09比・ペン・残像を一画面にまとめたヒーローギャラリー
歯数比の異なる曲線を複数、同じ画面の格子に並べ、それぞれ違う速度で描かせて、模様の違いを一目で比べるヒーローシーンを作る。
使いどころランディング・ブログのヒーロー背景デモ
HERO_RING_TEETHHERO_PEN_RATIOSHERO_TRAIL_FADE_FRAMES

<main class="stage">
<div class="sp9 is-demo" id="sp9Root" tabindex="0">
<canvas class="sp9__canvas" id="sp9Canvas" width="480" height="300"></canvas>
<p class="sp9__caption">歯数比の異なる複数の曲線を同じ画面グリッドに並べ、それぞれ違う速度で描かせ、模様同士の違いを一目で比べられるヒーローシーンを作る</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp9 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.sp9__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp9__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp9__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sp9 { cursor: default; }
}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);
}
.sp9 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sp9__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp9__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp9__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp9 {
cursor: default;
}
}(function () {
// 기법: 항목 01(단일 진행 곡선)·04(다중 펜 색 어긋남)·05(잔상 페이드)를 한 격자의
// 네 칸에 나눠 배정하고, 칸마다 다른 속도로 동시에 그려 비교 갤러리를 만든다.
var root = document.getElementById('sp9Root');
var canvas = document.getElementById('sp9Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var HERO_CELL_COUNT = 4;
var HERO_RING_TEETH = [60, 54, 48, 66];
var HERO_ROLL_TEETH = [23, 19, 17, 25];
var HERO_PEN_RATIO = 0.6;
var HERO_TOOTH_UNIT = 0.62;
var HERO_SPEED = [2.8, 3.4, 3.2, 3.8];
var HERO_TRAIL_FADE_FRAMES = 180;
var HERO_PEN_RATIOS = [0.35, 0.5, 0.65, 0.8];
var HERO_PEN_COLORS = ['rgba(230,90,120,.85)', 'rgba(50,175,135,.85)', 'rgba(70,130,230,.85)', 'rgba(228,150,45,.85)'];
var HERO_STEP_MS = 1000 / 60;
var HERO_FADE_ALPHA = Math.min(1, 3.0 / HERO_TRAIL_FADE_FRAMES);
var HERO_GAP_FRACTION = 0.03;
function gcd(a, b) { while (b) { var t = b; b = a % b; a = t; } return a; }
function makeCurve(ringTeeth, rollTeeth, penRatio) {
var R = ringTeeth * HERO_TOOTH_UNIT;
var r = rollTeeth * HERO_TOOTH_UNIT;
var d = r * penRatio;
var K = (R - r) / r;
var g = gcd(ringTeeth, rollTeeth);
var turns = rollTeeth / g;
return { R: R, r: r, d: d, K: K, turns: turns, maxT: 2 * Math.PI * turns, patternRadius: R + d };
}
function pointOn(curve, t) {
return {
x: (curve.R - curve.r) * Math.cos(t) + curve.d * Math.cos(curve.K * t),
y: (curve.R - curve.r) * Math.sin(t) - curve.d * Math.sin(curve.K * t)
};
}
var curveA = makeCurve(HERO_RING_TEETH[0], HERO_ROLL_TEETH[0], HERO_PEN_RATIO);
var curveB = makeCurve(HERO_RING_TEETH[1], HERO_ROLL_TEETH[1], HERO_PEN_RATIO);
var curveC = makeCurve(HERO_RING_TEETH[2], HERO_ROLL_TEETH[2], HERO_PEN_RATIO);
var curveD = makeCurve(HERO_RING_TEETH[3], HERO_ROLL_TEETH[3], HERO_PEN_RATIO);
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var randB = seedRand(43309);
var penPhasesB = [];
for (var pb = 0; pb < 4; pb += 1) penPhasesB.push(randB() * Math.PI * 2);
var cw = 480, ch = 300;
var cellW = 240, cellH = 150, gap = 6;
var cellPix = [];
var trailCanvas = document.createElement('canvas');
var trailCtx = trailCanvas.getContext('2d');
function fit() {
var rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas.width = cw; canvas.height = ch;
cellW = cw / 2; cellH = ch / 2;
gap = Math.min(cw, ch) * HERO_GAP_FRACTION;
cellPix = [];
var curves = [curveA, curveB, curveC, curveD];
for (var k = 0; k < HERO_CELL_COUNT; k += 1) {
var col = k % 2, row = Math.floor(k / 2);
var pw = cellW - gap * 2, ph = cellH - gap * 2;
var target = Math.min(pw, ph) / 2 * 0.88;
var sc = target / (curves[k].patternRadius * 1.05);
cellPix.push({
x: col * cellW + cellW / 2,
y: row * cellH + cellH / 2,
w: pw, h: ph,
scale: sc
});
}
trailCanvas.width = Math.max(1, Math.round(cellPix[2].w));
trailCanvas.height = Math.max(1, Math.round(cellPix[2].h));
trailCtx.fillStyle = '#17141a';
trailCtx.fillRect(0, 0, trailCanvas.width, trailCanvas.height);
}
function drawProgressive(curve, cell, t, strokeStyle) {
ctx.save();
ctx.translate(cell.x, cell.y);
ctx.beginPath();
var step = (2 * Math.PI) / 140;
var first = true;
for (var tt = 0; tt <= t; tt += step) {
var p = pointOn(curve, tt);
var sx = p.x * cell.scale, sy = p.y * cell.scale;
if (first) { ctx.moveTo(sx, sy); first = false; } else { ctx.lineTo(sx, sy); }
}
var tipP = pointOn(curve, t);
var wg = ctx.createRadialGradient(tipP.x * cell.scale, tipP.y * cell.scale, 0, tipP.x * cell.scale, tipP.y * cell.scale, Math.max(cell.w, cell.h) * 0.8);
wg.addColorStop(0, 'rgba(255,200,215,.42)');
wg.addColorStop(0.5, 'rgba(190,225,255,.24)');
wg.addColorStop(1, 'rgba(190,225,255,.06)');
ctx.save();
ctx.fillStyle = wg;
ctx.fillRect(-cell.w / 2, -cell.h / 2, cell.w, cell.h);
ctx.fillStyle = 'rgba(255,247,230,.14)';
ctx.fill('evenodd');
ctx.restore();
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = Math.max(2.4, cell.scale * 0.045);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}
function drawMultiPen(curve, cell, t) {
ctx.save();
ctx.translate(cell.x, cell.y);
var step = (2 * Math.PI) / 140;
var wb = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(cellPix[1].w, cellPix[1].h) * (0.45 + 0.2 * Math.sin(t * 0.9)));
wb.addColorStop(0, 'rgba(255,200,215,.40)');
wb.addColorStop(1, 'rgba(190,225,255,.08)');
ctx.fillStyle = wb;
ctx.fillRect(-cellPix[1].w / 2, -cellPix[1].h / 2, cellPix[1].w, cellPix[1].h);
for (var pIdx = 0; pIdx < HERO_PEN_RATIOS.length; pIdx += 1) {
var localCurve = { R: curve.R, r: curve.r, d: curve.r * HERO_PEN_RATIOS[pIdx], K: curve.K };
var phase = penPhasesB[pIdx];
ctx.beginPath();
var first = true;
for (var tt = 0; tt <= t; tt += step) {
var tp = tt + phase;
var px = (localCurve.R - localCurve.r) * Math.cos(tp) + localCurve.d * Math.cos(localCurve.K * tp);
var py = (localCurve.R - localCurve.r) * Math.sin(tp) - localCurve.d * Math.sin(localCurve.K * tp);
var sx = px * cell.scale, sy = py * cell.scale;
if (first) { ctx.moveTo(sx, sy); first = false; } else { ctx.lineTo(sx, sy); }
}
ctx.strokeStyle = HERO_PEN_COLORS[pIdx];
ctx.lineWidth = Math.max(2.2, cell.scale * 0.04);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
}
ctx.restore();
}
var trailAngle = 0;
function trailStep() {
trailCtx.fillStyle = 'rgba(23,20,26,' + HERO_FADE_ALPHA + ')';
trailCtx.fillRect(0, 0, trailCanvas.width, trailCanvas.height);
trailAngle += HERO_SPEED[2] * (HERO_STEP_MS / 1000);
var p = pointOn(curveC, trailAngle);
var cell = cellPix[2];
var sx = trailCanvas.width / 2 + p.x * cell.scale;
var sy = trailCanvas.height / 2 + p.y * cell.scale;
if (!trailStep.prev) trailStep.prev = { x: sx, y: sy };
trailCtx.strokeStyle = 'rgba(255,247,230,.95)';
trailCtx.lineWidth = Math.max(2.6, cell.scale * 0.05);
trailCtx.lineJoin = 'round'; trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(trailStep.prev.x, trailStep.prev.y);
trailCtx.lineTo(sx, sy);
trailCtx.stroke();
var th = trailCtx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(trailCanvas.width, trailCanvas.height) * 0.4);
th.addColorStop(0, 'rgba(255,200,215,.09)');
th.addColorStop(1, 'rgba(190,225,255,0)');
trailCtx.fillStyle = th;
trailCtx.fillRect(0, 0, trailCanvas.width, trailCanvas.height);
trailStep.prev = { x: sx, y: sy };
}
function render(elapsed, dtMs) {
ctx.fillStyle = '#17141a';
ctx.fillRect(0, 0, cw, ch);
var tA = (elapsed * HERO_SPEED[0]) % curveA.maxT;
drawProgressive(curveA, cellPix[0], tA, 'rgba(255,247,230,.95)');
var tB = (elapsed * HERO_SPEED[1]) % curveB.maxT;
drawMultiPen(curveB, cellPix[1], tB);
if (dtMs !== null) {
var acc = render.acc || 0;
acc += dtMs;
var guard = 0;
while (acc >= HERO_STEP_MS && guard < 6) { trailStep(); acc -= HERO_STEP_MS; guard += 1; }
render.acc = acc;
}
ctx.drawImage(trailCanvas, cellPix[2].x - cellPix[2].w / 2, cellPix[2].y - cellPix[2].h / 2, cellPix[2].w, cellPix[2].h);
var tD = (elapsed * HERO_SPEED[3]) % curveD.maxT;
drawProgressive(curveD, cellPix[3], tD, 'rgba(91,140,248,.95)');
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
fit();
if (reduce) {
root.classList.remove('is-demo');
for (var warm = 0; warm < HERO_TRAIL_FADE_FRAMES * 1.4; warm += 1) trailStep();
render(curveA.maxT * 0.94, null);
} else {
var startTs = null;
var lastTs = null;
function tick(ts) {
if (startTs === null) { startTs = ts; lastTs = ts; }
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
var elapsed = (ts - startTs) / 1000;
render(elapsed, dt);
requestAnimationFrame(tick);
}
window.addEventListener('resize', fit);
requestAnimationFrame(tick);
}
})();import { useEffect, useRef } from "react";
import styles from "./SpiroSceneHeroGalleryWall.module.scss";
type Pt = { x: number; y: number };
type Curve = { R: number; r: number; d: number; K: number; turns: number; maxT: number; patternRadius: number };
type Cell = { x: number; y: number; w: number; h: number; scale: number };
export default function SpiroSceneHeroGalleryWall() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const HERO_CELL_COUNT = 4;
const HERO_RING_TEETH = [60, 54, 48, 66];
const HERO_ROLL_TEETH = [23, 19, 17, 25];
const HERO_PEN_RATIO = 0.6;
const HERO_TOOTH_UNIT = 0.62;
const HERO_SPEED = [2.8, 3.4, 3.2, 3.8];
const HERO_TRAIL_FADE_FRAMES = 180;
const HERO_PEN_RATIOS = [0.35, 0.5, 0.65, 0.8];
const HERO_PEN_COLORS = ["rgba(230,90,120,.85)", "rgba(50,175,135,.85)", "rgba(70,130,230,.85)", "rgba(228,150,45,.85)"];
const HERO_STEP_MS = 1000 / 60;
const HERO_FADE_ALPHA = Math.min(1, 3.0 / HERO_TRAIL_FADE_FRAMES);
const HERO_GAP_FRACTION = 0.03;
function gcd(a: number, b: number): number { while (b) { const t = b; b = a % b; a = t; } return a; }
function makeCurve(ringTeeth: number, rollTeeth: number, penRatio: number): Curve {
const R = ringTeeth * HERO_TOOTH_UNIT;
const r = rollTeeth * HERO_TOOTH_UNIT;
const d = r * penRatio;
const K = (R - r) / r;
const g = gcd(ringTeeth, rollTeeth);
const turns = rollTeeth / g;
return { R, r, d, K, turns, maxT: 2 * Math.PI * turns, patternRadius: R + d };
}
function pointOn(curve: Curve, t: number): Pt {
return {
x: (curve.R - curve.r) * Math.cos(t) + curve.d * Math.cos(curve.K * t),
y: (curve.R - curve.r) * Math.sin(t) - curve.d * Math.sin(curve.K * t)
};
}
const curveA = makeCurve(HERO_RING_TEETH[0], HERO_ROLL_TEETH[0], HERO_PEN_RATIO);
const curveB = makeCurve(HERO_RING_TEETH[1], HERO_ROLL_TEETH[1], HERO_PEN_RATIO);
const curveC = makeCurve(HERO_RING_TEETH[2], HERO_ROLL_TEETH[2], HERO_PEN_RATIO);
const curveD = makeCurve(HERO_RING_TEETH[3], HERO_ROLL_TEETH[3], HERO_PEN_RATIO);
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const randB = seedRand(43309);
const penPhasesB: number[] = [];
for (let pb = 0; pb < 4; pb += 1) penPhasesB.push(randB() * Math.PI * 2);
let cw = 480, ch = 300;
let cellW = 240, cellH = 150, gap = 6;
let cellPix: Cell[] = [];
const trailCanvas = document.createElement("canvas");
const trailCtx = trailCanvas.getContext("2d")!;
function fit() {
const rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas!.width = cw; canvas!.height = ch;
cellW = cw / 2; cellH = ch / 2;
gap = Math.min(cw, ch) * HERO_GAP_FRACTION;
cellPix = [];
const curves = [curveA, curveB, curveC, curveD];
for (let k = 0; k < HERO_CELL_COUNT; k += 1) {
const col = k % 2, row = Math.floor(k / 2);
const pw = cellW - gap * 2, ph = cellH - gap * 2;
const target = Math.min(pw, ph) / 2 * 0.88;
const sc = target / (curves[k].patternRadius * 1.05);
cellPix.push({ x: col * cellW + cellW / 2, y: row * cellH + cellH / 2, w: pw, h: ph, scale: sc });
}
trailCanvas.width = Math.max(1, Math.round(cellPix[2].w));
trailCanvas.height = Math.max(1, Math.round(cellPix[2].h));
trailCtx.fillStyle = "#17141a";
trailCtx.fillRect(0, 0, trailCanvas.width, trailCanvas.height);
}
function drawProgressive(curve: Curve, cell: Cell, t: number, strokeStyle: string) {
ctx!.save();
ctx!.translate(cell.x, cell.y);
ctx!.beginPath();
const step = (2 * Math.PI) / 140;
let first = true;
for (let tt = 0; tt <= t; tt += step) {
const p = pointOn(curve, tt);
const sx = p.x * cell.scale, sy = p.y * cell.scale;
if (first) { ctx!.moveTo(sx, sy); first = false; } else { ctx!.lineTo(sx, sy); }
}
const tipP = pointOn(curve, t);
const wg = ctx!.createRadialGradient(tipP.x * cell.scale, tipP.y * cell.scale, 0, tipP.x * cell.scale, tipP.y * cell.scale, Math.max(cell.w, cell.h) * 0.8);
wg.addColorStop(0, 'rgba(255,200,215,.42)');
wg.addColorStop(0.5, 'rgba(190,225,255,.24)');
wg.addColorStop(1, 'rgba(190,225,255,.06)');
ctx!.save();
ctx!.fillStyle = wg;
ctx!.fillRect(-cell.w / 2, -cell.h / 2, cell.w, cell.h);
ctx!.fillStyle = 'rgba(255,247,230,.14)';
ctx!.fill('evenodd');
ctx!.restore();
ctx!.strokeStyle = strokeStyle;
ctx!.lineWidth = Math.max(2.4, cell.scale * 0.045);
ctx!.lineJoin = "round"; ctx!.lineCap = "round";
ctx!.stroke();
ctx!.restore();
}
function drawMultiPen(curve: Curve, cell: Cell, t: number) {
ctx!.save();
ctx!.translate(cell.x, cell.y);
const step = (2 * Math.PI) / 140;
const wb = ctx!.createRadialGradient(0, 0, 0, 0, 0, Math.max(cellPix[1].w, cellPix[1].h) * (0.45 + 0.2 * Math.sin(t * 0.9)));
wb.addColorStop(0, 'rgba(255,200,215,.40)');
wb.addColorStop(1, 'rgba(190,225,255,.08)');
ctx!.fillStyle = wb;
ctx!.fillRect(-cellPix[1].w / 2, -cellPix[1].h / 2, cellPix[1].w, cellPix[1].h);
for (let pIdx = 0; pIdx < HERO_PEN_RATIOS.length; pIdx += 1) {
const localCurve = { R: curve.R, r: curve.r, d: curve.r * HERO_PEN_RATIOS[pIdx], K: curve.K };
const phase = penPhasesB[pIdx];
ctx!.beginPath();
let first = true;
for (let tt = 0; tt <= t; tt += step) {
const tp = tt + phase;
const px = (localCurve.R - localCurve.r) * Math.cos(tp) + localCurve.d * Math.cos(localCurve.K * tp);
const py = (localCurve.R - localCurve.r) * Math.sin(tp) - localCurve.d * Math.sin(localCurve.K * tp);
const sx = px * cell.scale, sy = py * cell.scale;
if (first) { ctx!.moveTo(sx, sy); first = false; } else { ctx!.lineTo(sx, sy); }
}
ctx!.strokeStyle = HERO_PEN_COLORS[pIdx];
ctx!.lineWidth = Math.max(2.2, cell.scale * 0.04);
ctx!.lineJoin = "round"; ctx!.lineCap = "round";
ctx!.stroke();
}
ctx!.restore();
}
let trailAngle = 0;
let trailPrev: Pt | null = null;
function trailStep() {
trailCtx.fillStyle = "rgba(23,20,26," + HERO_FADE_ALPHA + ")";
trailCtx.fillRect(0, 0, trailCanvas.width, trailCanvas.height);
trailAngle += HERO_SPEED[2] * (HERO_STEP_MS / 1000);
const p = pointOn(curveC, trailAngle);
const cell = cellPix[2];
const sx = trailCanvas.width / 2 + p.x * cell.scale;
const sy = trailCanvas.height / 2 + p.y * cell.scale;
if (!trailPrev) trailPrev = { x: sx, y: sy };
trailCtx.strokeStyle = "rgba(255,247,230,.95)";
trailCtx.lineWidth = Math.max(2.6, cell.scale * 0.05);
trailCtx.lineJoin = "round"; trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(trailPrev.x, trailPrev.y);
trailCtx.lineTo(sx, sy);
trailCtx.stroke();
const th = trailCtx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(trailCanvas.width, trailCanvas.height) * 0.4);
th.addColorStop(0, "rgba(255,200,215,.09)");
th.addColorStop(1, "rgba(190,225,255,0)");
trailCtx.fillStyle = th;
trailCtx.fillRect(0, 0, trailCanvas.width, trailCanvas.height);
trailPrev = { x: sx, y: sy };
}
let accMs = 0;
function render(elapsed: number, dtMs: number | null) {
ctx!.fillStyle = "#17141a";
ctx!.fillRect(0, 0, cw, ch);
const tA = (elapsed * HERO_SPEED[0]) % curveA.maxT;
drawProgressive(curveA, cellPix[0], tA, "rgba(255,247,230,.95)");
const tB = (elapsed * HERO_SPEED[1]) % curveB.maxT;
drawMultiPen(curveB, cellPix[1], tB);
if (dtMs !== null) {
accMs += dtMs;
let guard = 0;
while (accMs >= HERO_STEP_MS && guard < 6) { trailStep(); accMs -= HERO_STEP_MS; guard += 1; }
}
ctx!.drawImage(trailCanvas, cellPix[2].x - cellPix[2].w / 2, cellPix[2].y - cellPix[2].h / 2, cellPix[2].w, cellPix[2].h);
const tD = (elapsed * HERO_SPEED[3]) % curveD.maxT;
drawProgressive(curveD, cellPix[3], tD, "rgba(91,140,248,.95)");
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
fit();
let raf = 0;
if (reduce) {
root.classList.remove("is-demo");
for (let warm = 0; warm < HERO_TRAIL_FADE_FRAMES * 1.4; warm += 1) trailStep();
render(curveA.maxT * 0.94, null);
} else {
let startTs: number | null = null;
let lastTs = 0;
const onResize = () => { fit(); };
function tick(ts: number) {
if (startTs === null) { startTs = ts; lastTs = ts; }
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
const elapsed = (ts - startTs) / 1000;
render(elapsed, dt);
raf = requestAnimationFrame(tick);
}
window.addEventListener("resize", onResize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}
return () => { cancelAnimationFrame(raf); };
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sp9} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.sp9__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.sp9__caption}>이빨 비율이 다른 곡선 여러 개를 같은 화면 격자에 놓고 각자 다른 속도로 그리게 해, 한눈에 무늬 사이의 차이를 비교하는 히어로 장면을 만든다</p>
</div>
</main>
);
}09 比・ペン・残像を一画面にまとめたヒーローギャラリー — 計測 累積面積 48.697% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · スピログラフ アート 9パターン — 歯車比で描く幾何模様
このデモが属する記事
スピログラフ アート 9パターン — 歯車比で描く幾何模様二つの歯車の歯数が互いに素なら曲線は閉じ、比がずれた瞬間、紙の上を果てしなくさまよう — 03はその比から模様の対称軸の数を計算する。スピログラフ アートの九つの場面で確かめます。 この記事のデモをすべて見る


