01基本の万華鏡ブラシ — 1本の線が2n本に増える反射
pointermoveで受けた線分をsetTransformで1つの扇形に描き、残りは2π/nずつ回転とy反転の変換で同じパスを再描画します。ブラシ1本が16本に分かれます。
使いどころお絵かきアプリ、遊び画面
setTransformlineTo

<main class="stage">
<div class="kb" id="kb">
<p class="kb__hint">1本の線を描くと反射行列が残りの扇形をすべて埋めます</p>
<div class="kb__row">
<div class="kb__field" id="field" role="application" aria-label="万華鏡ブラシ実験盤" tabindex="0">
<canvas class="kb__cv" id="cv"></canvas>
</div>
<div class="kb__side">
<b class="kb__num" id="parts">16</b>
<span class="kb__unit">反射の枝 2n</span>
<p class="kb__meta">次数 n = 8</p>
<p class="kb__meta"><b class="kb__count" id="cnt">0</b><span class="kb__metaUnit">ストローク</span></p>
<p class="kb__meta">マウスや指で直接描いてみてください</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 만화경 붓(01) — 움직임은 전부 캔버스 rAF 루프 안에 있다(077 방식). CSS @keyframes 를
// 두지 않는다 — 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다.
// 선빛깔 세 개는 파스텔 팔레트(채도 .34) — 면 위 명도 .74 / 어두운 보조 .46.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.kb {
--c1: #a6b4d3; // 파랑빛 파스텔 — 붓 첫째
--c2: #d3a6c8; // 연분홍 파스텔 — 붓 둘째
--c3: #a6d3b1; // 연두 파스텔 — 붓 셋째
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.kb__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.kb__row { display: flex; gap: $sp-2; align-items: stretch; }
.kb__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-cream, .04);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.kb__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.kb__cv { display: block; width: 100%; height: 100%; }
.kb__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.kb__num {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.kb__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .82); }
.kb__meta { margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .82); }
.kb__meta b { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.kb__metaUnit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.kb { gap: $sp-1; }
.kb__field { width: 150px; height: 150px; }
.kb__num { font-size: 16px; }
.kb__meta { margin-top: 0; font-size: 8px; }
}
// reduce — 루프가 안 돈다. 미리 그려 둔 정적 무늬 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.kb.is-still .kb__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kb {
--c1: #a6b4d3;
--c2: #d3a6c8;
--c3: #a6d3b1;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.kb__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.kb__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.kb__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(176px, 46vw, 232px);
height: clamp(176px, 46vw, 232px);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.kb__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.kb__cv {
display: block;
width: 100%;
height: 100%;
}
.kb__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.kb__num {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.kb__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.82);
}
.kb__meta {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
}
.kb__meta b {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.kb__metaUnit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.kb {
gap: 4px;
}
.kb__field {
width: 150px;
height: 150px;
}
.kb__num {
font-size: 16px;
}
.kb__meta {
margin-top: 0;
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb.is-still .kb__field {
cursor: default !important;
}
}// 만화경 붓 — 반사 행렬이 핵심이다. k번째 부채꼴의 변환은 회전 R(a) 와 y축 반사의 곱
// [[cos a, sin a], [sin a, -cos a]] (a = k · 2π/n). 선분 하나를 이 행렬로 n번 찍으면
// 좌우 반사까지 합쳐 2n 갈래의 대칭 무늬가 그린 순간에 생긴다. 경로는 파라미터 함수라
// 시드 난수 없이도 항상 같은 데모가 반복된다.
(function () {
var root = document.getElementById('kb');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var parts = document.getElementById('parts');
var cnt = document.getElementById('cnt');
if (!root || !field || !cv || !parts || !cnt) return;
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var COLS = [css.getPropertyValue('--c1').trim(), css.getPropertyValue('--c2').trim(), css.getPropertyValue('--c3').trim()];
var N = 8; // 차수 n — 반사까지 합치면 2N = 16 갈래
parts.textContent = String(2 * N);
var strokes = 0;
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 - 6;
}
// 반사 행렬 스트로크 — 선분 하나를 2N 위치에 찍는다. setTransform 의 여섯 인수는
// (a, b, c, d, e, f) = 행렬 2×2 + 이동. 여기 곱한 행렬이 곧 회전×반사다
function mirroredSeg(x0, y0, x1, y1, ci) {
for (var k = 0; k < N; k++) {
var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
ctx.strokeStyle = COLS[ci % 3];
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
strokes += 1;
cnt.textContent = String(strokes);
}
// 시드 고정 자동 ストローク — 붓 세 자루가 위상을 달리해 함께 돈다
function pathAt(t, ph) {
var ang = t * 1.55 + ph + 0.9 * Math.sin(t * 0.31 + ph);
var r = R * (0.30 + 0.34 * Math.sin(t * 0.47 + ph * 2) + 0.20 * Math.sin(t * 0.13 + ph));
if (r < R * 0.07) r = R * 0.07;
return [cx + r * Math.cos(ang), cy + r * Math.sin(ang)];
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t = 0, lastInput = -9, pen = false, penPt = null;
var PH = [0, 2.09, 4.19]; // 붓 세 자루의 위상 (2π/3 간격)
var prev = [null, null, null];
function toLocal(e) {
var b = field.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
}
field.addEventListener('pointerdown', function (e) {
pen = true; penPt = toLocal(e);
if (field.setPointerCapture) { try { field.setPointerCapture(e.pointerId); } catch (err) { /* 캡처 실패는 그리기에 영향 없다 */ }
}
});
field.addEventListener('pointermove', function (e) {
if (!pen) return;
var p = toLocal(e);
mirroredSeg(penPt[0], penPt[1], p[0], p[1], strokes);
penPt = p; lastInput = t;
});
field.addEventListener('pointerup', function () { pen = false; lastInput = t; });
field.addEventListener('pointercancel', function () { pen = false; lastInput = t; });
var last = 0;
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (!pen && t - lastInput > 1.6) {
t += dt;
for (var i = 0; i < PH.length; i++) {
var p = pathAt(t, PH[i]);
if (prev[i]) mirroredSeg(prev[i][0], prev[i][1], p[0], p[1], Math.floor(t * 0.9 + i));
prev[i] = p;
}
}
requestAnimationFrame(step);
}
fit();
window.addEventListener('resize', fit);
if (reduce) {
// 정지 한 장 — 시간 4초 분의 붓질을 미리 그려 둔다. 루프는 돌지 않는다
for (var j = 1; j <= 200; j++) {
var tt = j * 0.02;
for (var i2 = 0; i2 < PH.length; i2++) {
var a0 = pathAt(tt - 0.02, PH[i2]), a1 = pathAt(tt, PH[i2]);
mirroredSeg(a0[0], a0[1], a1[0], a1[1], Math.floor(tt * 0.9 + i2));
}
}
root.classList.add('is-still');
} else {
last = performance.now();
requestAnimationFrame(step);
}
})();// KaleidoMirrorDrawCanvas — vanilla 판(run/331/demo/01-kaleido-mirror-draw-canvas/index.html) 의 React 포트.
// 반사 행렬이 핵심이다. k번째 부채꼴의 변환은 회전 R(a) 와 y축 반사의 곱
// [[cos a, sin a], [sin a, -cos a]] (a = k · 2π/n). 선분 하나를 이 행렬로 n번 찍으면
// 좌우 반사까지 합쳐 2n 갈래의 대칭 무늬가 그린 순간에 생긴다. 경로는 파라미터 함수라
// 시드 난수 없이도 항상 같은 데모가 반복된다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 시간 4초 분의 붓질을 미리 그린 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./KaleidoMirrorDrawCanvas.module.scss";
export type KaleidoMirrorDrawCanvasProps = {
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 = 8; // 차수 n — 반사까지 합치면 2N = 16 갈래
const PH = [0, 2.09, 4.19]; // 붓 세 자루의 위상 (2π/3 간격)
const COLS = ["#a6b4d3", "#d3a6c8", "#a6d3b1"]; // 파랑빛·연분홍·연두 파스텔 — 붓 셋
const HINT = "선 하나를 그리면 반사 행렬이 나머지 칸을 전부 채웁니다";
const ARIA_FIELD = "만화경 붓 실험판";
const PARTS_UNIT = "반사 갈래 2n";
const ORDER_TEXT = "차수 n = 8";
const STROKE_UNIT = "붓질";
const DRAW_TEXT = "마우스나 손가락으로 직접 그려 보세요";
export default function KaleidoMirrorDrawCanvas({
duration = "2s",
easing = "ease-out",
color = "#6ec8ff"
}: KaleidoMirrorDrawCanvasProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const cntRef = 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,
t: 0, strokes: 0, lastInput: -9,
pen: false, penPt: null as [number, number] | null,
prev: [null, null, null] as Array<[number, number] | null>
}) as Box<{
W: number; H: number; dpr: number; cx: number; cy: number; R: number;
strokes: number; t: number; lastInput: number;
pen: boolean; penPt: [number, number] | null;
prev: Array<[number, number] | null>
}>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
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 - 6;
ctxRef.current = ctx;
}
// 반사 행렬 스트로크 — 선분 하나를 2N 위치에 찍는다. setTransform 의 여섯 인수는
// (a, b, c, d, e, f) = 행렬 2×2 + 이동. 여기 곱한 행렬이 곧 회전×반사다
function mirroredSeg(x0: number, y0: number, x1: number, y1: number, ci: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
for (let k = 0; k < N; k++) {
const a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
ctx.setTransform(st.dpr * c, st.dpr * s, st.dpr * s, -st.dpr * c, st.cx * st.dpr, st.cy * st.dpr);
ctx.strokeStyle = COLS[ci % 3];
ctx.lineWidth = 6;
ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
}
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
st.strokes += 1;
const out = cntRef.current;
if (out) out.textContent = String(st.strokes);
}
// 시드 고정 자동 붓질 — 붓 세 자루가 위상을 달리해 함께 돈다
function pathAt(t: number, ph: number): [number, number] {
const st = stRef.current;
const ang = t * 1.55 + ph + 0.9 * Math.sin(t * 0.31 + ph);
let r = st.R * (0.30 + 0.34 * Math.sin(t * 0.47 + ph * 2) + 0.20 * Math.sin(t * 0.13 + ph));
if (r < st.R * 0.07) r = st.R * 0.07;
return [st.cx + r * Math.cos(ang), st.cy + r * Math.sin(ang)];
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000; lastRef.current = now;
if (!st.pen && st.t - st.lastInput > 1.6) {
st.t += dt;
for (let i = 0; i < PH.length; i++) {
const p = pathAt(st.t, PH[i]);
const pv = st.prev[i];
if (pv) mirroredSeg(pv[0], pv[1], p[0], p[1], Math.floor(st.t * 0.9 + i));
st.prev[i] = p;
}
}
rafRef.current = requestAnimationFrame(step);
}
function toLocal(e: PointerEvent<HTMLDivElement>): [number, number] {
const b = e.currentTarget.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
}
function onPointerDown(e: PointerEvent<HTMLDivElement>): void {
const st = stRef.current;
st.pen = true; st.penPt = toLocal(e);
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 그리기에 영향 없다 */ }
}
function onPointerMove(e: PointerEvent<HTMLDivElement>): void {
const st = stRef.current;
if (!st.pen || !st.penPt) return;
const p = toLocal(e);
mirroredSeg(st.penPt[0], st.penPt[1], p[0], p[1], st.strokes);
st.penPt = p; st.lastInput = st.t;
}
function onPointerUp(): void {
const st = stRef.current;
st.pen = false; st.lastInput = st.t;
}
useEffect(() => {
fit();
function onResize(): void { fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 시간 4초 분의 붓질을 미리 그려 둔다. 루프는 돌지 않는다
for (let j = 1; j <= 200; j++) {
const tt = j * 0.02;
for (let i = 0; i < PH.length; i++) {
const a0 = pathAt(tt - 0.02, PH[i]), a1 = pathAt(tt, PH[i]);
mirroredSeg(a0[0], a0[1], a1[0], a1[1], Math.floor(tt * 0.9 + i));
}
}
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.kb + " " + styles.isStill : styles.kb;
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}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.side}>
<b className={styles.num}>{2 * N}</b>
<span className={styles.unit}>{PARTS_UNIT}</span>
<p className={styles.meta}>{ORDER_TEXT}</p>
<p className={styles.meta}><b ref={cntRef}>0</b><span className={styles.metaUnit}>{STROKE_UNIT}</span></p>
<p className={styles.meta}>{DRAW_TEXT}</p>
</div>
</div>
</div>
</main>
);
}01 基本の万華鏡ブラシ — 1本の線が2n本に増える反射 — 計測 累積面積 5.295% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 万華鏡 アプリ — 鏡の角度で描く対称お絵かき9種
このデモが属する記事
万華鏡 アプリ — 鏡の角度で描く対称お絵かき9種1本の線を描くと反射行列が残りの扇形をすべて埋めます。鏡の角度・対称次数・色相サイクルを自分で変える万華鏡描画ツール — ライブラリなしのキャンバス1枚、コードはパスワード付きzipでお渡しします。万華鏡 アプリと検索した人がブラウザで探し この記事のデモをすべて見る


