03マスカレード — 円上の2倍ピンの数学
円上のn番ピンから2n番ピンへ巻く規則だけで心臓曲線が完成します。角度の倍数と曲線の対応をスライダーで触る数学実験項目です。
使いどころ数学教育、パラメータ実験
Math.sinMath.coslineTostrokeStyle

<main class="stage">
<div class="sc is-demo" id="scRoot" role="application" aria-label="倍数規則実験盤" tabindex="0">
<canvas class="sc__cv" id="scCv"></canvas>
<p class="sc__hint">
<span class="sc__title">マスカレイド</span>
<span class="sc__desc">n番ピンからk倍ピンへ巻く規則だけで、曲線の種類が決まります</span>
</p>
<span class="sc__rule" id="scRule">規則 k = 2 · 曲線マスカレイド</span>
<div class="sc__bar">
<span class="sc__meta">ピン120本 · 倍数kは2·3·4で循環 · 周期2秒</span>
<span class="sc__meta sc__meta--dim">sin·cos · strokeStyle</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트링아트 마스카레이드(03) — @keyframes 를 두지 않는다. 움직임은 전부 캔버스 rAF 루프(k 규칙 재계산)가 만든다.
// 무대 로즈 파스텔 — 따뜻한 판 위 크림 실로 심장 곡선을 띄운다. 캔버스가 판 전체다.
$sc-stage: #c49d92;
@include stage($sc-stage);
$sc-panel: #d0aa9f; // 감기판 바닥 — 무대보다 한 단계 밝은 연로즈
$sc-text: #4a1d0d; // 라벨 글자 — 진적갈 잉크
$sc-thread: #fdf3e0; // 실 — 크림(연로즈 판 위 직선)
$sc-hot: #fffbef; // 포락선 꼭짓점쪽 현 — 더 밝은 크림
$sc-pin: #3a180b; // 핀 — 진갈먹
$duration: $dur-loop;
$easing: $ease-out;
$color: $sc-thread;
.sc {
--sc-thread: #{$sc-thread};
--sc-hot: #{$sc-hot};
--sc-pin: #{$sc-pin};
--sc-panel: #{$sc-panel};
position: relative;
display: flex; flex-direction: column;
width: 100%; height: 100%; min-height: 164px; // 폰 뷰포트(320×200) 안 — 스테이지 패딩 2회분 여유
overflow: hidden;
}
.sc__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sc__hint {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 0; margin: 0;
padding: $sp-2 $sp-3 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $sc-text;
}
.sc__desc {
margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
color: rgba($sc-text, .78);
}
.sc__rule {
position: absolute; z-index: 1;
left: $sp-2; bottom: $sp-6;
font-weight: 600; font-size: 10px; line-height: 1.4;
color: $sc-text;
background: rgba($sc-panel, .9);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sc__bar {
position: relative; z-index: 1;
margin-top: auto;
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-3 $sp-2;
}
.sc__meta {
font-size: 10px; font-weight: 500; color: rgba($sc-text, .78);
background: rgba($sc-panel, .8);
border-radius: $r-xs;
padding: 4px $sp-1;
}
.sc:focus-visible { outline: 2px solid $sc-thread; outline-offset: -2px; }
@media (max-width: 340px) {
.sc__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 k=2 카디오이드 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sc.is-still { 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: #c49d92;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
--sc-thread: #fdf3e0;
--sc-hot: #fffbef;
--sc-pin: #3a180b;
--sc-panel: #d0aa9f;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 164px;
overflow: hidden;
}
.sc__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sc__hint {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 8px 12px 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #4a1d0d;
}
.sc__desc {
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(74, 29, 13, 0.78);
}
.sc__rule {
position: absolute;
z-index: 1;
left: 8px;
bottom: 24px;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
color: #4a1d0d;
background: rgba(208, 170, 159, 0.9);
border-radius: 4px;
padding: 4px 4px;
}
.sc__bar {
position: relative;
z-index: 1;
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 12px 8px;
}
.sc__meta {
font-size: 10px;
font-weight: 500;
color: rgba(74, 29, 13, 0.78);
background: rgba(208, 170, 159, 0.8);
border-radius: 4px;
padding: 4px 4px;
}
.sc:focus-visible {
outline: 2px solid #fdf3e0;
outline-offset: -2px;
}
@media (max-width: 340px) {
.sc__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-still {
cursor: default !important;
}
}// 스트링아트 マスカレイド(03) — 원 위 n번 핀에서 k·n번 핀으로 감는 배수 규칙의 수학.
// k=2 는 カルジオイド(マスカレイド 곡선), k=3 은 ネフロイド, k=4 는 3꽃잎 에피사이클로이드.
// 현들이 겹쳐 밝아지는 자리가 이 곡선들을 포락한다 — 극방정식 계열의 하필(下匹) 현 밝기 계산.
// k 가 구간마다 2→3→4 로 바뀌면 곡선 전체가 다시 짜인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N = 120; // 핀 개수 — 60으로 내리면 곡선이 각져 보인다(변형 2)
var CYCLE = 2.0; // k 구간 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('scRoot');
var cvEl = document.getElementById('scCv');
var ruleEl = document.getElementById('scRule');
if (!root || !cvEl) return;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var COL_THRD = tokRGB('--sc-thread');
var COL_PIN = tokRGB('--sc-pin');
var COL_HOT = tokRGB('--sc-hot');
var COL_PANEL = tokRGB('--sc-panel');
var ctx = cvEl.getContext('2d');
var W = 0, H = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);
var R = 0;
var RULE_NAMES = ['カルジオイド', 'ネフロイド', '3弁エピサイクロイド'];
function fit() {
var r = root.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = Math.round(W * dpr); cvEl.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
R = Math.min(W, H) * 0.54;
}
function pinXY(i) {
var a = Math.PI * 2 * i / N - Math.PI / 2;
return [W / 2 + Math.cos(a) * R, H / 2 + Math.sin(a) * R];
}
// k 를 구간마다 2→3→4 로 바꾼다. 감는 동안은 현재 구간의 k 로 전부 감고,
// 구간이 바뀌는 순간 곡선 전체가 다시 짜인다(실을 다 감는 것과 같은 계산).
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + COL_PANEL.join(',') + ')';
ctx.fillRect(0, 0, W, H);
var phase = (t / CYCLE) % 3; // 0..3 — 구간마다 k
var k = 2 + Math.floor(phase); // 2, 3, 4
var local = phase - Math.floor(phase); // 구간 안 진행 0..1
var label = '規則 k = ' + k + ' · 曲線 ' + RULE_NAMES[k - 2];
// 감기 진행 — 구간 앞 70% 에 걸어 다 감고 뒤 30% 유지(다 감긴 곡선을 볼 시간)
var progress = Math.min(1, local / 0.7);
var total = N; // 한 번에 보이는 올 수
var full = Math.floor(progress * total);
ctx.lineCap = 'round';
ctx.shadowBlur = 5;
for (var i = 0; i < full; i++) {
var a = pinXY(i), b = pinXY((i * k) % N);
// 중심에 가까워지는 현(포락선 꼭짓점 쪽)을 강조 + 최근 올 강조
var mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
var near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / R);
var recency = (full - i) / total;
var hot = near > 0.55;
var alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
var base = hot ? COL_HOT : COL_THRD;
ctx.strokeStyle = 'rgba(' + base.join(',') + ',' + Math.min(0.97, alpha).toFixed(3) + ')';
ctx.shadowColor = 'rgba(' + COL_HOT.join(',') + ',' + (alpha * 0.45).toFixed(3) + ')';
ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 순차적으로 숨쉰다(유지 구간에도 맥동)
for (var p = 0; p < N; p++) {
var xy = pinXY(p);
var pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = 'rgba(' + COL_PIN.join(',') + ',' + (0.55 + 0.4 * pulse).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
}
if (ruleEl) ruleEl.textContent = label;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.45);
});
fit();
if (reduce) {
draw(0.45); // reduce — k=2 カルジオイド 다 감긴 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// StringCardioidCircleMath — vanilla 판(run/326/demo/03-string-cardioid-circle-math/index.html) 의 React 포트.
// 원 위 n번 핀에서 k·n번 핀으로 감는 배수 규칙의 수학. k=2 는 카디오이드(마스카레이드 곡선),
// k=3 은 네프로이드, k=4 는 3꽃잎 에피사이클로이드. 현들이 겹쳐 밝아지는 자리가 이 곡선들을
// 포락한다. k 가 구간마다 2→3→4 로 바뀌면 곡선 전체가 다시 짜인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StringCardioidCircleMath.module.scss";
export type StringCardioidCircleMathProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 핀 개수 — 60으로 내리면 곡선이 각져 보인다(변형 2)
cycle?: number; // k 구간 주기(초)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const N = 120; // 핀 개수
const CYCLE = 2.0; // k 구간 주기(초) — 설정 초(2s)와 같아 한 구간이 한 루프
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type XY = [number, number];
const BOARD_LABEL = "배수 규칙 실험판";
const META_LEFT = "핀 120개 · 배수 k 순환 2·3·4 · 주기 2초";
const META_RIGHT = "sin·cos · strokeStyle";
const HINT_TITLE = "마스카레이드";
const HINT_DESC = "n번 핀에서 k배 핀으로 감는 규칙 하나로 곡선의 종류가 정해진다";
const RULE_NAMES = ["카디오이드", "네프로이드", "3꽃 에피사이클로이드"];
export default function StringCardioidCircleMath({
duration = "2s",
easing = "ease-out",
color = "#fdf3e0",
n = N,
cycle = CYCLE
}: StringCardioidCircleMathProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ thrd: RGB; pin: RGB; hot: RGB; panel: RGB } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [rule, setRule] = useState("규칙 k = 2 · 곡선 카디오이드");
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const root = rootRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!root || !cv || !ctx) return;
const r = root.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = Math.round(wRef.current * dpr);
cv.height = Math.round(hRef.current * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
rRef.current = Math.min(wRef.current, hRef.current) * 0.54;
}
function pinXY(i: number): XY {
const a = Math.PI * 2 * i / n - Math.PI / 2;
return [wRef.current / 2 + Math.cos(a) * rRef.current, hRef.current / 2 + Math.sin(a) * rRef.current];
}
// k 를 구간마다 2→3→4 로 바꾼다. 감는 동안은 현재 구간의 k 로 전부 감고,
// 구간이 바뀌는 순간 곡선 전체가 다시 짜인다(실을 다 감는 것과 같은 계산).
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.panel.join(",") + ")";
ctx.fillRect(0, 0, W, H);
const phase = (t / cycle) % 3; // 0..3 — 구간마다 k
const k = 2 + Math.floor(phase); // 2, 3, 4
const local = phase - Math.floor(phase); // 구간 안 진행 0..1
const label = "규칙 k = " + k + " · 곡선 " + RULE_NAMES[k - 2];
// 감기 진행 — 구간 앞 70% 에 걸어 다 감고 뒤 30% 유지(다 감긴 곡선을 볼 시간)
const progress = Math.min(1, local / 0.7);
const total = n; // 한 번에 보이는 올 수
const full = Math.floor(progress * total);
ctx.lineCap = "round";
ctx.shadowBlur = 5;
for (let i = 0; i < full; i++) {
const a = pinXY(i), b = pinXY((i * k) % n);
// 중심에 가까워지는 현(포락선 꼭짓점 쪽)을 강조 + 최근 올 강조
const mx = (a[0] + b[0]) / 2 - W / 2, my = (a[1] + b[1]) / 2 - H / 2;
const near = 1 - Math.min(1, Math.sqrt(mx * mx + my * my) / rRef.current);
const recency = (full - i) / total;
const hot = near > 0.55;
const alpha = (hot ? 0.72 : 0.58) + 0.26 * Math.pow(recency, 1.5);
const base = hot ? col.hot : col.thrd;
ctx.strokeStyle = "rgba(" + base.join(",") + "," + Math.min(0.97, alpha).toFixed(3) + ")";
ctx.shadowColor = "rgba(" + col.hot.join(",") + "," + (alpha * 0.45).toFixed(3) + ")";
ctx.lineWidth = (hot ? 1.6 : 1.2) + recency * 1.1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.shadowBlur = 0;
// 핀 — 순차적으로 숨쉰다(유지 구간에도 맥동)
for (let p = 0; p < n; p++) {
const xy = pinXY(p);
const pulse = 0.5 + 0.5 * Math.sin(t * 4 - p * 0.3);
ctx.fillStyle = "rgba(" + col.pin.join(",") + "," + (0.55 + 0.4 * pulse).toFixed(3) + ")";
ctx.beginPath(); ctx.arc(xy[0], xy[1], 1.2 + 1.1 * pulse, 0, Math.PI * 2); ctx.fill();
}
setRule(label); // 규칙 라벨 — vanilla 판의 ruleEl.textContent 갱신에 해당
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
thrd: tokRGB(root, "--sc-thread"),
pin: tokRGB(root, "--sc-pin"),
hot: tokRGB(root, "--sc-hot"),
panel: tokRGB(root, "--sc-panel")
};
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.45);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.45); // reduce — k=2 카디오이드 다 감긴 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div
className={rootCls}
ref={rootRef}
style={vars}
role="application"
aria-label={BOARD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<p className={styles.hint}>
<span className={styles.title}>{HINT_TITLE}</span>
<span className={styles.desc}>{HINT_DESC}</span>
</p>
<span className={styles.rule}>{rule}</span>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}03 マスカレード — 円上の2倍ピンの数学 — 計測 累積面積 33.918% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ストリングアート9種 — 糸の一本一本を計算するキャンバス
このデモが属する記事
ストリングアート9種 — 糸の一本一本を計算するキャンバスピンの間を糸が規則で飛び移ると曲線がひとりでに生まれます。どこからどこへ巻くかを毎回計算するキャンバスエンジン — ライブラリなしのコピペです。ストリングアートとは、板に打ったピンの間に糸を規則で渡していくと、どこにも描いていない円弧や心臓 この記事のデモをすべて見る


