07ランゴリグリッド — 点から織る対称の花輪
極座標グリッドに点を打つと、対称全体に同じ点が巡る配置が計算されます。点を結ぶ曲線を重ねれば手描きの花輪模様が整います。
使いどころ祭りグラフィック、カラーパレット遊び
arcchange

<main class="stage">
<div class="kr" id="kr">
<p class="kr__hint">極座標の交点を順に打ち、隣り合う点を曲線で結びます</p>
<div class="kr__row">
<div class="kr__field" id="field" role="application" aria-label="ランゴリ格子実験盤" tabindex="0">
<canvas class="kr__cv" id="cv"></canvas>
</div>
<div class="kr__side">
<label class="kr__lab" for="link">結び模様</label>
<select class="kr__sel" id="link" aria-label="結び模様">
<option value="0">花びら結び</option>
<option value="1">ジグザグ</option>
<option value="2">星の網</option>
</select>
<b class="kr__num" id="dots">0</b>
<span class="kr__unit">打った点</span>
<p class="kr__meta">点は順に増えます — 層が変わると曲線も結び直します</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 랑골 격자(07) — rAF 캔버스 데모. 극좌표 격자 점을 하나씩 찍고 곡선으로 잇는다.
// 주황 무대 위 짙은 파스텔 점·선. select 변경(change)이 무늬 층을 갈아끼운다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.kr {
--c1: #9d4d54; // 붉은기 파스텔 짙음 — 점
--c2: #854d9d; // 보라 파스텔 짙음 — 연결 곡선
--c3: #4d9d61; // 연두 파스텔 짙음 — 씨앗 점
--grid: #{rgba($subject-ink, .12)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.kr__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.kr__row { display: flex; gap: $sp-2; align-items: stretch; }
.kr__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: $r-card;
background: rgba($subject-ink, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .18);
cursor: crosshair; touch-action: none;
}
.kr__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.kr__cv { display: block; width: 100%; height: 100%; }
.kr__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.kr__lab { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.kr__sel {
width: 100%; margin: 0; padding: $sp-1;
font-family: inherit; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.4;
color: $subject-ink;
background: transparent;
border: 1px solid rgba($subject-ink, .32); border-radius: $r-control;
}
.kr__sel:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.kr__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $subject-ink; font-variant-numeric: tabular-nums;
}
.kr__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.kr__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .82); }
@media (max-width: 340px) {
.kr { gap: $sp-1; }
.kr__field { width: 150px; height: 150px; }
.kr__num { font-size: 16px; }
.kr__meta { margin-top: 0; font-size: 8px; }
}
// reduce — 루프가 안 돈다. 완성 랑골 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.kr.is-still .kr__field { cursor: default !important; }
.kr.is-still .kr__sel { pointer-events: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kr {
--c1: #9d4d54;
--c2: #854d9d;
--c3: #4d9d61;
--grid: rgba(23, 20, 26, 0.12);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.kr__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.kr__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.kr__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.18);
cursor: crosshair;
touch-action: none;
}
.kr__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.kr__cv {
display: block;
width: 100%;
height: 100%;
}
.kr__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.kr__lab {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.kr__sel {
width: 100%;
margin: 0;
padding: 4px;
font-family: inherit;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
color: #17141a;
background: transparent;
border: 1px solid rgba(23, 20, 26, 0.32);
border-radius: 12px;
}
.kr__sel:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.kr__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.kr__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.kr__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.82);
}
@media (max-width: 340px) {
.kr {
gap: 4px;
}
.kr__field {
width: 150px;
height: 150px;
}
.kr__num {
font-size: 16px;
}
.kr__meta {
margin-top: 0;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kr.is-still .kr__field {
cursor: default !important;
}
.kr.is-still .kr__sel {
pointer-events: none !important;
}
}// 랑골 격자 — 인도 랑골(바닥 그림)의 극좌표 점 배치를 재현한다. 링 r 개 × 스포크 N 개
// 격자점을 안쪽 링부터 바깥까지 나선 순서로 찍는다. 점을 다 찍은 뒤엔 이웃 링의 점을
// quadraticCurveTo 로 잇는 곡선 층을 올린다. 무늬(꽃잎·ジグザグ·별그물)는 링 간
// 스포크 오프셋으로 만든다 — change 이벤트로 갈아끼운다.
(function () {
var root = document.getElementById('kr');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var link = document.getElementById('link');
var dots = document.getElementById('dots');
if (!root || !field || !cv || !link || !dots) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var C1 = css.getPropertyValue('--c1').trim();
var C2 = css.getPropertyValue('--c2').trim();
var C3 = css.getPropertyValue('--c3').trim();
var GRID = css.getPropertyValue('--grid').trim();
var N = 16; // 스포크 수
var RINGS = 4; // 링 수
var pattern = 0; // 0 꽃잎 · 1 ジグザグ · 2 별그물
var W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0;
function fit() {
W = field.clientWidth; H = field.clientHeight;
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
cx = W / 2; cy = H / 2; R = Math.min(W, H) / 2 - 8;
}
function pt(ring, k) {
var off = (pattern === 1 && ring % 2 === 1) ? 0.5 : 0; // 지그재그는 링마다 반 マス
var r = R * (ring + 1) / (RINGS + 1);
var a = (k + off) * 2 * Math.PI / N;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
function drawDot(x, y, s, col) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(x, y, s, 0, 2 * Math.PI); ctx.fill();
}
// 곡선 연결 — 링 ring 과 ring+1 사이. 무늬별로 잇는 상대 스포크가 다르다
function drawLink(ring, k) {
var a0 = pt(ring, k), a1;
var step = pattern === 2 ? Math.round(N / 5) : (pattern === 1 ? 0.5 : (ring % 2 === 0 ? 1 : -1));
if (pattern === 1) {
a1 = pt(ring + 1, k + ((k % 2 === 0) ? 0.5 : -0.5));
} else {
a1 = pt(ring + 1, k + step);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = C2; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(a0[0], a0[1]);
var mx = (a0[0] + a1[0]) / 2 + (a1[1] - a0[1]) * 0.35;
var my = (a0[1] + a1[1]) / 2 - (a1[0] - a0[0]) * 0.35;
ctx.quadraticCurveTo(mx, my, a1[0], a1[1]);
ctx.stroke();
}
// 격자 안내 — 옅은 링
function drawGrid() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = GRID; ctx.lineWidth = 1;
for (var g = 1; g <= RINGS; g++) {
ctx.beginPath(); ctx.arc(cx, cy, R * g / (RINGS + 1), 0, 2 * Math.PI); ctx.stroke();
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var total = RINGS * N;
var t = 0, last = 0, fired = 0, phase2 = false, linked = 0;
link.addEventListener('change', function () {
pattern = parseInt(link.value, 10) || 0;
redrawAll();
});
function redrawAll() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
drawGrid();
for (var i = 0; i < fired; i++) placeDot(i);
for (var j = 0; j < linked; j++) placeLink(j);
}
function placeDot(i) {
var ring = Math.floor(i / N), k = i % N;
var p = pt(ring, k);
drawDot(p[0], p[1], 2.4 + ring * 0.5, ring === 0 ? C3 : C1);
}
function placeLink(j) {
var ring = Math.floor(j / N), k = j % N;
if (ring < RINGS - 1) drawLink(ring, k);
}
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
t += dt;
if (!phase2) {
var want = Math.min(total, Math.floor(t / 0.045)); // 점 성상 속도
while (fired < want) { placeDot(fired); fired += 1; }
dots.textContent = String(fired);
if (fired >= total) phase2 = true;
} else {
var want2 = Math.floor((t - total * 0.045) / 0.06); // 곡선 연결 속도
while (linked < Math.min(want2, (RINGS - 1) * N)) { placeLink(linked); linked += 1; }
if (linked >= (RINGS - 1) * N) {
// 한 바퀴 끝 — 2초 쉬고 다음 무늬로
t = -2; fired = 0; linked = 0; phase2 = false;
pattern = (pattern + 1) % 3;
link.value = String(pattern);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
drawGrid();
dots.textContent = '0';
}
}
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 점·곡선 전부 완성된 랑골
for (var i2 = 0; i2 < total; i2++) placeDot(i2);
for (var j2 = 0; j2 < (RINGS - 1) * N; j2++) placeLink(j2);
dots.textContent = String(total);
root.classList.add('is-still');
} else {
last = performance.now();
requestAnimationFrame(step);
}
})();// KaleidoRangoliDotGrid — vanilla 판(run/331/demo/07-kaleido-rangoli-dot-grid/index.html) 의 React 포트.
// 인도 랑골(바닥 그림)의 극좌표 점 배치를 재현한다. 링 r 개 × 스포크 N 개 격자점을
// 안쪽 링부터 바깥까지 나선 순서로 찍는다. 점을 다 찍은 뒤엔 이웃 링의 점을
// quadraticCurveTo 로 잇는 곡선 층을 올린다. 무늬(꽃잎·지그재그·별그물)는 링 간
// 스포크 오프셋으로 만든다 — change 이벤트로 갈아끼운다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 점·곡선 전부 완성된 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./KaleidoRangoliDotGrid.module.scss";
export type KaleidoRangoliDotGridProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const N = 16; // 스포크 수
const RINGS = 4; // 링 수
const C1 = "#9d4d54"; // 붉은기 파스텔 짙음 — 점
const C2 = "#854d9d"; // 보라 파스텔 짙음 — 연결 곡선
const C3 = "#4d9d61"; // 연두 파스텔 짙음 — 씨앗 점
const GRID = "rgba(23, 20, 26, 0.12)"; // 옅은 링 안내선
const HINT = "극좌표 격자점을 차례로 찍고 이웃 점을 곡선으로 잇습니다";
const ARIA_FIELD = "랑골 격자 실험판";
const LINK_LABEL = "연결 무늬";
const PATTERNS = ["꽃잎 잇기", "지그재그", "별 그물"];
const LINK_ARIA = "연결 무늬";
const DOTS_UNIT = "찍힌 점";
const GROW_TEXT = "점은 차례로 자랍니다 — 층이 바뀌면 곡선도 다시 잇습니다";
export default function KaleidoRangoliDotGrid({
duration = "2s",
easing = "ease-out",
color = "#17141a"
}: KaleidoRangoliDotGridProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const selRef = useRef<HTMLSelectElement | null>(null);
const dotsRef = useRef<HTMLSpanElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, dpr: 1, cx: 0, cy: 0, R: 0,
pattern: 0, t: 0, fired: 0, phase2: false, linked: 0
}) as Box<{
W: number; H: number; dpr: number; cx: number; cy: number; R: number;
pattern: number; t: number; fired: number; phase2: boolean; linked: number;
}>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const total = RINGS * N;
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
st.W = field.clientWidth; st.H = field.clientHeight;
st.dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(st.W * st.dpr); cv.height = Math.round(st.H * st.dpr);
st.cx = st.W / 2; st.cy = st.H / 2; st.R = Math.min(st.W, st.H) / 2 - 8;
ctxRef.current = ctx;
}
function pt(ring: number, k: number): [number, number] {
const st = stRef.current;
const off = (st.pattern === 1 && ring % 2 === 1) ? 0.5 : 0; // 지그재그는 링마다 반 칸
const r = st.R * (ring + 1) / (RINGS + 1);
const a = (k + off) * 2 * Math.PI / N;
return [st.cx + r * Math.cos(a), st.cy + r * Math.sin(a)];
}
function drawDot(x: number, y: number, s: number, col: string): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(x, y, s, 0, 2 * Math.PI); ctx.fill();
}
// 곡선 연결 — 링 ring 과 ring+1 사이. 무늬별로 잇는 상대 스포크가 다르다
function drawLink(ring: number, k: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const a0 = pt(ring, k);
const step = st.pattern === 2 ? Math.round(N / 5) : (st.pattern === 1 ? 0.5 : (ring % 2 === 0 ? 1 : -1));
let a1: [number, number];
if (st.pattern === 1) {
a1 = pt(ring + 1, k + ((k % 2 === 0) ? 0.5 : -0.5));
} else {
a1 = pt(ring + 1, k + step);
}
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.strokeStyle = C2; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(a0[0], a0[1]);
const mx = (a0[0] + a1[0]) / 2 + (a1[1] - a0[1]) * 0.35;
const my = (a0[1] + a1[1]) / 2 - (a1[0] - a0[0]) * 0.35;
ctx.quadraticCurveTo(mx, my, a1[0], a1[1]);
ctx.stroke();
}
// 격자 안내 — 옅은 링
function drawGrid(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.strokeStyle = GRID; ctx.lineWidth = 1;
for (let g = 1; g <= RINGS; g++) {
ctx.beginPath(); ctx.arc(st.cx, st.cy, st.R * g / (RINGS + 1), 0, 2 * Math.PI); ctx.stroke();
}
}
function redrawAll(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.clearRect(0, 0, st.W, st.H);
drawGrid();
for (let i = 0; i < st.fired; i++) placeDot(i);
for (let j = 0; j < st.linked; j++) placeLink(j);
}
function placeDot(i: number): void {
const ring = Math.floor(i / N), k = i % N;
const p = pt(ring, k);
drawDot(p[0], p[1], 2.4 + ring * 0.5, ring === 0 ? C3 : C1);
}
function placeLink(j: number): void {
const ring = Math.floor(j / N), k = j % N;
if (ring < RINGS - 1) drawLink(ring, k);
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
st.t += dt;
const out = dotsRef.current;
if (!st.phase2) {
const want = Math.min(total, Math.floor(st.t / 0.045)); // 점 성상 속도
while (st.fired < want) { placeDot(st.fired); st.fired += 1; }
if (out) out.textContent = String(st.fired);
if (st.fired >= total) st.phase2 = true;
} else {
const want2 = Math.floor((st.t - total * 0.045) / 0.06); // 곡선 연결 속도
while (st.linked < Math.min(want2, (RINGS - 1) * N)) { placeLink(st.linked); st.linked += 1; }
if (st.linked >= (RINGS - 1) * N) {
// 한 바퀴 끝 — 2초 쉬고 다음 무늬로
st.t = -2; st.fired = 0; st.linked = 0; st.phase2 = false;
st.pattern = (st.pattern + 1) % 3;
const sel = selRef.current;
if (sel) sel.value = String(st.pattern);
const ctx = ctxRef.current;
if (ctx) {
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
ctx.clearRect(0, 0, st.W, st.H);
}
drawGrid();
if (out) out.textContent = "0";
}
}
rafRef.current = requestAnimationFrame(step);
}
function onSelectChange(): void {
const st = stRef.current;
const sel = selRef.current;
if (!sel) return;
st.pattern = parseInt(sel.value, 10) || 0;
redrawAll();
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 점·곡선 전부 완성된 랑골
for (let i = 0; i < total; i++) placeDot(i);
for (let j = 0; j < (RINGS - 1) * N; j++) placeLink(j);
const out = dotsRef.current;
if (out) out.textContent = String(total);
setStill(true);
} else {
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rootCls = still ? styles.kr + " " + styles.isStill : styles.kr;
return (
<main className={styles.stage}>
<div className={rootCls} style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row}>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={ARIA_FIELD}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.side}>
<label className={styles.lab} htmlFor="link">{LINK_LABEL}</label>
<select className={styles.sel} id="link" ref={selRef} aria-label={LINK_ARIA} defaultValue="0" onChange={onSelectChange}>
<option value="0">{PATTERNS[0]}</option>
<option value="1">{PATTERNS[1]}</option>
<option value="2">{PATTERNS[2]}</option>
</select>
<b className={styles.num}><span ref={dotsRef}>0</span></b>
<span className={styles.unit}>{DOTS_UNIT}</span>
<p className={styles.meta}>{GROW_TEXT}</p>
</div>
</div>
</div>
</main>
);
}07 ランゴリグリッド — 点から織る対称の花輪 — 計測 累積面積 2.192% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 万華鏡 アプリ — 鏡の角度で描く対称お絵かき9種
このデモが属する記事
万華鏡 アプリ — 鏡の角度で描く対称お絵かき9種1本の線を描くと反射行列が残りの扇形をすべて埋めます。鏡の角度・対称次数・色相サイクルを自分で変える万華鏡描画ツール — ライブラリなしのキャンバス1枚、コードはパスワード付きzipでお渡しします。万華鏡 アプリと検索した人がブラウザで探し この記事のデモをすべて見る

