css waveform 9パターン — ピークで縮め区間を選んで切る音の波形の場面
css waveform は線を引く前に、標本数万を数百の画素列へ詰める仕事があります — 01番の最大・最小の束と05番のゼロ交差吸着が、詰めと選びを計算で解きます。音楽アプリや録音画面で、真ん中の線を境に上下へ揺れる棒の絵が、ここで扱う波形です。
九つの音の波形を画面でそのまま再生して切ってみる
01 wf-minmax-bucket-peak-columns
02 wf-rms-envelope-attack-release-fill
04 wf-wheel-zoom-pointer-anchor-lod
05 wf-trim-handle-zero-cross-snap
06 wf-scrolling-scope-rising-edge-trigger
07 wf-polar-ring-rotating-playhead
08 wf-equalizer-peak-hold-gravity-fall
09 wf-live-record-append-auto-gain
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 何万もの標本が最大・最小の束に縮んで画素の柱になる基本の波形
- 02 立ち上がりは速く戻りは遅い追従器が音量の輪郭をなめらかに塗る波形
- 03 再生位置の一本が柱を半画素単位で割り、済んだ部分と残りの色を変える波形
- 04 ホイールで拡大しても、ポインタの下の瞬間が動かず束が計算し直される波形
- 05 切り出し区間のつまみが最も近いゼロ交差へ引き寄せられる区間ピッカー
- 06 立ち上がりでトリガーが掛かり、揺れずに止まって見える流れるオシロスコープ
- 07 波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面
- 08 棒は即座に立ち上がり、頂点の印はしばらく留まってから重力のように落ちるイコライザー
- 09 録音中の音が右から継ぎ足され、自動ゲインが小さな音を引き上げる波形
css waveform 9パターン — 区間の束ね・輪郭の追従・ゼロ交差吸着で描く波形
この記事の九つの場面は音を鳴らしません。信号は種を決めた線形合同法の乱数 lcg、整数ハッシュ hash1、正弦波の合成から作った数の配列で、九つのファイルのどこにも AudioContext や getUserMedia がないため、マイクの許可を求める画面も出ません。種が同じなら波形も同じなので、記事に書いた数字は zip を開いて同じ関数を呼べばもう一度得られます。
絵は CSS のキーフレームではなく、<canvas> の2Dに直接塗っています。01だけを見ても、標本86,400個のうち画面に見える43,200個を160本の柱に畳む必要があり、どの標本がどの柱の端を決めるかはスクリプトで数えなければわかりません。CSS が受け持つのは、舞台の比率 aspect-ratio: 480 / 300、場面ごとに違う touch-action、フォーカスの枠 :focus-visible、動きを減らすメディアクエリといった外側の枠です。
九つは boot() ひとつを共有し、違うのは makeXxxScene の関数だけです。FIXED_DT の16ms刻みで step() を回してフレームごとに draw() を呼びますが、一フレームで追いつく時間を160msで切ってあるので、タブを長く離れて戻っても一度に進むのは10ステップまでです。220ステップ、約3.5秒のあいだ入力がないと仮のポインターが現れてデモを回し、人が触れた瞬間にデモは止まります。
用途から出発点を選ぶと迷いません。waveform html css でプレイヤー画面を組んでいる途中なら、タイムラインのプレビューは01、再生の進み具合は03、ボイスメモの一覧は02から開いてみてください。音声や動画を切り出す編集画面には04と05、楽器のチューナーや信号を測るダッシュボードには06、音楽イベントのランディングのヒーローには07、プレイヤーの状態表示には08、会議の録音ツールには09が近い場面です。
css waveform animation を技法別のコードで見る
css waveform animation と聞くと、棒がひとりでに上下する飾りを思い浮かべがちですが、この先の断片が見せるのは、数の配列を画面の列へどう振り分けるかです。抜き出した行は、場面の index.html に書かれた <script> のうち、途中を飛ばさずに続く範囲です。01・02・03・04・07が呼ぶ共通の関数 scanRange(sig, a, b) は、標本aからbの手前までを走査して最小値と最大値を rangeLo・rangeHi に残すため、断片に rangeLo が出てきたらその結果を読んでいます。
01何万もの標本が最大・最小の束に縮んで画素の柱になる基本の波形
bucketColumns は画面幅480を BUCKET_PX の3で割った160列に、見えている標本43,200個を均等に分け、柱一本が標本270個の最大値と最小値だけを代表するようにします。平均を取ると一瞬だけ跳ねた標本が隣と混ざって消えますが、最大・最小ならその一つが柱の端にそのまま残り、高さが1pxに届かない静かな区間は PEAK_FLOOR が上下1pxを守るので線が途切れません。ポインターか←/→でレンズの列を動かすと、状態表示にその柱の最大・最小が出ます。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="何万もの標本が最大・最小の束に縮んで画素の柱になる基本の波形"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">標本 → 柱</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">何万もの標本が最大・最小の束に縮んで画素の柱になる基本の波形</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SAMPLE_N = 86400;
const BUCKET_PX = 3;
const BAR_GAP = 1;
const PEAK_FLOOR = 1;
const VIEW_SAMPLES = 43200;
const SLIDE_STEP = 180;
const MID_Y = 140;
const HALF_Y = 84;
const TXT_RATIO = '標本 → 柱';
const TXT_BUCKET = '束の最大 / 最小';
// 구간 [start, start + view) 를 기둥 폭마다 한 묶음으로 줄여 최솟값과 최댓값 두 줄을 돌려준다
function bucketColumns(sig, start, view, bucketPx) {
const n = Math.floor(W / bucketPx);
const per = view / n;
const lo = [];
const hi = [];
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
lo.push(rangeLo);
hi.push(rangeHi);
}
return { lo, hi };
}
function makeBucketScene(h) {
const sig = synth(SAMPLE_N, 459);
let start = 0;
let dir = 1;
let k = 0;
let lensX = 120;
function move(x) {
lensX = clamp(x, 0, W - 1);
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointermove', function (e) {
move(h.local(e)[0]);
});
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowLeft') {
e.preventDefault();
move(lensX - BUCKET_PX * 4);
}
else if (key === 'ArrowRight') {
e.preventDefault();
move(lensX + BUCKET_PX * 4);
}
else if (key === 'Home') {
e.preventDefault();
move(0);
}
else if (key === 'End') {
e.preventDefault();
move(W - 1);
}
});
return {
step() {
k += 1;
start += dir * SLIDE_STEP;
if (start > SAMPLE_N - VIEW_SAMPLES) {
start = SAMPLE_N - VIEW_SAMPLES;
dir = -1;
}
else if (start < 0) {
start = 0;
dir = 1;
}
if (h.auto()) {
lensX = W * (0.5 + 0.42 * Math.sin(k * 0.035));
}
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(0, MID_Y, W, 1);
const cols = bucketColumns(sig, start, VIEW_SAMPLES, BUCKET_PX);
const lens = clamp(Math.floor(lensX / BUCKET_PX), 0, cols.hi.length - 1);
for (let c = 0; c < cols.hi.length; c += 1) {
const hi = cols.hi[c];
const lo = cols.lo[c];
let y1 = MID_Y - hi * HALF_Y;
let y2 = MID_Y - lo * HALF_Y;
if (MID_Y - y1 < PEAK_FLOOR) {
y1 = MID_Y - PEAK_FLOOR;
}
if (y2 - MID_Y < PEAK_FLOOR) {
y2 = MID_Y + PEAK_FLOOR;
}
const loud = clamp(Math.max(hi, -lo), 0, 1);
ctx.fillStyle = c === lens ? rgba(BUTTER, 1) : rgba(mix(LILAC, PINK, loud), 0.55 + 0.45 * loud);
ctx.fillRect(c * BUCKET_PX, y1, BUCKET_PX - BAR_GAP, y2 - y1);
}
// 렌즈 아래 묶음 — 이 한 기둥이 표본 몇 개를 대표하는지 표시
ctx.fillStyle = rgba(BUTTER, 0.18);
ctx.fillRect(lens * BUCKET_PX - 2, 44, BUCKET_PX + 3, 192);
h.say(h.auto() && !h.reduce
? TXT_RATIO + ': ' + VIEW_SAMPLES + ' → ' + cols.hi.length
: TXT_BUCKET + ': ' + cols.hi[lens].toFixed(2) + ' / ' + cols.lo[lens].toFixed(2));
}
};
}
boot(document.getElementById('wfRoot'), makeBucketScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfMinmaxBucketPeakColumns.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SAMPLE_N = 86400;
const BUCKET_PX = 3;
const BAR_GAP = 1;
const PEAK_FLOOR = 1;
const VIEW_SAMPLES = 43200;
const SLIDE_STEP = 180;
const MID_Y = 140;
const HALF_Y = 84;
const TXT_RATIO = '표본 → 기둥';
const TXT_BUCKET = '묶음 최대 / 최소';
// 구간 [start, start + view) 를 기둥 폭마다 한 묶음으로 줄여 최솟값과 최댓값 두 줄을 돌려준다
function bucketColumns(sig: Float32Array, start: number, view: number, bucketPx: number): { lo: number[]; hi: number[] } {
const n = Math.floor(W / bucketPx);
const per = view / n;
const lo: number[] = [];
const hi: number[] = [];
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
lo.push(rangeLo);
hi.push(rangeHi);
}
return { lo, hi };
}
function makeBucketScene(h: Host): Scene {
const sig = synth(SAMPLE_N, 459);
let start = 0;
let dir = 1;
let k = 0;
let lensX = 120;
function move(x: number): void {
lensX = clamp(x, 0, W - 1);
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointermove', function (e: Event): void {
move(h.local(e as PointerEvent)[0]);
});
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft') {
e.preventDefault();
move(lensX - BUCKET_PX * 4);
}
else if (key === 'ArrowRight') {
e.preventDefault();
move(lensX + BUCKET_PX * 4);
}
else if (key === 'Home') {
e.preventDefault();
move(0);
}
else if (key === 'End') {
e.preventDefault();
move(W - 1);
}
});
return {
step(): void {
k += 1;
start += dir * SLIDE_STEP;
if (start > SAMPLE_N - VIEW_SAMPLES) {
start = SAMPLE_N - VIEW_SAMPLES;
dir = -1;
}
else if (start < 0) {
start = 0;
dir = 1;
}
if (h.auto()) {
lensX = W * (0.5 + 0.42 * Math.sin(k * 0.035));
}
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(0, MID_Y, W, 1);
const cols = bucketColumns(sig, start, VIEW_SAMPLES, BUCKET_PX);
const lens = clamp(Math.floor(lensX / BUCKET_PX), 0, cols.hi.length - 1);
for (let c = 0; c < cols.hi.length; c += 1) {
const hi = cols.hi[c];
const lo = cols.lo[c];
let y1 = MID_Y - hi * HALF_Y;
let y2 = MID_Y - lo * HALF_Y;
if (MID_Y - y1 < PEAK_FLOOR) { y1 = MID_Y - PEAK_FLOOR; }
if (y2 - MID_Y < PEAK_FLOOR) { y2 = MID_Y + PEAK_FLOOR; }
const loud = clamp(Math.max(hi, -lo), 0, 1);
ctx.fillStyle = c === lens ? rgba(BUTTER, 1) : rgba(mix(LILAC, PINK, loud), 0.55 + 0.45 * loud);
ctx.fillRect(c * BUCKET_PX, y1, BUCKET_PX - BAR_GAP, y2 - y1);
}
// 렌즈 아래 묶음 — 이 한 기둥이 표본 몇 개를 대표하는지 표시
ctx.fillStyle = rgba(BUTTER, 0.18);
ctx.fillRect(lens * BUCKET_PX - 2, 44, BUCKET_PX + 3, 192);
h.say(h.auto() && !h.reduce
? TXT_RATIO + ': ' + VIEW_SAMPLES + ' → ' + cols.hi.length
: TXT_BUCKET + ': ' + cols.hi[lens].toFixed(2) + ' / ' + cols.lo[lens].toFixed(2));
}
};
}
export default function WfMinmaxBucketPeakColumns() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeBucketScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="표본 수만 개가 최대·최소 묶음으로 줄어 픽셀 기둥이 되는 기본 파형" />
<p className={styles.status} data-role="status" aria-live="polite">표본 → 기둥</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>표본 수만 개가 최대·최소 묶음으로 줄어 픽셀 기둥이 되는 기본 파형</p>
</div>
</main>
);
}01 · 何万もの標本が最大・最小の束に縮んで画素の柱になる基本の波形 — 計測 累積面積 38.376% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function bucketColumns(sig, start, view, bucketPx) {
const n = Math.floor(W / bucketPx);
const per = view / n;
const lo = [];
const hi = [];
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
lo.push(rangeLo);
hi.push(rangeHi);
}
return { lo, hi };
}
02立ち上がりは速く戻りは遅い追従器が音量の輪郭をなめらかに塗る波形
rmsAt が列の終わりから手前 RMS_WIN 160個の標本の二乗平均平方根を出すと、followEnvelope は値が上がるときは差に ATTACK_K 0.5を、下がるときは RELEASE_K 0.06を掛けた分を列ごとに足してその値を追います。そのため0から1へ跳ぶ段差には3列で90%まで張り付き、1から0へ落ちると10%を下回るまで38列かかって、太鼓のあとの余韻のように残ります。見えている最初の列より WARM_COLS の30列手前から追い始めておくので、画面の左端で輪郭が0から立ち上がることはありません。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="立ち上がりは速く戻りは遅い追従器が音量の輪郭をなめらかに塗る波形"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">二乗平均 / 輪郭</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">立ち上がりは速く戻りは遅い追従器が音量の輪郭をなめらかに塗る波形</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SAMPLE_N = 43200;
const ATTACK_K = 0.5;
const RELEASE_K = 0.06;
const RMS_WIN = 160;
const FILL_ALPHA = 0.55;
const COL_PX = 2;
const VIEW_SAMPLES = 14400;
const SLIDE_STEP = 60;
const WARM_COLS = 30;
const MID_Y = 140;
const HALF_Y = 84;
const VIS_GAIN = 1.5;
const TXT_ENV = '二乗平均 / 輪郭';
// 끝 위치 end 바로 앞 win 개 표본의 제곱평균 제곱근
function rmsAt(sig, end, win) {
const to = clamp(Math.floor(end), 1, sig.length);
const from = Math.max(0, to - win);
let sum = 0;
for (let i = from; i < to; i += 1) {
sum += sig[i] * sig[i];
}
return Math.sqrt(sum / Math.max(1, to - from));
}
// 공격 계수와 해제 계수가 다른 지수 평활 — 오를 때는 빨리, 내릴 때는 천천히 따라간다
function followEnvelope(values, attackK, releaseK) {
const out = [];
let e = 0;
for (let i = 0; i < values.length; i += 1) {
const v = values[i];
e += (v > e ? attackK : releaseK) * (v - e);
out.push(e);
}
return out;
}
function makeEnvelopeScene(h) {
const sig = synth(SAMPLE_N, 4591);
let start = 0;
let dir = 1;
let k = 0;
let cursorX = 200;
function move(x) {
cursorX = clamp(x, 0, W - 1);
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointermove', function (e) {
move(h.local(e)[0]);
});
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowLeft') {
e.preventDefault();
move(cursorX - 12);
}
else if (key === 'ArrowRight') {
e.preventDefault();
move(cursorX + 12);
}
});
return {
step() {
k += 1;
start += dir * SLIDE_STEP;
if (start > SAMPLE_N - VIEW_SAMPLES) {
start = SAMPLE_N - VIEW_SAMPLES;
dir = -1;
}
else if (start < 0) {
start = 0;
dir = 1;
}
if (h.auto()) {
cursorX = W * (0.5 + 0.4 * Math.sin(k * 0.03));
}
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
const n = Math.floor(W / COL_PX);
const per = VIEW_SAMPLES / n;
const raw = [];
for (let c = -WARM_COLS; c < n; c += 1) {
raw.push(rmsAt(sig, start + (c + 1) * per, RMS_WIN));
}
const env = followEnvelope(raw, ATTACK_K, RELEASE_K);
// 원래 최대·최소 기둥은 옅게 깔아 두고 그 위에 윤곽을 얹는다
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
ctx.fillStyle = rgba(LILAC, 0.42);
ctx.fillRect(c * COL_PX, MID_Y - rangeHi * HALF_Y, COL_PX - 0.5, (rangeHi - rangeLo) * HALF_Y + 1);
}
ctx.beginPath();
for (let c = 0; c < n; c += 1) {
const y = MID_Y - Math.min(1, env[c + WARM_COLS] * VIS_GAIN) * HALF_Y;
if (c === 0) {
ctx.moveTo(0, y);
}
else {
ctx.lineTo(c * COL_PX, y);
}
}
for (let c = n - 1; c >= 0; c -= 1) {
const y = MID_Y + Math.min(1, env[c + WARM_COLS] * VIS_GAIN) * HALF_Y;
ctx.lineTo(c * COL_PX, y);
}
ctx.closePath();
ctx.fillStyle = rgba(MINT, FILL_ALPHA);
ctx.fill();
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.lineWidth = 1.5;
ctx.stroke();
// 순간 제곱평균 — 평활 전의 들쭉날쭉한 값
ctx.beginPath();
for (let c = 0; c < n; c += 1) {
const y = MID_Y - Math.min(1, raw[c + WARM_COLS] * VIS_GAIN) * HALF_Y;
if (c === 0) {
ctx.moveTo(0, y);
}
else {
ctx.lineTo(c * COL_PX, y);
}
}
ctx.strokeStyle = rgba(SKY_BLUE, 0.95);
ctx.lineWidth = 1;
ctx.stroke();
const cc = clamp(Math.floor(cursorX / COL_PX), 0, n - 1);
ctx.fillStyle = rgba(BUTTER, 0.85);
ctx.fillRect(cc * COL_PX, 40, 1.5, 200);
h.say(TXT_ENV + ': ' + raw[cc + WARM_COLS].toFixed(2) + ' / ' + env[cc + WARM_COLS].toFixed(2));
}
};
}
boot(document.getElementById('wfRoot'), makeEnvelopeScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfRmsEnvelopeAttackReleaseFill.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SAMPLE_N = 43200;
const ATTACK_K = 0.5;
const RELEASE_K = 0.06;
const RMS_WIN = 160;
const FILL_ALPHA = 0.55;
const COL_PX = 2;
const VIEW_SAMPLES = 14400;
const SLIDE_STEP = 60;
const WARM_COLS = 30;
const MID_Y = 140;
const HALF_Y = 84;
const VIS_GAIN = 1.5;
const TXT_ENV = '제곱평균 / 윤곽';
// 끝 위치 end 바로 앞 win 개 표본의 제곱평균 제곱근
function rmsAt(sig: Float32Array, end: number, win: number): number {
const to = clamp(Math.floor(end), 1, sig.length);
const from = Math.max(0, to - win);
let sum = 0;
for (let i = from; i < to; i += 1) { sum += sig[i] * sig[i]; }
return Math.sqrt(sum / Math.max(1, to - from));
}
// 공격 계수와 해제 계수가 다른 지수 평활 — 오를 때는 빨리, 내릴 때는 천천히 따라간다
function followEnvelope(values: number[], attackK: number, releaseK: number): number[] {
const out: number[] = [];
let e = 0;
for (let i = 0; i < values.length; i += 1) {
const v = values[i];
e += (v > e ? attackK : releaseK) * (v - e);
out.push(e);
}
return out;
}
function makeEnvelopeScene(h: Host): Scene {
const sig = synth(SAMPLE_N, 4591);
let start = 0;
let dir = 1;
let k = 0;
let cursorX = 200;
function move(x: number): void {
cursorX = clamp(x, 0, W - 1);
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointermove', function (e: Event): void {
move(h.local(e as PointerEvent)[0]);
});
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft') {
e.preventDefault();
move(cursorX - 12);
}
else if (key === 'ArrowRight') {
e.preventDefault();
move(cursorX + 12);
}
});
return {
step(): void {
k += 1;
start += dir * SLIDE_STEP;
if (start > SAMPLE_N - VIEW_SAMPLES) {
start = SAMPLE_N - VIEW_SAMPLES;
dir = -1;
}
else if (start < 0) {
start = 0;
dir = 1;
}
if (h.auto()) {
cursorX = W * (0.5 + 0.4 * Math.sin(k * 0.03));
}
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
const n = Math.floor(W / COL_PX);
const per = VIEW_SAMPLES / n;
const raw: number[] = [];
for (let c = -WARM_COLS; c < n; c += 1) {
raw.push(rmsAt(sig, start + (c + 1) * per, RMS_WIN));
}
const env = followEnvelope(raw, ATTACK_K, RELEASE_K);
// 원래 최대·최소 기둥은 옅게 깔아 두고 그 위에 윤곽을 얹는다
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
ctx.fillStyle = rgba(LILAC, 0.42);
ctx.fillRect(c * COL_PX, MID_Y - rangeHi * HALF_Y, COL_PX - 0.5, (rangeHi - rangeLo) * HALF_Y + 1);
}
ctx.beginPath();
for (let c = 0; c < n; c += 1) {
const y = MID_Y - Math.min(1, env[c + WARM_COLS] * VIS_GAIN) * HALF_Y;
if (c === 0) { ctx.moveTo(0, y); } else { ctx.lineTo(c * COL_PX, y); }
}
for (let c = n - 1; c >= 0; c -= 1) {
const y = MID_Y + Math.min(1, env[c + WARM_COLS] * VIS_GAIN) * HALF_Y;
ctx.lineTo(c * COL_PX, y);
}
ctx.closePath();
ctx.fillStyle = rgba(MINT, FILL_ALPHA);
ctx.fill();
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.lineWidth = 1.5;
ctx.stroke();
// 순간 제곱평균 — 평활 전의 들쭉날쭉한 값
ctx.beginPath();
for (let c = 0; c < n; c += 1) {
const y = MID_Y - Math.min(1, raw[c + WARM_COLS] * VIS_GAIN) * HALF_Y;
if (c === 0) { ctx.moveTo(0, y); } else { ctx.lineTo(c * COL_PX, y); }
}
ctx.strokeStyle = rgba(SKY_BLUE, 0.95);
ctx.lineWidth = 1;
ctx.stroke();
const cc = clamp(Math.floor(cursorX / COL_PX), 0, n - 1);
ctx.fillStyle = rgba(BUTTER, 0.85);
ctx.fillRect(cc * COL_PX, 40, 1.5, 200);
h.say(TXT_ENV + ': ' + raw[cc + WARM_COLS].toFixed(2) + ' / ' + env[cc + WARM_COLS].toFixed(2));
}
};
}
export default function WfRmsEnvelopeAttackReleaseFill() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeEnvelopeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="공격은 빠르고 해제는 느린 추종기가 소리 크기 윤곽을 매끈하게 채우는 파형" />
<p className={styles.status} data-role="status" aria-live="polite">제곱평균 / 윤곽</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>공격은 빠르고 해제는 느린 추종기가 소리 크기 윤곽을 매끈하게 채우는 파형</p>
</div>
</main>
);
}02 · 立ち上がりは速く戻りは遅い追従器が音量の輪郭をなめらかに塗る波形 — 計測 累積面積 29.142% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function rmsAt(sig, end, win) {
const to = clamp(Math.floor(end), 1, sig.length);
const from = Math.max(0, to - win);
let sum = 0;
for (let i = from; i < to; i += 1) {
sum += sig[i] * sig[i];
}
return Math.sqrt(sum / Math.max(1, to - from));
}
// 立ち上がりと戻りで係数が違う指数平滑 — 上がるときは速く、下がるときはゆっくり追う
function followEnvelope(values, attackK, releaseK) {
const out = [];
let e = 0;
for (let i = 0; i < values.length; i += 1) {
const v = values[i];
e += (v > e ? attackK : releaseK) * (v - e);
out.push(e);
}
return out;
}
03再生位置の一本が柱を半画素単位で割り、済んだ部分と残りの色を変える波形
再生位置は halfPixel が Math.round(px * 2) / 2 で0.5pxの格子に揃え、その位置が幅4pxの柱の胴に落ちると clip() の領域二つで同じ四角を二度塗って、左はピンク(PLAYED_HUE 340)、右は薄いインク(UNPLAYED_ALPHA 0.28)になります。境目が柱のあいだの隙間へ飛ばずに柱の中を通るので、柱一本ぶんの6pxより細かく再生位置が読み取れます。キャンバスは0から6秒を伝える role="slider" で、←/→一回で0.5秒ずつ動き、ドラッグするとステップごとに目標までの差の SCRUB_EASE 0.25倍ずつ近づきます。
<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="再生位置の一本が柱を半画素単位で割り、済んだ部分と残りの色を変える波形" role="slider" aria-valuemin="0" aria-valuemax="6" aria-valuenow="0.5"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">再生位置</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">再生位置の一本が柱を半画素単位で割り、済んだ部分と残りの色を変える波形</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SAMPLE_N = 43200;
const PLAYHEAD_W = 2;
const PLAYED_HUE = 340;
const UNPLAYED_ALPHA = 0.28;
const SCRUB_EASE = 0.25;
const COL_PX = 6;
const COL_GAP = 2;
const DURATION_STEPS = 375;
const DURATION_S = 6;
const MID_Y = 128;
const HALF_Y = 80;
const KEY_SEEK = 0.0833;
const TXT_POS = '再生位置';
// 再生 위치를 반 픽셀 단위로 맞춘다 — 기둥 안쪽에 떨어지면 그 기둥을 둘로 가른다
function halfPixel(px) {
return Math.round(px * 2) / 2;
}
function makeSplitScene(h) {
const sig = synth(SAMPLE_N, 4592);
const nCols = Math.floor(W / COL_PX);
const peaks = [];
const lows = [];
const per = SAMPLE_N / nCols;
for (let c = 0; c < nCols; c += 1) {
scanRange(sig, c * per, (c + 1) * per);
lows.push(rangeLo);
peaks.push(rangeHi);
}
let pos = 0.08;
let target = pos;
let dragging = false;
let tick = 0;
function setAria(force) {
if (!force && tick % 30 !== 0) {
return;
}
const sec = pos * DURATION_S;
h.canvas.setAttribute('aria-valuenow', sec.toFixed(1));
h.canvas.setAttribute('aria-valuetext', sec.toFixed(1) + ' / ' + DURATION_S.toFixed(1) + ' s');
}
function seek(next) {
target = clamp(next, 0, 1);
if (h.reduce) {
pos = target;
}
h.touch();
setAria(true);
h.refresh();
}
h.listen(h.canvas, 'pointerdown', function (e) {
const pe = e;
dragging = true;
h.canvas.setPointerCapture(pe.pointerId);
seek(h.local(pe)[0] / W);
});
h.listen(h.canvas, 'pointermove', function (e) {
if (dragging) {
seek(h.local(e)[0] / W);
}
});
const release = function () { dragging = false; };
h.listen(h.canvas, 'pointerup', release);
h.listen(h.canvas, 'pointercancel', release);
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowLeft') {
e.preventDefault();
seek(pos - KEY_SEEK);
}
else if (key === 'ArrowRight') {
e.preventDefault();
seek(pos + KEY_SEEK);
}
else if (key === 'Home') {
e.preventDefault();
seek(0);
}
else if (key === 'End') {
e.preventDefault();
seek(1);
}
});
return {
step() {
tick += 1;
if (dragging) {
pos += (target - pos) * SCRUB_EASE;
}
else {
pos += 1 / DURATION_STEPS;
if (pos >= 1) {
pos -= 1;
}
target = pos;
}
setAria(false);
},
draw() {
const ctx = h.ctx;
ctx.fillStyle = rgba(CREAM, 1);
ctx.fillRect(0, 0, W, H);
const played = 'hsl(' + PLAYED_HUE + ' 82% 66%)';
const rest = rgba(INK, UNPLAYED_ALPHA);
const ph = halfPixel(pos * W);
for (let c = 0; c < nCols; c += 1) {
const x0 = c * COL_PX;
const x1 = x0 + COL_PX - COL_GAP;
const top = MID_Y - Math.max(peaks[c], 0.02) * HALF_Y;
const bot = MID_Y - Math.min(lows[c], -0.02) * HALF_Y;
if (x1 <= ph) {
ctx.fillStyle = played;
ctx.fillRect(x0, top, x1 - x0, bot - top);
}
else if (x0 >= ph) {
ctx.fillStyle = rest;
ctx.fillRect(x0, top, x1 - x0, bot - top);
}
else {
// 再生 위치가 걸친 기둥 — 클립 영역 둘로 나눠 왼쪽은 진하게, 오른쪽은 옅게
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, ph - x0, H);
ctx.clip();
ctx.fillStyle = played;
ctx.fillRect(x0, top, x1 - x0, bot - top);
ctx.restore();
ctx.save();
ctx.beginPath();
ctx.rect(ph, 0, x1 - ph, H);
ctx.clip();
ctx.fillStyle = rest;
ctx.fillRect(x0, top, x1 - x0, bot - top);
ctx.restore();
}
}
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillRect(ph - PLAYHEAD_W / 2, 36, PLAYHEAD_W, 188);
ctx.beginPath();
ctx.arc(ph, 36, 4, 0, PI2);
ctx.fill();
h.say(TXT_POS + ': ' + (pos * DURATION_S).toFixed(1) + ' / ' + DURATION_S.toFixed(1) + ' s');
}
};
}
boot(document.getElementById('wfRoot'), makeSplitScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfPlayheadSubpixelPlayedSplit.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SAMPLE_N = 43200;
const PLAYHEAD_W = 2;
const PLAYED_HUE = 340;
const UNPLAYED_ALPHA = 0.28;
const SCRUB_EASE = 0.25;
const COL_PX = 6;
const COL_GAP = 2;
const DURATION_STEPS = 375;
const DURATION_S = 6;
const MID_Y = 128;
const HALF_Y = 80;
const KEY_SEEK = 0.0833;
const TXT_POS = '재생 위치';
// 재생 위치를 반 픽셀 단위로 맞춘다 — 기둥 안쪽에 떨어지면 그 기둥을 둘로 가른다
function halfPixel(px: number): number {
return Math.round(px * 2) / 2;
}
function makeSplitScene(h: Host): Scene {
const sig = synth(SAMPLE_N, 4592);
const nCols = Math.floor(W / COL_PX);
const peaks: number[] = [];
const lows: number[] = [];
const per = SAMPLE_N / nCols;
for (let c = 0; c < nCols; c += 1) {
scanRange(sig, c * per, (c + 1) * per);
lows.push(rangeLo);
peaks.push(rangeHi);
}
let pos = 0.08;
let target = pos;
let dragging = false;
let tick = 0;
function setAria(force: boolean): void {
if (!force && tick % 30 !== 0) { return; }
const sec = pos * DURATION_S;
h.canvas.setAttribute('aria-valuenow', sec.toFixed(1));
h.canvas.setAttribute('aria-valuetext', sec.toFixed(1) + ' / ' + DURATION_S.toFixed(1) + ' s');
}
function seek(next: number): void {
target = clamp(next, 0, 1);
if (h.reduce) { pos = target; }
h.touch();
setAria(true);
h.refresh();
}
h.listen(h.canvas, 'pointerdown', function (e: Event): void {
const pe = e as PointerEvent;
dragging = true;
h.canvas.setPointerCapture(pe.pointerId);
seek(h.local(pe)[0] / W);
});
h.listen(h.canvas, 'pointermove', function (e: Event): void {
if (dragging) { seek(h.local(e as PointerEvent)[0] / W); }
});
const release = function (): void { dragging = false; };
h.listen(h.canvas, 'pointerup', release);
h.listen(h.canvas, 'pointercancel', release);
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft') { e.preventDefault(); seek(pos - KEY_SEEK); }
else if (key === 'ArrowRight') { e.preventDefault(); seek(pos + KEY_SEEK); }
else if (key === 'Home') { e.preventDefault(); seek(0); }
else if (key === 'End') { e.preventDefault(); seek(1); }
});
return {
step(): void {
tick += 1;
if (dragging) {
pos += (target - pos) * SCRUB_EASE;
}
else {
pos += 1 / DURATION_STEPS;
if (pos >= 1) { pos -= 1; }
target = pos;
}
setAria(false);
},
draw(): void {
const ctx = h.ctx;
ctx.fillStyle = rgba(CREAM, 1);
ctx.fillRect(0, 0, W, H);
const played = 'hsl(' + PLAYED_HUE + ' 82% 66%)';
const rest = rgba(INK, UNPLAYED_ALPHA);
const ph = halfPixel(pos * W);
for (let c = 0; c < nCols; c += 1) {
const x0 = c * COL_PX;
const x1 = x0 + COL_PX - COL_GAP;
const top = MID_Y - Math.max(peaks[c], 0.02) * HALF_Y;
const bot = MID_Y - Math.min(lows[c], -0.02) * HALF_Y;
if (x1 <= ph) {
ctx.fillStyle = played;
ctx.fillRect(x0, top, x1 - x0, bot - top);
}
else if (x0 >= ph) {
ctx.fillStyle = rest;
ctx.fillRect(x0, top, x1 - x0, bot - top);
}
else {
// 재생 위치가 걸친 기둥 — 클립 영역 둘로 나눠 왼쪽은 진하게, 오른쪽은 옅게
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, ph - x0, H);
ctx.clip();
ctx.fillStyle = played;
ctx.fillRect(x0, top, x1 - x0, bot - top);
ctx.restore();
ctx.save();
ctx.beginPath();
ctx.rect(ph, 0, x1 - ph, H);
ctx.clip();
ctx.fillStyle = rest;
ctx.fillRect(x0, top, x1 - x0, bot - top);
ctx.restore();
}
}
ctx.fillStyle = rgba(INK, 0.9);
ctx.fillRect(ph - PLAYHEAD_W / 2, 36, PLAYHEAD_W, 188);
ctx.beginPath();
ctx.arc(ph, 36, 4, 0, PI2);
ctx.fill();
h.say(TXT_POS + ': ' + (pos * DURATION_S).toFixed(1) + ' / ' + DURATION_S.toFixed(1) + ' s');
}
};
}
export default function WfPlayheadSubpixelPlayedSplit() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeSplitScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="재생 위치 한 줄이 기둥을 반 픽셀 단위로 갈라 지난 부분과 남은 부분의 색을 바꾸는 파형" role="slider" aria-valuemin={0} aria-valuemax={6} aria-valuenow={0.5} />
<p className={styles.status} data-role="status" aria-live="polite">재생 위치</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>재생 위치 한 줄이 기둥을 반 픽셀 단위로 갈라 지난 부분과 남은 부분의 색을 바꾸는 파형</p>
</div>
</main>
);
}03 · 再生位置の一本が柱を半画素単位で割り、済んだ部分と残りの色を変える波形 — 計測 累積面積 13.158% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (x1 <= ph) {
ctx.fillStyle = played;
ctx.fillRect(x0, top, x1 - x0, bot - top);
}
else if (x0 >= ph) {
ctx.fillStyle = rest;
ctx.fillRect(x0, top, x1 - x0, bot - top);
}
else {
// 再生位置がまたがる柱 — クリップ領域二つに分け、左は濃く、右は薄く
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, ph - x0, H);
ctx.clip();
ctx.fillStyle = played;
ctx.fillRect(x0, top, x1 - x0, bot - top);
ctx.restore();
ctx.save();
ctx.beginPath();
ctx.rect(ph, 0, x1 - ph, H);
ctx.clip();
ctx.fillStyle = rest;
ctx.fillRect(x0, top, x1 - x0, bot - top);
ctx.restore();
}
04ホイールで拡大しても、ポインターの下の瞬間が動かず束が計算し直される波形
ホイールが一段回ると、notch がまずポインターの下の標本番号を anchorT に書き留め、目標の倍率だけを ZOOM_STEP の1.5倍に変えます。倍率が z * Math.pow(r, 0.2) で近づくあいだは、viewStartFor が anchorT - anchorPx / W * span で開始点を逆算し、同じ標本を同じ画面のxに留めます。区間は描くたびに新しい倍率で束ね直され、一画素あたりの標本が1.6個を下回ると柱の代わりに標本の点と線に切り替わり、倍率が1か96に達してもう変わらないホイール入力は止めないので、そのときはページがスクロールします。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="ホイールで拡大しても、ポインタの下の瞬間が動かず束が計算し直される波形"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">拡大率</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">ホイールで拡大しても、ポインタの下の瞬間が動かず束が計算し直される波形</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SAMPLE_N = 43200;
const ZOOM_MIN = 1;
const ZOOM_MAX = 96;
const ZOOM_STEP = 1.5;
const VIEW_START = 0;
const COL_PX = 2;
const MID_Y = 142;
const HALF_Y = 78;
const SCRIPT_LEN = 420;
const KEY_PAN_PX = 24;
const TXT_ZOOM = '拡大率';
// 포인터 아래 표본 위치(anchorT)가 같은 화면 x(anchorPx)에 머물도록 시작점을 거꾸로 구한다
function viewStartFor(anchorT, anchorPx, span) {
return clamp(anchorT - anchorPx / W * span, 0, SAMPLE_N - span);
}
function niceStep(raw) {
const p = Math.pow(10, Math.floor(Math.log10(Math.max(1, raw))));
const m = raw / p;
return (m < 1.5 ? 1 : (m < 3.5 ? 2 : (m < 7.5 ? 5 : 10))) * p;
}
function makeZoomScene(h) {
const sig = synth(SAMPLE_N, 4593);
const nOv = Math.floor(W / 4);
const ovLo = [];
const ovHi = [];
for (let c = 0; c < nOv; c += 1) {
scanRange(sig, c * SAMPLE_N / nOv, (c + 1) * SAMPLE_N / nOv);
ovLo.push(rangeLo);
ovHi.push(rangeHi);
}
let zt = ZOOM_MIN;
let z = ZOOM_MIN;
let span = SAMPLE_N / ZOOM_MIN;
let anchorPx = W / 2;
let anchorT = VIEW_START + anchorPx / W * span;
let start = VIEW_START;
let hoverPx = W / 2;
let k = 0;
let fakeX = 330;
let fakeDown = false;
let drag = false;
let lastX = 0;
function recompute() {
span = SAMPLE_N / z;
start = viewStartFor(anchorT, anchorPx, span);
}
// 휠 한 칸 — 포인터가 가리키는 표본을 앵커로 잡고 목표 배율만 바꾼다
function notch(dir, px) {
const next = clamp(zt * (dir > 0 ? ZOOM_STEP : 1 / ZOOM_STEP), ZOOM_MIN, ZOOM_MAX);
if (next === zt) {
return false;
}
anchorT = start + px / W * span;
anchorPx = px;
zt = next;
if (h.reduce) {
z = zt;
recompute();
}
return true;
}
function pan(dxPx) {
anchorT = clamp(anchorT - dxPx / W * span, 0, SAMPLE_N);
recompute();
}
h.listen(h.canvas, 'wheel', function (e) {
const we = e;
const px = h.local(we)[0];
hoverPx = px;
if (notch(we.deltaY < 0 ? 1 : -1, px)) {
// 배율이 실제로 바뀔 때만 페이지 스크롤을 막는다
e.preventDefault();
h.touch();
h.refresh();
}
}, { passive: false });
h.listen(h.canvas, 'pointerdown', function (e) {
const pe = e;
drag = true;
lastX = h.local(pe)[0];
h.canvas.setPointerCapture(pe.pointerId);
h.touch();
});
h.listen(h.canvas, 'pointermove', function (e) {
const x = h.local(e)[0];
hoverPx = x;
if (drag) {
pan(x - lastX);
lastX = x;
h.touch();
h.refresh();
}
});
const drop = function () { drag = false; };
h.listen(h.canvas, 'pointerup', drop);
h.listen(h.canvas, 'pointercancel', drop);
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
let handled = true;
if (key === '+' || key === '=') {
notch(1, hoverPx);
}
else if (key === '-' || key === '_') {
notch(-1, hoverPx);
}
else if (key === 'ArrowLeft') {
pan(KEY_PAN_PX);
}
else if (key === 'ArrowRight') {
pan(-KEY_PAN_PX);
}
else if (key === 'Home') {
zt = ZOOM_MIN;
anchorT = SAMPLE_N / 2;
anchorPx = W / 2;
if (h.reduce) {
z = zt;
recompute();
}
}
else {
handled = false;
}
if (handled) {
e.preventDefault();
h.touch();
h.refresh();
}
});
function script() {
const j = k % SCRIPT_LEN;
if (j === 0) {
zt = ZOOM_MIN;
anchorT = SAMPLE_N * 0.5;
anchorPx = W / 2;
}
fakeDown = false;
if (j < 12) {
fakeX = 330;
}
else if (j < 56) {
fakeX = 330;
if ((j - 12) % 4 === 0) {
notch(1, fakeX);
}
}
else if (j >= 64 && j < 130) {
fakeDown = true;
fakeX = 330 - (j - 64) * 1.2;
pan(-1.2);
}
else if (j >= 190 && j < 232) {
fakeX = lerp(250, 120, (j - 190) / 42);
}
else if (j >= 240 && j < 312) {
fakeX = 120;
if ((j - 240) % 6 === 0) {
notch(-1, fakeX);
}
}
hoverPx = fakeX;
}
return {
step() {
k += 1;
if (h.auto()) {
script();
}
if (!h.reduce) {
const r = zt / z;
z = Math.abs(Math.log(r)) < 0.002 ? zt : z * Math.pow(r, 0.2);
recompute();
}
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
// 위쪽 개요 띠 — 전체 신호와 지금 보는 구간
for (let c = 0; c < nOv; c += 1) {
ctx.fillStyle = rgba(LILAC, 0.5);
ctx.fillRect(c * 4, 40 - ovHi[c] * 5, 3, (ovHi[c] - ovLo[c]) * 5 + 1);
}
ctx.strokeStyle = rgba(BUTTER, 1);
ctx.lineWidth = 1.5;
ctx.strokeRect(start / SAMPLE_N * W, 31, Math.max(3, span / SAMPLE_N * W), 18);
// 눈금
const tickStep = niceStep(span / 7);
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
for (let t = Math.ceil(start / tickStep) * tickStep; t < start + span; t += tickStep) {
const x = (t - start) / span * W;
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(x, 56, 1, 178);
ctx.fillStyle = rgba(CREAM, 0.7);
ctx.fillText(String(Math.round(t)), x, 64);
}
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(0, MID_Y, W, 1);
const spp = span / W;
if (spp >= 1.6) {
// 묶음 단계 — 기둥마다 최대·최소
const n = Math.floor(W / COL_PX);
const per = span / n;
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
const loud = clamp(Math.max(rangeHi, -rangeLo), 0, 1);
ctx.fillStyle = rgba(mix(LILAC, PINK, loud), 0.9);
ctx.fillRect(c * COL_PX, MID_Y - rangeHi * HALF_Y, COL_PX - 0.5, Math.max(1, (rangeHi - rangeLo) * HALF_Y));
}
}
else {
// 표본 단계 — 표본마다 점과 선
ctx.beginPath();
const first = Math.floor(start);
const last = Math.min(SAMPLE_N - 1, Math.ceil(start + span));
for (let i = first; i <= last; i += 1) {
const x = (i - start) / span * W;
const y = MID_Y - sig[i] * HALF_Y;
if (i === first) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = rgba(MINT, 0.95);
ctx.lineWidth = 1.4;
ctx.stroke();
ctx.fillStyle = rgba(PINK, 1);
for (let i = first; i <= last; i += 1) {
ctx.beginPath();
ctx.arc((i - start) / span * W, MID_Y - sig[i] * HALF_Y, 1.8, 0, PI2);
ctx.fill();
}
}
// 앵커 — 확대해도 이 선 아래 표본은 같은 자리에 있다
ctx.fillStyle = rgba(BUTTER, 0.8);
ctx.fillRect(anchorPx - 0.5, 56, 1.5, 178);
if (h.auto() && !h.reduce) {
drawCursor(ctx, fakeX, MID_Y + 6, fakeDown);
}
h.say(TXT_ZOOM + ': ' + z.toFixed(1) + ' x / ' + Math.round(span));
}
};
}
boot(document.getElementById('wfRoot'), makeZoomScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfWheelZoomPointerAnchorLod.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SAMPLE_N = 43200;
const ZOOM_MIN = 1;
const ZOOM_MAX = 96;
const ZOOM_STEP = 1.5;
const VIEW_START = 0;
const COL_PX = 2;
const MID_Y = 142;
const HALF_Y = 78;
const SCRIPT_LEN = 420;
const KEY_PAN_PX = 24;
const TXT_ZOOM = '확대 배율';
// 포인터 아래 표본 위치(anchorT)가 같은 화면 x(anchorPx)에 머물도록 시작점을 거꾸로 구한다
function viewStartFor(anchorT: number, anchorPx: number, span: number): number {
return clamp(anchorT - anchorPx / W * span, 0, SAMPLE_N - span);
}
function niceStep(raw: number): number {
const p = Math.pow(10, Math.floor(Math.log10(Math.max(1, raw))));
const m = raw / p;
return (m < 1.5 ? 1 : (m < 3.5 ? 2 : (m < 7.5 ? 5 : 10))) * p;
}
function makeZoomScene(h: Host): Scene {
const sig = synth(SAMPLE_N, 4593);
const nOv = Math.floor(W / 4);
const ovLo: number[] = [];
const ovHi: number[] = [];
for (let c = 0; c < nOv; c += 1) {
scanRange(sig, c * SAMPLE_N / nOv, (c + 1) * SAMPLE_N / nOv);
ovLo.push(rangeLo);
ovHi.push(rangeHi);
}
let zt = ZOOM_MIN;
let z = ZOOM_MIN;
let span = SAMPLE_N / ZOOM_MIN;
let anchorPx = W / 2;
let anchorT = VIEW_START + anchorPx / W * span;
let start = VIEW_START;
let hoverPx = W / 2;
let k = 0;
let fakeX = 330;
let fakeDown = false;
let drag = false;
let lastX = 0;
function recompute(): void {
span = SAMPLE_N / z;
start = viewStartFor(anchorT, anchorPx, span);
}
// 휠 한 칸 — 포인터가 가리키는 표본을 앵커로 잡고 목표 배율만 바꾼다
function notch(dir: number, px: number): boolean {
const next = clamp(zt * (dir > 0 ? ZOOM_STEP : 1 / ZOOM_STEP), ZOOM_MIN, ZOOM_MAX);
if (next === zt) { return false; }
anchorT = start + px / W * span;
anchorPx = px;
zt = next;
if (h.reduce) {
z = zt;
recompute();
}
return true;
}
function pan(dxPx: number): void {
anchorT = clamp(anchorT - dxPx / W * span, 0, SAMPLE_N);
recompute();
}
h.listen(h.canvas, 'wheel', function (e: Event): void {
const we = e as WheelEvent;
const px = h.local(we)[0];
hoverPx = px;
if (notch(we.deltaY < 0 ? 1 : -1, px)) {
// 배율이 실제로 바뀔 때만 페이지 스크롤을 막는다
e.preventDefault();
h.touch();
h.refresh();
}
}, { passive: false });
h.listen(h.canvas, 'pointerdown', function (e: Event): void {
const pe = e as PointerEvent;
drag = true;
lastX = h.local(pe)[0];
h.canvas.setPointerCapture(pe.pointerId);
h.touch();
});
h.listen(h.canvas, 'pointermove', function (e: Event): void {
const x = h.local(e as PointerEvent)[0];
hoverPx = x;
if (drag) {
pan(x - lastX);
lastX = x;
h.touch();
h.refresh();
}
});
const drop = function (): void { drag = false; };
h.listen(h.canvas, 'pointerup', drop);
h.listen(h.canvas, 'pointercancel', drop);
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
let handled = true;
if (key === '+' || key === '=') { notch(1, hoverPx); }
else if (key === '-' || key === '_') { notch(-1, hoverPx); }
else if (key === 'ArrowLeft') { pan(KEY_PAN_PX); }
else if (key === 'ArrowRight') { pan(-KEY_PAN_PX); }
else if (key === 'Home') { zt = ZOOM_MIN; anchorT = SAMPLE_N / 2; anchorPx = W / 2; if (h.reduce) { z = zt; recompute(); } }
else { handled = false; }
if (handled) {
e.preventDefault();
h.touch();
h.refresh();
}
});
function script(): void {
const j = k % SCRIPT_LEN;
if (j === 0) { zt = ZOOM_MIN; anchorT = SAMPLE_N * 0.5; anchorPx = W / 2; }
fakeDown = false;
if (j < 12) { fakeX = 330; }
else if (j < 56) {
fakeX = 330;
if ((j - 12) % 4 === 0) { notch(1, fakeX); }
}
else if (j >= 64 && j < 130) {
fakeDown = true;
fakeX = 330 - (j - 64) * 1.2;
pan(-1.2);
}
else if (j >= 190 && j < 232) {
fakeX = lerp(250, 120, (j - 190) / 42);
}
else if (j >= 240 && j < 312) {
fakeX = 120;
if ((j - 240) % 6 === 0) { notch(-1, fakeX); }
}
hoverPx = fakeX;
}
return {
step(): void {
k += 1;
if (h.auto()) { script(); }
if (!h.reduce) {
const r = zt / z;
z = Math.abs(Math.log(r)) < 0.002 ? zt : z * Math.pow(r, 0.2);
recompute();
}
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
// 위쪽 개요 띠 — 전체 신호와 지금 보는 구간
for (let c = 0; c < nOv; c += 1) {
ctx.fillStyle = rgba(LILAC, 0.5);
ctx.fillRect(c * 4, 40 - ovHi[c] * 5, 3, (ovHi[c] - ovLo[c]) * 5 + 1);
}
ctx.strokeStyle = rgba(BUTTER, 1);
ctx.lineWidth = 1.5;
ctx.strokeRect(start / SAMPLE_N * W, 31, Math.max(3, span / SAMPLE_N * W), 18);
// 눈금
const tickStep = niceStep(span / 7);
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
for (let t = Math.ceil(start / tickStep) * tickStep; t < start + span; t += tickStep) {
const x = (t - start) / span * W;
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(x, 56, 1, 178);
ctx.fillStyle = rgba(CREAM, 0.7);
ctx.fillText(String(Math.round(t)), x, 64);
}
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(0, MID_Y, W, 1);
const spp = span / W;
if (spp >= 1.6) {
// 묶음 단계 — 기둥마다 최대·최소
const n = Math.floor(W / COL_PX);
const per = span / n;
for (let c = 0; c < n; c += 1) {
scanRange(sig, start + c * per, start + (c + 1) * per);
const loud = clamp(Math.max(rangeHi, -rangeLo), 0, 1);
ctx.fillStyle = rgba(mix(LILAC, PINK, loud), 0.9);
ctx.fillRect(c * COL_PX, MID_Y - rangeHi * HALF_Y, COL_PX - 0.5, Math.max(1, (rangeHi - rangeLo) * HALF_Y));
}
}
else {
// 표본 단계 — 표본마다 점과 선
ctx.beginPath();
const first = Math.floor(start);
const last = Math.min(SAMPLE_N - 1, Math.ceil(start + span));
for (let i = first; i <= last; i += 1) {
const x = (i - start) / span * W;
const y = MID_Y - sig[i] * HALF_Y;
if (i === first) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.strokeStyle = rgba(MINT, 0.95);
ctx.lineWidth = 1.4;
ctx.stroke();
ctx.fillStyle = rgba(PINK, 1);
for (let i = first; i <= last; i += 1) {
ctx.beginPath();
ctx.arc((i - start) / span * W, MID_Y - sig[i] * HALF_Y, 1.8, 0, PI2);
ctx.fill();
}
}
// 앵커 — 확대해도 이 선 아래 표본은 같은 자리에 있다
ctx.fillStyle = rgba(BUTTER, 0.8);
ctx.fillRect(anchorPx - 0.5, 56, 1.5, 178);
if (h.auto() && !h.reduce) { drawCursor(ctx, fakeX, MID_Y + 6, fakeDown); }
h.say(TXT_ZOOM + ': ' + z.toFixed(1) + ' x / ' + Math.round(span));
}
};
}
export default function WfWheelZoomPointerAnchorLod() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeZoomScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="휠로 확대해도 포인터 아래 순간이 제자리에 머물고 묶음이 다시 계산되는 파형" />
<p className={styles.status} data-role="status" aria-live="polite">확대 배율</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>휠로 확대해도 포인터 아래 순간이 제자리에 머물고 묶음이 다시 계산되는 파형</p>
</div>
</main>
);
}04 · ホイールで拡大しても、ポインタの下の瞬間が動かず束が計算し直される波形 — 計測 累積面積 35.085% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function recompute() {
span = SAMPLE_N / z;
start = viewStartFor(anchorT, anchorPx, span);
}
// ホイール一段 — ポインターが指す標本をアンカーにして目標の倍率だけ変える
function notch(dir, px) {
const next = clamp(zt * (dir > 0 ? ZOOM_STEP : 1 / ZOOM_STEP), ZOOM_MIN, ZOOM_MAX);
if (next === zt) {
return false;
}
anchorT = start + px / W * span;
anchorPx = px;
zt = next;
if (h.reduce) {
z = zt;
recompute();
}
return true;
}
05切り出し区間のつまみが最も近いゼロ交差へ引き寄せられる区間ピッカー
zeroCrossings は隣り合う二つの標本の符号が変わるところで線形補間し、値が0になるxを小数のpxまで求めます。つまみを離すと snapTarget が SNAP_RADIUS 6pxの中で最も近い交差を選び、残りの距離の SNAP_EASE 0.25倍ずつ引き寄せます。切った境目の値が0なので、切り出した区間をつなぎ直しても値が段差のように跳ねません。半径の中に交差がなければつまみは離した場所に残って状態表示が「半径内にゼロ交差なし」に変わり、二つのつまみは role="slider" の <div> なので Tab で届き、←/→で次や前のゼロ交差へ一度に飛びます。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="切り出し区間のつまみが最も近いゼロ交差へ引き寄せられる区間ピッカー"></canvas>
<div class="wf__handle" data-role="handle" role="slider" tabindex="0" aria-label="開始ハンドル" aria-valuemin="0" aria-valuemax="1919" aria-valuenow="221" style="left: 11.54%"></div>
<div class="wf__handle" data-role="handle" role="slider" tabindex="0" aria-label="終了ハンドル" aria-valuemin="0" aria-valuemax="1919" aria-valuenow="1694" style="left: 88.25%"></div>
<p class="wf__status" data-role="status" aria-live="polite">選択の長さ</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">切り出し区間のつまみが最も近いゼロ交差へ引き寄せられる区間ピッカー</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.wf__handle {
position: absolute;
top: 12%;
height: 76%;
width: 6%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
transform: translateX(-50%);
cursor: ew-resize;
touch-action: none;
}
.wf__handle:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: -2px;
border-radius: $r-sm;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.wf__handle {
position: absolute;
top: 12%;
height: 76%;
width: 6%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
transform: translateX(-50%);
cursor: ew-resize;
touch-action: none;
}
.wf__handle:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
border-radius: 8px;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SAMPLE_N = 1920;
const SNAP_RADIUS = 6;
const HANDLE_W = 8;
const MIN_SPAN = 24;
const SNAP_EASE = 0.25;
const MID_Y = 138;
const HALF_Y = 74;
const SCRIPT_LEN = 360;
const START_A = 55.4;
const START_B = 423.6;
const TXT_SNAP = '引き寄せた距離';
const TXT_STAY = '半径内にゼロ交差なし';
const TXT_SPAN = '選択の長さ';
// 저주파 두 성분을 겹친 신호 — 영점 교차가 수십 픽셀 간격으로 나타난다
function lowSignal(n) {
const out = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const a = Math.sin(PI2 * 0.0062 * i + 0.7) * 0.78;
const b = Math.sin(PI2 * 0.0147 * i + 2.1) * 0.34;
const c = Math.sin(PI2 * 0.0021 * i + 4.0) * 0.18;
out[i] = clamp(a + b + c, -0.95, 0.95);
}
return out;
}
// 부호가 바뀌는 표본 사이를 직선으로 이어 영점 교차의 화면 x 를 구한다
function zeroCrossings(sig) {
const xs = [];
for (let i = 0; i < sig.length - 1; i += 1) {
const a = sig[i];
const b = sig[i + 1];
if ((a < 0 && b >= 0) || (a >= 0 && b < 0)) {
xs.push((i + a / (a - b)) / (sig.length - 1) * W);
}
}
return xs;
}
// 놓은 자리 x 에서 반경 안의 가장 가까운 영점 교차로 — 없으면 제자리
function snapTarget(x, crossings, radius) {
let best = x;
let bestD = radius + 0.0001;
for (let i = 0; i < crossings.length; i += 1) {
const d = Math.abs(crossings[i] - x);
if (d < bestD) {
bestD = d;
best = crossings[i];
}
}
return best;
}
function makeTrimScene(h) {
const sig = lowSignal(SAMPLE_N);
const cross = zeroCrossings(sig);
const hx = [START_A, START_B];
const tx = [START_A, START_B];
const els = [];
const nodes = h.root.querySelectorAll('[data-role="handle"]');
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
if (node instanceof HTMLElement) {
els.push(node);
}
}
let active = -1;
let k = 0;
let fakeX = 200;
let fakeY = 210;
let fakeDown = false;
let lastPull = -1;
let msg = '';
function limit(i, x) {
return i === 0 ? clamp(x, 0, hx[1] - MIN_SPAN) : clamp(x, hx[0] + MIN_SPAN, W);
}
function release(i) {
const want = limit(i, snapTarget(hx[i], cross, SNAP_RADIUS));
lastPull = Math.abs(want - hx[i]);
tx[i] = want;
if (h.reduce) {
hx[i] = want;
}
msg = lastPull < 0.05 ? TXT_STAY : TXT_SNAP + ': ' + lastPull.toFixed(1) + ' px';
}
function nextCross(i, dir) {
let best = hx[i];
let found = false;
for (let c = 0; c < cross.length; c += 1) {
const x = cross[c];
if (dir > 0 && x > hx[i] + 0.5 && (!found || x < best)) {
best = x;
found = true;
}
if (dir < 0 && x < hx[i] - 0.5 && (!found || x > best)) {
best = x;
found = true;
}
}
tx[i] = limit(i, best);
if (h.reduce) {
hx[i] = tx[i];
}
}
for (let i = 0; i < els.length && i < 2; i += 1) {
const el = els[i];
const idx = i;
h.listen(el, 'pointerdown', function (e) {
const pe = e;
active = idx;
el.setPointerCapture(pe.pointerId);
h.touch();
});
h.listen(el, 'pointermove', function (e) {
if (active !== idx) {
return;
}
const x = limit(idx, h.local(e)[0]);
hx[idx] = x;
tx[idx] = x;
h.refresh();
});
const up = function () {
if (active !== idx) {
return;
}
active = -1;
release(idx);
h.refresh();
};
h.listen(el, 'pointerup', up);
h.listen(el, 'pointercancel', up);
h.listen(el, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
e.preventDefault();
nextCross(idx, key === 'ArrowRight' ? 1 : -1);
h.touch();
h.refresh();
}
});
}
function script() {
const j = k % SCRIPT_LEN;
fakeDown = false;
if (j === 0) {
hx[0] = START_A;
tx[0] = START_A;
hx[1] = START_B;
tx[1] = START_B;
}
if (j < 20) {
fakeX = lerp(200, START_A, smooth(j / 20));
fakeY = lerp(210, MID_Y, smooth(j / 20));
}
else if (j < 60) {
fakeDown = true;
const x = lerp(START_A, 103.5, smooth((j - 20) / 40));
hx[0] = limit(0, x);
tx[0] = hx[0];
fakeX = hx[0];
fakeY = MID_Y;
if (j === 59) {
release(0);
}
}
else if (j < 110) {
fakeX = lerp(hx[0], START_B, smooth((j - 60) / 50));
}
else if (j < 150) {
fakeDown = true;
const x = lerp(START_B, 329, smooth((j - 110) / 40));
hx[1] = limit(1, x);
tx[1] = hx[1];
fakeX = hx[1];
if (j === 149) {
release(1);
}
}
else if (j < 200) {
fakeX = lerp(tx[1], 250, smooth((j - 150) / 50));
fakeY = lerp(MID_Y, 214, smooth((j - 150) / 50));
}
}
return {
step() {
k += 1;
if (h.auto()) {
script();
}
for (let i = 0; i < 2; i += 1) {
if (active === i || (h.auto() && fakeDown && ((k % SCRIPT_LEN) < 60 ? i === 0 : i === 1))) {
continue;
}
const d = tx[i] - hx[i];
hx[i] = Math.abs(d) < 0.05 ? tx[i] : hx[i] + d * SNAP_EASE;
}
},
draw() {
const ctx = h.ctx;
ctx.fillStyle = rgba(CREAM, 1);
ctx.fillRect(0, 0, W, H);
const a = hx[0];
const b = hx[1];
// 선택 구간 바탕
ctx.fillStyle = rgba(MINT, 0.38);
ctx.fillRect(a, 40, b - a, 196);
// 흡착 반경 띠 — 끌고 있는 손잡이 둘레
const grabbed = active >= 0 ? active : (h.auto() && fakeDown ? ((k % SCRIPT_LEN) < 60 ? 0 : 1) : -1);
if (grabbed >= 0) {
ctx.fillStyle = rgba(LILAC, 0.34);
ctx.fillRect(hx[grabbed] - SNAP_RADIUS, 40, SNAP_RADIUS * 2, 196);
}
ctx.fillStyle = rgba(INK, 0.28);
ctx.fillRect(0, MID_Y, W, 1);
// 파형
ctx.beginPath();
for (let i = 0; i < SAMPLE_N; i += 4) {
const x = i / (SAMPLE_N - 1) * W;
const y = MID_Y - sig[i] * HALF_Y;
if (i === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = rgba(INK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
// 영점 교차 표시
for (let i = 0; i < cross.length; i += 1) {
ctx.beginPath();
ctx.arc(cross[i], MID_Y, 3, 0, PI2);
ctx.fillStyle = (cross[i] >= a - 0.5 && cross[i] <= b + 0.5) ? rgba(PINK, 1) : rgba(LILAC, 1);
ctx.fill();
}
// 손잡이
for (let i = 0; i < 2; i += 1) {
const x = hx[i];
ctx.fillStyle = rgba(INK, 0.95);
ctx.fillRect(x - 1, 40, 2, 196);
ctx.beginPath();
ctx.roundRect(x - HANDLE_W / 2, 118, HANDLE_W, 40, 4);
ctx.fillStyle = rgba(PINK, 1);
ctx.fill();
ctx.strokeStyle = rgba(INK, 0.95);
ctx.lineWidth = 1.5;
ctx.stroke();
const el = els[i];
if (el) {
el.style.left = (x / W * 100).toFixed(2) + '%';
const v = String(Math.round(x / W * (SAMPLE_N - 1)));
if (el.getAttribute('aria-valuenow') !== v) {
el.setAttribute('aria-valuenow', v);
}
}
}
if (h.auto() && !h.reduce) {
drawCursor(ctx, fakeX, fakeY, fakeDown);
}
h.say(msg !== '' && (active < 0) ? msg : TXT_SPAN + ': ' + Math.round((b - a) / W * SAMPLE_N));
}
};
}
boot(document.getElementById('wfRoot'), makeTrimScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfTrimHandleZeroCrossSnap.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SAMPLE_N = 1920;
const SNAP_RADIUS = 6;
const HANDLE_W = 8;
const MIN_SPAN = 24;
const SNAP_EASE = 0.25;
const MID_Y = 138;
const HALF_Y = 74;
const SCRIPT_LEN = 360;
const START_A = 55.4;
const START_B = 423.6;
const TXT_SNAP = '끌려간 거리';
const TXT_STAY = '반경 안에 영점 교차 없음';
const TXT_SPAN = '선택 길이';
// 저주파 두 성분을 겹친 신호 — 영점 교차가 수십 픽셀 간격으로 나타난다
function lowSignal(n: number): Float32Array {
const out = new Float32Array(n);
for (let i = 0; i < n; i += 1) {
const a = Math.sin(PI2 * 0.0062 * i + 0.7) * 0.78;
const b = Math.sin(PI2 * 0.0147 * i + 2.1) * 0.34;
const c = Math.sin(PI2 * 0.0021 * i + 4.0) * 0.18;
out[i] = clamp(a + b + c, -0.95, 0.95);
}
return out;
}
// 부호가 바뀌는 표본 사이를 직선으로 이어 영점 교차의 화면 x 를 구한다
function zeroCrossings(sig: Float32Array): number[] {
const xs: number[] = [];
for (let i = 0; i < sig.length - 1; i += 1) {
const a = sig[i];
const b = sig[i + 1];
if ((a < 0 && b >= 0) || (a >= 0 && b < 0)) {
xs.push((i + a / (a - b)) / (sig.length - 1) * W);
}
}
return xs;
}
// 놓은 자리 x 에서 반경 안의 가장 가까운 영점 교차로 — 없으면 제자리
function snapTarget(x: number, crossings: number[], radius: number): number {
let best = x;
let bestD = radius + 0.0001;
for (let i = 0; i < crossings.length; i += 1) {
const d = Math.abs(crossings[i] - x);
if (d < bestD) {
bestD = d;
best = crossings[i];
}
}
return best;
}
function makeTrimScene(h: Host): Scene {
const sig = lowSignal(SAMPLE_N);
const cross = zeroCrossings(sig);
const hx = [START_A, START_B];
const tx = [START_A, START_B];
const els: HTMLElement[] = [];
const nodes = h.root.querySelectorAll('[data-role="handle"]');
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
if (node instanceof HTMLElement) { els.push(node); }
}
let active = -1;
let k = 0;
let fakeX = 200;
let fakeY = 210;
let fakeDown = false;
let lastPull = -1;
let msg = '';
function limit(i: number, x: number): number {
return i === 0 ? clamp(x, 0, hx[1] - MIN_SPAN) : clamp(x, hx[0] + MIN_SPAN, W);
}
function release(i: number): void {
const want = limit(i, snapTarget(hx[i], cross, SNAP_RADIUS));
lastPull = Math.abs(want - hx[i]);
tx[i] = want;
if (h.reduce) { hx[i] = want; }
msg = lastPull < 0.05 ? TXT_STAY : TXT_SNAP + ': ' + lastPull.toFixed(1) + ' px';
}
function nextCross(i: number, dir: number): void {
let best = hx[i];
let found = false;
for (let c = 0; c < cross.length; c += 1) {
const x = cross[c];
if (dir > 0 && x > hx[i] + 0.5 && (!found || x < best)) { best = x; found = true; }
if (dir < 0 && x < hx[i] - 0.5 && (!found || x > best)) { best = x; found = true; }
}
tx[i] = limit(i, best);
if (h.reduce) { hx[i] = tx[i]; }
}
for (let i = 0; i < els.length && i < 2; i += 1) {
const el = els[i];
const idx = i;
h.listen(el, 'pointerdown', function (e: Event): void {
const pe = e as PointerEvent;
active = idx;
el.setPointerCapture(pe.pointerId);
h.touch();
});
h.listen(el, 'pointermove', function (e: Event): void {
if (active !== idx) { return; }
const x = limit(idx, h.local(e as PointerEvent)[0]);
hx[idx] = x;
tx[idx] = x;
h.refresh();
});
const up = function (): void {
if (active !== idx) { return; }
active = -1;
release(idx);
h.refresh();
};
h.listen(el, 'pointerup', up);
h.listen(el, 'pointercancel', up);
h.listen(el, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
e.preventDefault();
nextCross(idx, key === 'ArrowRight' ? 1 : -1);
h.touch();
h.refresh();
}
});
}
function script(): void {
const j = k % SCRIPT_LEN;
fakeDown = false;
if (j === 0) {
hx[0] = START_A; tx[0] = START_A; hx[1] = START_B; tx[1] = START_B;
}
if (j < 20) {
fakeX = lerp(200, START_A, smooth(j / 20));
fakeY = lerp(210, MID_Y, smooth(j / 20));
}
else if (j < 60) {
fakeDown = true;
const x = lerp(START_A, 103.5, smooth((j - 20) / 40));
hx[0] = limit(0, x);
tx[0] = hx[0];
fakeX = hx[0];
fakeY = MID_Y;
if (j === 59) { release(0); }
}
else if (j < 110) {
fakeX = lerp(hx[0], START_B, smooth((j - 60) / 50));
}
else if (j < 150) {
fakeDown = true;
const x = lerp(START_B, 329, smooth((j - 110) / 40));
hx[1] = limit(1, x);
tx[1] = hx[1];
fakeX = hx[1];
if (j === 149) { release(1); }
}
else if (j < 200) {
fakeX = lerp(tx[1], 250, smooth((j - 150) / 50));
fakeY = lerp(MID_Y, 214, smooth((j - 150) / 50));
}
}
return {
step(): void {
k += 1;
if (h.auto()) { script(); }
for (let i = 0; i < 2; i += 1) {
if (active === i || (h.auto() && fakeDown && ((k % SCRIPT_LEN) < 60 ? i === 0 : i === 1))) { continue; }
const d = tx[i] - hx[i];
hx[i] = Math.abs(d) < 0.05 ? tx[i] : hx[i] + d * SNAP_EASE;
}
},
draw(): void {
const ctx = h.ctx;
ctx.fillStyle = rgba(CREAM, 1);
ctx.fillRect(0, 0, W, H);
const a = hx[0];
const b = hx[1];
// 선택 구간 바탕
ctx.fillStyle = rgba(MINT, 0.38);
ctx.fillRect(a, 40, b - a, 196);
// 흡착 반경 띠 — 끌고 있는 손잡이 둘레
const grabbed = active >= 0 ? active : (h.auto() && fakeDown ? ((k % SCRIPT_LEN) < 60 ? 0 : 1) : -1);
if (grabbed >= 0) {
ctx.fillStyle = rgba(LILAC, 0.34);
ctx.fillRect(hx[grabbed] - SNAP_RADIUS, 40, SNAP_RADIUS * 2, 196);
}
ctx.fillStyle = rgba(INK, 0.28);
ctx.fillRect(0, MID_Y, W, 1);
// 파형
ctx.beginPath();
for (let i = 0; i < SAMPLE_N; i += 4) {
const x = i / (SAMPLE_N - 1) * W;
const y = MID_Y - sig[i] * HALF_Y;
if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.strokeStyle = rgba(INK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
// 영점 교차 표시
for (let i = 0; i < cross.length; i += 1) {
ctx.beginPath();
ctx.arc(cross[i], MID_Y, 3, 0, PI2);
ctx.fillStyle = (cross[i] >= a - 0.5 && cross[i] <= b + 0.5) ? rgba(PINK, 1) : rgba(LILAC, 1);
ctx.fill();
}
// 손잡이
for (let i = 0; i < 2; i += 1) {
const x = hx[i];
ctx.fillStyle = rgba(INK, 0.95);
ctx.fillRect(x - 1, 40, 2, 196);
ctx.beginPath();
ctx.roundRect(x - HANDLE_W / 2, 118, HANDLE_W, 40, 4);
ctx.fillStyle = rgba(PINK, 1);
ctx.fill();
ctx.strokeStyle = rgba(INK, 0.95);
ctx.lineWidth = 1.5;
ctx.stroke();
const el = els[i];
if (el) {
el.style.left = (x / W * 100).toFixed(2) + '%';
const v = String(Math.round(x / W * (SAMPLE_N - 1)));
if (el.getAttribute('aria-valuenow') !== v) { el.setAttribute('aria-valuenow', v); }
}
}
if (h.auto() && !h.reduce) { drawCursor(ctx, fakeX, fakeY, fakeDown); }
h.say(msg !== '' && (active < 0) ? msg : TXT_SPAN + ': ' + Math.round((b - a) / W * SAMPLE_N));
}
};
}
export default function WfTrimHandleZeroCrossSnap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeTrimScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="자를 구간의 손잡이가 가장 가까운 영점 교차로 끌려가 붙는 구간 선택기" />
<div className={styles.handle} data-role="handle" role="slider" tabIndex={0} aria-label="시작 손잡이" aria-valuemin={0} aria-valuemax={1919} aria-valuenow={221} style={{ left: "11.54%" }} />
<div className={styles.handle} data-role="handle" role="slider" tabIndex={0} aria-label="끝 손잡이" aria-valuemin={0} aria-valuemax={1919} aria-valuenow={1694} style={{ left: "88.25%" }} />
<p className={styles.status} data-role="status" aria-live="polite">선택 길이</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>자를 구간의 손잡이가 가장 가까운 영점 교차로 끌려가 붙는 구간 선택기</p>
</div>
</main>
);
}05 · 切り出し区間のつまみが最も近いゼロ交差へ引き寄せられる区間ピッカー — 計測 累積面積 17.933% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function zeroCrossings(sig) {
const xs = [];
for (let i = 0; i < sig.length - 1; i += 1) {
const a = sig[i];
const b = sig[i + 1];
if ((a < 0 && b >= 0) || (a >= 0 && b < 0)) {
xs.push((i + a / (a - b)) / (sig.length - 1) * W);
}
}
return xs;
}
// 離した位置 x から半径内で最も近いゼロ交差へ — なければその場に残す
function snapTarget(x, crossings, radius) {
let best = x;
let bestD = radius + 0.0001;
for (let i = 0; i < crossings.length; i += 1) {
const d = Math.abs(crossings[i] - x);
if (d < bestD) {
bestD = d;
best = crossings[i];
}
}
return best;
}
06立ち上がりでトリガーが掛かり、揺れずに止まって見える流れるオシロスコープ
findTrigger はバッファの2,048標本から、レベル TRIG_LEVEL 0.15を下から上へ越える地点を探し、上の段はそこから480標本を描くので、流れる信号が毎フレーム同じ位置から始まります。入力には速いさざ波 0.09 * Math.sin(n * 0.83) が混ざって一周期にレベルを二度越えることもありますが、HOLDOFF 40が、採用したエッジから40標本以内の次のエッジを捨てます。下の段はトリガーなしでステップごとに9標本ずつ流れるフリーランなので、二段を並べて見ればトリガーの働きがすぐにわかります。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="立ち上がりでトリガーが掛かり、揺れずに止まって見える流れるオシロスコープ"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">トリガーレベル</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">立ち上がりでトリガーが掛かり、揺れずに止まって見える流れるオシロスコープ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-x;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-x;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TRIG_LEVEL = 0.15;
const HOLDOFF = 40;
const TRACE_GLOW = 6;
const TIME_DIV = 48;
const SHOW = 480;
const BUF_LEN = 2048;
const STEP_SAMPLES = 9;
const BASE_FREQ = 0.011;
const PANEL_H = 88;
const TOP_Y = 40;
const BOT_Y = 146;
const LEVEL_LIMIT = 0.7;
const TXT_TRIG = 'トリガー';
const TXT_FREE = 'フリーラン';
const TXT_LEVEL = 'トリガーレベル';
// 입력 신호 — 주파수가 천천히 떠도는 비대칭 파형에 빠른 잔물결을 얹었다. 표본 번호만으로 값이 정해진다
function scopeSample(n) {
const phase = PI2 * BASE_FREQ * (n + 285.7 * (1 - Math.cos(0.0007 * n)));
return 0.62 * Math.sin(phase) + 0.28 * Math.sin(2 * phase + 0.9) + 0.09 * Math.sin(n * 0.83) + (hash1(n, 7) - 0.5) * 0.05;
}
// 최근 표본에서 레벨을 아래에서 위로 가로지르는 지점을 찾고, HOLDOFF 안쪽의 다음 에지는 버린다
function findTrigger(buf, level, holdoff, show) {
let kept = -1;
let found = -1;
for (let j = 1; j + show <= buf.length; j += 1) {
if (buf[j - 1] < level && buf[j] >= level) {
if (kept < 0 || j - kept >= holdoff) {
kept = j;
found = j;
}
}
}
return found;
}
function makeScopeScene(h) {
const buf = new Float32Array(BUF_LEN);
let n = BUF_LEN;
let level = TRIG_LEVEL;
let k = 0;
let dragging = false;
function fill() {
for (let i = 0; i < BUF_LEN; i += 1) {
buf[i] = scopeSample(n - BUF_LEN + i);
}
}
function setLevel(v) {
level = clamp(v, -LEVEL_LIMIT, LEVEL_LIMIT);
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointerdown', function (e) {
const pe = e;
dragging = true;
h.canvas.setPointerCapture(pe.pointerId);
setLevel((TOP_Y + PANEL_H / 2 - h.local(pe)[1]) / (PANEL_H / 2 * 0.85));
});
h.listen(h.canvas, 'pointermove', function (e) {
if (dragging) {
setLevel((TOP_Y + PANEL_H / 2 - h.local(e)[1]) / (PANEL_H / 2 * 0.85));
}
});
const drop = function () { dragging = false; };
h.listen(h.canvas, 'pointerup', drop);
h.listen(h.canvas, 'pointercancel', drop);
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowUp') {
e.preventDefault();
setLevel(level + 0.05);
}
else if (key === 'ArrowDown') {
e.preventDefault();
setLevel(level - 0.05);
}
});
function panel(y0, from, label, accent, marker) {
const ctx = h.ctx;
const yc = y0 + PANEL_H / 2;
const amp = PANEL_H / 2 * 0.85;
ctx.fillStyle = rgba(INK, 0.45);
ctx.fillRect(0, y0, W, PANEL_H);
for (let x = TIME_DIV; x < W; x += TIME_DIV) {
ctx.fillStyle = rgba(CREAM, 0.1);
ctx.fillRect(x, y0, 1, PANEL_H);
}
ctx.fillStyle = rgba(CREAM, 0.18);
ctx.fillRect(0, yc, W, 1);
ctx.beginPath();
ctx.moveTo(0, yc);
for (let i = 0; i < SHOW; i += 1) {
ctx.lineTo(i, yc - buf[from + i] * amp);
}
ctx.lineTo(SHOW - 1, yc);
ctx.closePath();
ctx.fillStyle = rgba(accent, 0.16);
ctx.fill();
ctx.beginPath();
for (let i = 0; i < SHOW; i += 1) {
const y = yc - buf[from + i] * amp;
if (i === 0) {
ctx.moveTo(i, y);
}
else {
ctx.lineTo(i, y);
}
}
ctx.save();
ctx.shadowColor = rgba(accent, 0.9);
ctx.shadowBlur = TRACE_GLOW * ctx.getTransform().a;
ctx.strokeStyle = rgba(accent, 1);
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = rgba(CREAM, 0.85);
ctx.fillText(label, W - 8, y0 + 14);
if (marker) {
const ly = yc - level * amp;
ctx.fillStyle = rgba(BUTTER, 0.85);
ctx.fillRect(0, ly, W, 1);
ctx.beginPath();
ctx.moveTo(0, ly - 5);
ctx.lineTo(9, ly);
ctx.lineTo(0, ly + 5);
ctx.closePath();
ctx.fill();
}
}
fill();
return {
step() {
k += 1;
n += STEP_SAMPLES;
fill();
if (h.auto() && !dragging) {
level = TRIG_LEVEL + 0.4 * Math.sin(k * 0.045);
}
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
const at = findTrigger(buf, level, HOLDOFF, SHOW);
panel(TOP_Y, at < 0 ? BUF_LEN - SHOW : at, TXT_TRIG, MINT, true);
panel(BOT_Y, BUF_LEN - SHOW, TXT_FREE, PINK, false);
h.say(TXT_LEVEL + ': ' + level.toFixed(2));
}
};
}
boot(document.getElementById('wfRoot'), makeScopeScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfScrollingScopeRisingEdgeTrigger.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const TRIG_LEVEL = 0.15;
const HOLDOFF = 40;
const TRACE_GLOW = 6;
const TIME_DIV = 48;
const SHOW = 480;
const BUF_LEN = 2048;
const STEP_SAMPLES = 9;
const BASE_FREQ = 0.011;
const PANEL_H = 88;
const TOP_Y = 40;
const BOT_Y = 146;
const LEVEL_LIMIT = 0.7;
const TXT_TRIG = '트리거';
const TXT_FREE = '프리런';
const TXT_LEVEL = '트리거 레벨';
// 입력 신호 — 주파수가 천천히 떠도는 비대칭 파형에 빠른 잔물결을 얹었다. 표본 번호만으로 값이 정해진다
function scopeSample(n: number): number {
const phase = PI2 * BASE_FREQ * (n + 285.7 * (1 - Math.cos(0.0007 * n)));
return 0.62 * Math.sin(phase) + 0.28 * Math.sin(2 * phase + 0.9) + 0.09 * Math.sin(n * 0.83) + (hash1(n, 7) - 0.5) * 0.05;
}
// 최근 표본에서 레벨을 아래에서 위로 가로지르는 지점을 찾고, HOLDOFF 안쪽의 다음 에지는 버린다
function findTrigger(buf: Float32Array, level: number, holdoff: number, show: number): number {
let kept = -1;
let found = -1;
for (let j = 1; j + show <= buf.length; j += 1) {
if (buf[j - 1] < level && buf[j] >= level) {
if (kept < 0 || j - kept >= holdoff) {
kept = j;
found = j;
}
}
}
return found;
}
function makeScopeScene(h: Host): Scene {
const buf = new Float32Array(BUF_LEN);
let n = BUF_LEN;
let level = TRIG_LEVEL;
let k = 0;
let dragging = false;
function fill(): void {
for (let i = 0; i < BUF_LEN; i += 1) { buf[i] = scopeSample(n - BUF_LEN + i); }
}
function setLevel(v: number): void {
level = clamp(v, -LEVEL_LIMIT, LEVEL_LIMIT);
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointerdown', function (e: Event): void {
const pe = e as PointerEvent;
dragging = true;
h.canvas.setPointerCapture(pe.pointerId);
setLevel((TOP_Y + PANEL_H / 2 - h.local(pe)[1]) / (PANEL_H / 2 * 0.85));
});
h.listen(h.canvas, 'pointermove', function (e: Event): void {
if (dragging) {
setLevel((TOP_Y + PANEL_H / 2 - h.local(e as PointerEvent)[1]) / (PANEL_H / 2 * 0.85));
}
});
const drop = function (): void { dragging = false; };
h.listen(h.canvas, 'pointerup', drop);
h.listen(h.canvas, 'pointercancel', drop);
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowUp') { e.preventDefault(); setLevel(level + 0.05); }
else if (key === 'ArrowDown') { e.preventDefault(); setLevel(level - 0.05); }
});
function panel(y0: number, from: number, label: string, accent: RGB, marker: boolean): void {
const ctx = h.ctx;
const yc = y0 + PANEL_H / 2;
const amp = PANEL_H / 2 * 0.85;
ctx.fillStyle = rgba(INK, 0.45);
ctx.fillRect(0, y0, W, PANEL_H);
for (let x = TIME_DIV; x < W; x += TIME_DIV) {
ctx.fillStyle = rgba(CREAM, 0.1);
ctx.fillRect(x, y0, 1, PANEL_H);
}
ctx.fillStyle = rgba(CREAM, 0.18);
ctx.fillRect(0, yc, W, 1);
ctx.beginPath();
ctx.moveTo(0, yc);
for (let i = 0; i < SHOW; i += 1) {
ctx.lineTo(i, yc - buf[from + i] * amp);
}
ctx.lineTo(SHOW - 1, yc);
ctx.closePath();
ctx.fillStyle = rgba(accent, 0.16);
ctx.fill();
ctx.beginPath();
for (let i = 0; i < SHOW; i += 1) {
const y = yc - buf[from + i] * amp;
if (i === 0) { ctx.moveTo(i, y); } else { ctx.lineTo(i, y); }
}
ctx.save();
ctx.shadowColor = rgba(accent, 0.9);
ctx.shadowBlur = TRACE_GLOW * ctx.getTransform().a;
ctx.strokeStyle = rgba(accent, 1);
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = rgba(CREAM, 0.85);
ctx.fillText(label, W - 8, y0 + 14);
if (marker) {
const ly = yc - level * amp;
ctx.fillStyle = rgba(BUTTER, 0.85);
ctx.fillRect(0, ly, W, 1);
ctx.beginPath();
ctx.moveTo(0, ly - 5);
ctx.lineTo(9, ly);
ctx.lineTo(0, ly + 5);
ctx.closePath();
ctx.fill();
}
}
fill();
return {
step(): void {
k += 1;
n += STEP_SAMPLES;
fill();
if (h.auto() && !dragging) {
level = TRIG_LEVEL + 0.4 * Math.sin(k * 0.045);
}
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
const at = findTrigger(buf, level, HOLDOFF, SHOW);
panel(TOP_Y, at < 0 ? BUF_LEN - SHOW : at, TXT_TRIG, MINT, true);
panel(BOT_Y, BUF_LEN - SHOW, TXT_FREE, PINK, false);
h.say(TXT_LEVEL + ': ' + level.toFixed(2));
}
};
}
export default function WfScrollingScopeRisingEdgeTrigger() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeScopeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="상승 구간에서 트리거가 걸려 흔들림 없이 멈춰 보이는 흐르는 오실로스코프" />
<p className={styles.status} data-role="status" aria-live="polite">트리거 레벨</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>상승 구간에서 트리거가 걸려 흔들림 없이 멈춰 보이는 흐르는 오실로스코프</p>
</div>
</main>
);
}06 · 立ち上がりでトリガーが掛かり、揺れずに止まって見える流れるオシロスコープ — 計測 累積面積 32.302% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 入力信号 — 周波数がゆっくり漂う非対称の波形に速いさざ波を重ねた。値は標本番号だけで決まる
function scopeSample(n) {
const phase = PI2 * BASE_FREQ * (n + 285.7 * (1 - Math.cos(0.0007 * n)));
return 0.62 * Math.sin(phase) + 0.28 * Math.sin(2 * phase + 0.9) + 0.09 * Math.sin(n * 0.83) + (hash1(n, 7) - 0.5) * 0.05;
}
// 直近の標本でレベルを下から上へ横切る地点を探し、HOLDOFF 以内の次のエッジは捨てる
function findTrigger(buf, level, holdoff, show) {
let kept = -1;
let found = -1;
for (let j = 1; j + show <= buf.length; j += 1) {
if (buf[j - 1] < level && buf[j] >= level) {
if (kept < 0 || j - kept >= holdoff) {
kept = j;
found = j;
}
}
}
return found;
}
07波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面
円周を BAR_COUNT の96区画に分け、区画ごとに標本450個の最大値を外側へ、最小値を内側へ立てて、長さを振幅の0.7乗にすることで、小さな音の棒も目に留まる長さまで伸ばします。針は毎秒 SWEEP_SPEED 2.2ラジアンで回って一周に約2.86秒かかり、針が通り過ぎてから回った角度に Math.exp(-behind * AFTERGLOW) を掛けて、すぐ後ろの棒だけをピンクに明るく残します。キャンバスは0から360度を伝えるスライダーで、押したまま回すと円の中心を基準にした atan2 で針が手を追ってきます。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面" role="slider" aria-valuemin="0" aria-valuemax="360" aria-valuenow="0"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">針の角度</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SAMPLE_N = 43200;
const RING_R = 66;
const BAR_COUNT = 96;
const SWEEP_SPEED = 2.2;
const AFTERGLOW = 1.1;
const BAR_LEN = 62;
const CX = 240;
const CY = 134;
const WEDGE_SLICES = 14;
const KEY_TURN = 0.26;
const TXT_ANGLE = '針の角度';
// 막대 i 의 구간 최댓값(바깥으로)과 최솟값(안쪽으로)
function ringBars(sig, count) {
const lo = [];
const hi = [];
const per = sig.length / count;
for (let i = 0; i < count; i += 1) {
scanRange(sig, i * per, (i + 1) * per);
lo.push(rangeLo);
hi.push(rangeHi);
}
return { lo, hi };
}
function makeRingScene(h) {
const sig = synth(SAMPLE_N, 4595);
const bars = ringBars(sig, BAR_COUNT);
let theta = 0;
let dragging = false;
let tick = 0;
function angleOf(e) {
const p = h.local(e);
return Math.atan2(p[1] - CY, p[0] - CX) + Math.PI / 2;
}
function turn(a) {
theta = ((a % PI2) + PI2) % PI2;
h.touch();
setAria(true);
h.refresh();
}
function setAria(force) {
if (!force && tick % 30 !== 0) {
return;
}
const deg = Math.round(theta / PI2 * 360);
h.canvas.setAttribute('aria-valuenow', String(deg));
h.canvas.setAttribute('aria-valuetext', deg + ' deg');
}
h.listen(h.canvas, 'pointerdown', function (e) {
const pe = e;
dragging = true;
h.canvas.setPointerCapture(pe.pointerId);
turn(angleOf(pe));
});
h.listen(h.canvas, 'pointermove', function (e) {
if (dragging) {
turn(angleOf(e));
}
});
const drop = function () { dragging = false; };
h.listen(h.canvas, 'pointerup', drop);
h.listen(h.canvas, 'pointercancel', drop);
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowLeft') {
e.preventDefault();
turn(theta - KEY_TURN);
}
else if (key === 'ArrowRight') {
e.preventDefault();
turn(theta + KEY_TURN);
}
});
return {
step() {
tick += 1;
if (!dragging) {
theta = (theta + SWEEP_SPEED * FIXED_DT / 1000) % PI2;
}
setAria(false);
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
ctx.lineCap = 'round';
// 바늘 뒤로 끌리는 부채꼴 잔광
for (let s = 0; s < WEDGE_SLICES; s += 1) {
const a1 = theta - Math.PI / 2 - s * 0.09;
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.arc(CX, CY, RING_R + BAR_LEN * 0.75, a1 - 0.09, a1);
ctx.closePath();
ctx.fillStyle = rgba(SKY_BLUE, 0.2 * (1 - s / WEDGE_SLICES));
ctx.fill();
}
ctx.lineWidth = PI2 * RING_R / BAR_COUNT * 0.74;
const cur = Math.floor(theta / PI2 * BAR_COUNT) % BAR_COUNT;
for (let i = 0; i < BAR_COUNT; i += 1) {
const a = (i + 0.5) / BAR_COUNT * PI2;
// 바늘이 지나간 뒤 얼마나 돌았는지 — 가까울수록 밝다
const behind = ((theta - a) % PI2 + PI2) % PI2;
const glow = Math.exp(-behind * AFTERGLOW);
const outer = RING_R + Math.pow(Math.max(bars.hi[i], 0.03), 0.7) * BAR_LEN;
const inner = RING_R - Math.pow(Math.max(-bars.lo[i], 0.03), 0.7) * BAR_LEN * 0.8;
const s = Math.sin(a - Math.PI / 2);
const c = Math.cos(a - Math.PI / 2);
ctx.strokeStyle = rgba(mix(LILAC, PINK, glow), 0.42 + 0.58 * glow);
ctx.beginPath();
ctx.moveTo(CX + c * inner, CY + s * inner);
ctx.lineTo(CX + c * outer, CY + s * outer);
ctx.stroke();
}
// 바늘
const ns = Math.sin(theta - Math.PI / 2);
const nc = Math.cos(theta - Math.PI / 2);
ctx.strokeStyle = rgba(BUTTER, 1);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(CX + nc * (RING_R - BAR_LEN * 0.9), CY + ns * (RING_R - BAR_LEN * 0.9));
ctx.lineTo(CX + nc * (RING_R + BAR_LEN + 6), CY + ns * (RING_R + BAR_LEN + 6));
ctx.stroke();
// 가운데 원 — 바늘 아래 막대의 크기만큼 부푼다
const lead = Math.max(bars.hi[cur], -bars.lo[cur]);
ctx.beginPath();
ctx.arc(CX, CY, 9 + lead * 14, 0, PI2);
ctx.fillStyle = rgba(MINT, 0.9);
ctx.fill();
h.say(TXT_ANGLE + ': ' + Math.round(theta / PI2 * 360) + ' deg');
}
};
}
boot(document.getElementById('wfRoot'), makeRingScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfPolarRingRotatingPlayhead.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const SAMPLE_N = 43200;
const RING_R = 66;
const BAR_COUNT = 96;
const SWEEP_SPEED = 2.2;
const AFTERGLOW = 1.1;
const BAR_LEN = 62;
const CX = 240;
const CY = 134;
const WEDGE_SLICES = 14;
const KEY_TURN = 0.26;
const TXT_ANGLE = '바늘 각도';
// 막대 i 의 구간 최댓값(바깥으로)과 최솟값(안쪽으로)
function ringBars(sig: Float32Array, count: number): { lo: number[]; hi: number[] } {
const lo: number[] = [];
const hi: number[] = [];
const per = sig.length / count;
for (let i = 0; i < count; i += 1) {
scanRange(sig, i * per, (i + 1) * per);
lo.push(rangeLo);
hi.push(rangeHi);
}
return { lo, hi };
}
function makeRingScene(h: Host): Scene {
const sig = synth(SAMPLE_N, 4595);
const bars = ringBars(sig, BAR_COUNT);
let theta = 0;
let dragging = false;
let tick = 0;
function angleOf(e: PointerEvent): number {
const p = h.local(e);
return Math.atan2(p[1] - CY, p[0] - CX) + Math.PI / 2;
}
function turn(a: number): void {
theta = ((a % PI2) + PI2) % PI2;
h.touch();
setAria(true);
h.refresh();
}
function setAria(force: boolean): void {
if (!force && tick % 30 !== 0) { return; }
const deg = Math.round(theta / PI2 * 360);
h.canvas.setAttribute('aria-valuenow', String(deg));
h.canvas.setAttribute('aria-valuetext', deg + ' deg');
}
h.listen(h.canvas, 'pointerdown', function (e: Event): void {
const pe = e as PointerEvent;
dragging = true;
h.canvas.setPointerCapture(pe.pointerId);
turn(angleOf(pe));
});
h.listen(h.canvas, 'pointermove', function (e: Event): void {
if (dragging) { turn(angleOf(e as PointerEvent)); }
});
const drop = function (): void { dragging = false; };
h.listen(h.canvas, 'pointerup', drop);
h.listen(h.canvas, 'pointercancel', drop);
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft') { e.preventDefault(); turn(theta - KEY_TURN); }
else if (key === 'ArrowRight') { e.preventDefault(); turn(theta + KEY_TURN); }
});
return {
step(): void {
tick += 1;
if (!dragging) {
theta = (theta + SWEEP_SPEED * FIXED_DT / 1000) % PI2;
}
setAria(false);
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
ctx.lineCap = 'round';
// 바늘 뒤로 끌리는 부채꼴 잔광
for (let s = 0; s < WEDGE_SLICES; s += 1) {
const a1 = theta - Math.PI / 2 - s * 0.09;
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.arc(CX, CY, RING_R + BAR_LEN * 0.75, a1 - 0.09, a1);
ctx.closePath();
ctx.fillStyle = rgba(SKY_BLUE, 0.2 * (1 - s / WEDGE_SLICES));
ctx.fill();
}
ctx.lineWidth = PI2 * RING_R / BAR_COUNT * 0.74;
const cur = Math.floor(theta / PI2 * BAR_COUNT) % BAR_COUNT;
for (let i = 0; i < BAR_COUNT; i += 1) {
const a = (i + 0.5) / BAR_COUNT * PI2;
// 바늘이 지나간 뒤 얼마나 돌았는지 — 가까울수록 밝다
const behind = ((theta - a) % PI2 + PI2) % PI2;
const glow = Math.exp(-behind * AFTERGLOW);
const outer = RING_R + Math.pow(Math.max(bars.hi[i], 0.03), 0.7) * BAR_LEN;
const inner = RING_R - Math.pow(Math.max(-bars.lo[i], 0.03), 0.7) * BAR_LEN * 0.8;
const s = Math.sin(a - Math.PI / 2);
const c = Math.cos(a - Math.PI / 2);
ctx.strokeStyle = rgba(mix(LILAC, PINK, glow), 0.42 + 0.58 * glow);
ctx.beginPath();
ctx.moveTo(CX + c * inner, CY + s * inner);
ctx.lineTo(CX + c * outer, CY + s * outer);
ctx.stroke();
}
// 바늘
const ns = Math.sin(theta - Math.PI / 2);
const nc = Math.cos(theta - Math.PI / 2);
ctx.strokeStyle = rgba(BUTTER, 1);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(CX + nc * (RING_R - BAR_LEN * 0.9), CY + ns * (RING_R - BAR_LEN * 0.9));
ctx.lineTo(CX + nc * (RING_R + BAR_LEN + 6), CY + ns * (RING_R + BAR_LEN + 6));
ctx.stroke();
// 가운데 원 — 바늘 아래 막대의 크기만큼 부푼다
const lead = Math.max(bars.hi[cur], -bars.lo[cur]);
ctx.beginPath();
ctx.arc(CX, CY, 9 + lead * 14, 0, PI2);
ctx.fillStyle = rgba(MINT, 0.9);
ctx.fill();
h.say(TXT_ANGLE + ': ' + Math.round(theta / PI2 * 360) + ' deg');
}
};
}
export default function WfPolarRingRotatingPlayhead() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeRingScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="파형이 둥근 고리 둘레에 말려 돌아가는 재생 바늘을 따라 켜지는 극좌표 장면" role="slider" aria-valuemin={0} aria-valuemax={360} aria-valuenow={0} />
<p className={styles.status} data-role="status" aria-live="polite">바늘 각도</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>파형이 둥근 고리 둘레에 말려 돌아가는 재생 바늘을 따라 켜지는 극좌표 장면</p>
</div>
</main>
);
}07 · 波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面 — 計測 累積面積 22.116% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ctx.lineWidth = PI2 * RING_R / BAR_COUNT * 0.74;
const cur = Math.floor(theta / PI2 * BAR_COUNT) % BAR_COUNT;
for (let i = 0; i < BAR_COUNT; i += 1) {
const a = (i + 0.5) / BAR_COUNT * PI2;
// 針が通り過ぎてからどれだけ回ったか — 近いほど明るい
const behind = ((theta - a) % PI2 + PI2) % PI2;
const glow = Math.exp(-behind * AFTERGLOW);
const outer = RING_R + Math.pow(Math.max(bars.hi[i], 0.03), 0.7) * BAR_LEN;
const inner = RING_R - Math.pow(Math.max(-bars.lo[i], 0.03), 0.7) * BAR_LEN * 0.8;
const s = Math.sin(a - Math.PI / 2);
const c = Math.cos(a - Math.PI / 2);
ctx.strokeStyle = rgba(mix(LILAC, PINK, glow), 0.42 + 0.58 * glow);
ctx.beginPath();
ctx.moveTo(CX + c * inner, CY + s * inner);
ctx.lineTo(CX + c * outer, CY + s * outer);
ctx.stroke();
}
08棒は即座に立ち上がり、頂点の印はしばらく留まってから重力のように落ちるイコライザー
24本の帯域ごとに stepBand は、目標が棒より大きければその値までいきなり上げ、小さければ差の BAR_FALL_K 0.12倍ずつだけ下げるので、10%を下回るまで19フレームかかります。頂点の印は棒が届くたびに HOLD_FRAMES の28フレーム留まり、そのあとフレームごとに速度へ CAP_FALL_G 0.0006を足して加速しながら落ち、棒の下へ潜り込むと棒の高さで速度0に戻ります。帯域を押すと押した高さまで押し上げ、←/→で帯域を選んでから Enter か Space を押すと0.95まで上がります。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="棒は即座に立ち上がり、頂点の印はしばらく留まってから重力のように落ちるイコライザー"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">帯域</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">棒は即座に立ち上がり、頂点の印はしばらく留まってから重力のように落ちるイコライザー</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const BAND_N = 24;
const HOLD_FRAMES = 28;
const CAP_FALL_G = 0.0006;
const BAR_FALL_K = 0.12;
const BAR_W = 14;
const BAR_STRIDE = 18;
const BASE_X = 24;
const FLOOR_Y = 232;
const TOP_BAR_Y = 52;
const SEG_H = 6;
const SEG_GAP = 2;
const PUSH_STEPS = 40;
const TXT_BAND = '帯域';
function stepBand(s, target, fallK, holdFrames, gravity) {
if (target > s.bar) {
s.bar = target;
}
else {
s.bar += (target - s.bar) * fallK;
}
if (s.bar >= s.cap) {
s.cap = s.bar;
s.hold = holdFrames;
s.vel = 0;
}
else if (s.hold > 0) {
s.hold -= 1;
}
else {
s.vel += gravity;
s.cap -= s.vel;
if (s.cap < s.bar) {
s.cap = s.bar;
s.vel = 0;
}
}
}
function makeEqScene(h) {
const bands = [];
const base = [];
const level = [];
const push = [];
const pushTo = [];
for (let i = 0; i < BAND_N; i += 1) {
bands.push({ bar: 0, cap: 0, hold: 0, vel: 0 });
base.push(0.95 - 0.5 * Math.pow(i / (BAND_N - 1), 0.8) + 0.1 * hash1(i, 11));
level.push(0);
push.push(0);
pushTo.push(0);
}
let k = 0;
let focus = 4;
let fakeX = 80;
let fakeY = 200;
let fakeDown = false;
function bandAt(x) {
return clamp(Math.floor((x - BASE_X) / BAR_STRIDE), 0, BAND_N - 1);
}
function poke(i, v) {
push[i] = PUSH_STEPS;
pushTo[i] = clamp(v, 0, 1);
focus = i;
h.touch();
if (h.reduce) {
bands[i].bar = pushTo[i];
bands[i].cap = pushTo[i];
}
h.refresh();
}
function heightAt(y) {
return clamp((FLOOR_Y - y) / (FLOOR_Y - TOP_BAR_Y), 0, 1);
}
h.listen(h.canvas, 'pointerdown', function (e) {
const p = h.local(e);
poke(bandAt(p[0]), heightAt(p[1]));
});
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'ArrowLeft') {
e.preventDefault();
focus = clamp(focus - 1, 0, BAND_N - 1);
h.touch();
h.refresh();
}
else if (key === 'ArrowRight') {
e.preventDefault();
focus = clamp(focus + 1, 0, BAND_N - 1);
h.touch();
h.refresh();
}
else if (key === 'Enter' || key === ' ') {
e.preventDefault();
poke(focus, 0.95);
}
});
// 박자마다 낮은 대역이 크게, 높은 대역은 어긋난 주기로 튄다
function drive(i) {
const period = 10 + Math.floor(hash1(i, 3) * 26);
const slot = Math.floor((k + i * 3) / period);
const burst = hash1(slot, i + 40);
const age = ((k + i * 3) % period) / period;
return base[i] * (0.15 + 0.85 * burst) * Math.pow(1 - age, 1.5);
}
function script() {
const j = k % 300;
fakeDown = false;
if (j >= 60 && j < 70) {
fakeX = 80 + (j - 60) * 4;
fakeY = 120;
}
if (j >= 70 && j < 74) {
fakeDown = true;
poke(bandAt(fakeX), 0.92);
}
if (j >= 100 && j < 140) {
fakeX = 150 + (j - 100) * 6;
fakeY = 160;
}
if (j >= 140 && j < 144) {
fakeDown = true;
poke(bandAt(fakeX), 0.88);
}
}
return {
step() {
k += 1;
if (h.auto()) {
script();
}
for (let i = 0; i < BAND_N; i += 1) {
let t = drive(i);
if (push[i] > 0) {
push[i] -= 1;
t = Math.max(t, pushTo[i] * (push[i] / PUSH_STEPS > 0.7 ? 1 : 0.4));
}
level[i] = t;
stepBand(bands[i], t, BAR_FALL_K, HOLD_FRAMES, CAP_FALL_G);
}
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
const segs = Math.floor((FLOOR_Y - TOP_BAR_Y) / (SEG_H + SEG_GAP));
for (let i = 0; i < BAND_N; i += 1) {
const x = BASE_X + i * BAR_STRIDE;
const lit = Math.round(bands[i].bar * segs);
for (let s = 0; s < segs; s += 1) {
const y = FLOOR_Y - (s + 1) * (SEG_H + SEG_GAP);
const ratio = s / (segs - 1);
const col = ratio < 0.5 ? mix(MINT, SKY_BLUE, ratio * 2) : mix(SKY_BLUE, PINK, (ratio - 0.5) * 2);
ctx.fillStyle = s < lit ? rgba(col, 1) : rgba(CREAM, 0.07);
ctx.fillRect(x, y, BAR_W, SEG_H);
}
const capY = FLOOR_Y - Math.max(1, Math.round(bands[i].cap * segs)) * (SEG_H + SEG_GAP);
ctx.fillStyle = rgba(BUTTER, 1);
ctx.fillRect(x, capY, BAR_W, SEG_H);
}
ctx.fillStyle = rgba(CREAM, 0.7);
ctx.fillRect(BASE_X + focus * BAR_STRIDE, FLOOR_Y + 4, BAR_W, 3);
if (h.auto() && !h.reduce) {
drawCursor(ctx, fakeX, fakeY, fakeDown);
}
h.say(TXT_BAND + ' ' + (focus + 1) + ': ' + bands[focus].bar.toFixed(2) + ' / ' + bands[focus].cap.toFixed(2));
}
};
}
boot(document.getElementById('wfRoot'), makeEqScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfEqualizerPeakHoldGravityFall.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const BAND_N = 24;
const HOLD_FRAMES = 28;
const CAP_FALL_G = 0.0006;
const BAR_FALL_K = 0.12;
const BAR_W = 14;
const BAR_STRIDE = 18;
const BASE_X = 24;
const FLOOR_Y = 232;
const TOP_BAR_Y = 52;
const SEG_H = 6;
const SEG_GAP = 2;
const PUSH_STEPS = 40;
const TXT_BAND = '대역';
// 막대 높이 b 와 꼭대기 표시 cap 의 한 걸음 갱신 — 막대는 즉시 오르고 천천히 내리며, 표시는 잠시 머문 뒤 중력으로 떨어진다
interface BandState { bar: number; cap: number; hold: number; vel: number; }
function stepBand(s: BandState, target: number, fallK: number, holdFrames: number, gravity: number): void {
if (target > s.bar) { s.bar = target; } else { s.bar += (target - s.bar) * fallK; }
if (s.bar >= s.cap) {
s.cap = s.bar;
s.hold = holdFrames;
s.vel = 0;
}
else if (s.hold > 0) {
s.hold -= 1;
}
else {
s.vel += gravity;
s.cap -= s.vel;
if (s.cap < s.bar) { s.cap = s.bar; s.vel = 0; }
}
}
function makeEqScene(h: Host): Scene {
const bands: BandState[] = [];
const base: number[] = [];
const level: number[] = [];
const push: number[] = [];
const pushTo: number[] = [];
for (let i = 0; i < BAND_N; i += 1) {
bands.push({ bar: 0, cap: 0, hold: 0, vel: 0 });
base.push(0.95 - 0.5 * Math.pow(i / (BAND_N - 1), 0.8) + 0.1 * hash1(i, 11));
level.push(0);
push.push(0);
pushTo.push(0);
}
let k = 0;
let focus = 4;
let fakeX = 80;
let fakeY = 200;
let fakeDown = false;
function bandAt(x: number): number {
return clamp(Math.floor((x - BASE_X) / BAR_STRIDE), 0, BAND_N - 1);
}
function poke(i: number, v: number): void {
push[i] = PUSH_STEPS;
pushTo[i] = clamp(v, 0, 1);
focus = i;
h.touch();
if (h.reduce) {
bands[i].bar = pushTo[i];
bands[i].cap = pushTo[i];
}
h.refresh();
}
function heightAt(y: number): number {
return clamp((FLOOR_Y - y) / (FLOOR_Y - TOP_BAR_Y), 0, 1);
}
h.listen(h.canvas, 'pointerdown', function (e: Event): void {
const p = h.local(e as PointerEvent);
poke(bandAt(p[0]), heightAt(p[1]));
});
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'ArrowLeft') { e.preventDefault(); focus = clamp(focus - 1, 0, BAND_N - 1); h.touch(); h.refresh(); }
else if (key === 'ArrowRight') { e.preventDefault(); focus = clamp(focus + 1, 0, BAND_N - 1); h.touch(); h.refresh(); }
else if (key === 'Enter' || key === ' ') { e.preventDefault(); poke(focus, 0.95); }
});
// 박자마다 낮은 대역이 크게, 높은 대역은 어긋난 주기로 튄다
function drive(i: number): number {
const period = 10 + Math.floor(hash1(i, 3) * 26);
const slot = Math.floor((k + i * 3) / period);
const burst = hash1(slot, i + 40);
const age = ((k + i * 3) % period) / period;
return base[i] * (0.15 + 0.85 * burst) * Math.pow(1 - age, 1.5);
}
function script(): void {
const j = k % 300;
fakeDown = false;
if (j >= 60 && j < 70) { fakeX = 80 + (j - 60) * 4; fakeY = 120; }
if (j >= 70 && j < 74) { fakeDown = true; poke(bandAt(fakeX), 0.92); }
if (j >= 100 && j < 140) { fakeX = 150 + (j - 100) * 6; fakeY = 160; }
if (j >= 140 && j < 144) { fakeDown = true; poke(bandAt(fakeX), 0.88); }
}
return {
step(): void {
k += 1;
if (h.auto()) { script(); }
for (let i = 0; i < BAND_N; i += 1) {
let t = drive(i);
if (push[i] > 0) {
push[i] -= 1;
t = Math.max(t, pushTo[i] * (push[i] / PUSH_STEPS > 0.7 ? 1 : 0.4));
}
level[i] = t;
stepBand(bands[i], t, BAR_FALL_K, HOLD_FRAMES, CAP_FALL_G);
}
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
const segs = Math.floor((FLOOR_Y - TOP_BAR_Y) / (SEG_H + SEG_GAP));
for (let i = 0; i < BAND_N; i += 1) {
const x = BASE_X + i * BAR_STRIDE;
const lit = Math.round(bands[i].bar * segs);
for (let s = 0; s < segs; s += 1) {
const y = FLOOR_Y - (s + 1) * (SEG_H + SEG_GAP);
const ratio = s / (segs - 1);
const col = ratio < 0.5 ? mix(MINT, SKY_BLUE, ratio * 2) : mix(SKY_BLUE, PINK, (ratio - 0.5) * 2);
ctx.fillStyle = s < lit ? rgba(col, 1) : rgba(CREAM, 0.07);
ctx.fillRect(x, y, BAR_W, SEG_H);
}
const capY = FLOOR_Y - Math.max(1, Math.round(bands[i].cap * segs)) * (SEG_H + SEG_GAP);
ctx.fillStyle = rgba(BUTTER, 1);
ctx.fillRect(x, capY, BAR_W, SEG_H);
}
ctx.fillStyle = rgba(CREAM, 0.7);
ctx.fillRect(BASE_X + focus * BAR_STRIDE, FLOOR_Y + 4, BAR_W, 3);
if (h.auto() && !h.reduce) { drawCursor(ctx, fakeX, fakeY, fakeDown); }
h.say(TXT_BAND + ' ' + (focus + 1) + ': ' + bands[focus].bar.toFixed(2) + ' / ' + bands[focus].cap.toFixed(2));
}
};
}
export default function WfEqualizerPeakHoldGravityFall() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeEqScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="막대는 즉시 솟고 꼭대기 표시는 잠시 머문 뒤 중력처럼 떨어지는 이퀄라이저" />
<p className={styles.status} data-role="status" aria-live="polite">대역</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>막대는 즉시 솟고 꼭대기 표시는 잠시 머문 뒤 중력처럼 떨어지는 이퀄라이저</p>
</div>
</main>
);
}08 · 棒は即座に立ち上がり、頂点の印はしばらく留まってから重力のように落ちるイコライザー — 計測 累積面積 18.564% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepBand(s, target, fallK, holdFrames, gravity) {
if (target > s.bar) {
s.bar = target;
}
else {
s.bar += (target - s.bar) * fallK;
}
if (s.bar >= s.cap) {
s.cap = s.bar;
s.hold = holdFrames;
s.vel = 0;
}
else if (s.hold > 0) {
s.hold -= 1;
}
else {
s.vel += gravity;
s.cap -= s.vel;
if (s.cap < s.bar) {
s.cap = s.bar;
s.vel = 0;
}
}
}
09録音中の音が右から継ぎ足され、自動ゲインが小さな音を引き上げる波形
append はステップごとに標本80個の最大・最小を一列にして右端へ足し、いちばん左の列を外すので、幅2pxの240列が約3.8秒で画面を横切ります。同じところで peakHold はステップごとに0.996倍ずつ掛けられて縮む最大値の記録になり、stepGain は GAIN_TARGET / peakHold を0.5から8のあいだに切った値へ向けて、上げるときは GAIN_RISE 0.012、下げるときは GAIN_FALL 0.35の割合でゲインを動かします。キャンバスを押すか Enter・Space を押すと拍手のような大きな音が入り、ゲインが急いで下がる様子を見られます。

<main class="stage">
<div class="wf is-demo" id="wfRoot">
<canvas class="wf__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="録音中の音が右から継ぎ足され、自動ゲインが小さな音を引き上げる波形"></canvas>
<p class="wf__status" data-role="status" aria-live="polite">自動ゲイン</p>
<button class="wf__btn" data-role="btn" type="button">一時停止</button>
<p class="wf__caption">録音中の音が右から継ぎ足され、自動ゲインが小さな音を引き上げる波形</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wf {
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: $subject-ink;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.wf__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wf { 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);
}
.wf {
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;
}
.wf__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.wf__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.wf__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.wf__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.wf__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.wf__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.wf__caption, .wf__btn {
font-size: 10px;
padding: 4px 8px;
}
.wf__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '一時停止';
const BTN_PLAY = '再生';
const BTN_NEXT = '次の瞬間';
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const INK = [23, 20, 26];
const BG_TOP = [24, 21, 40];
const BG_BOT = [48, 42, 78];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n, seed) {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) {
peak = a;
}
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) {
out[k] *= norm;
}
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig, a, b) {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
if (lo > hi) {
lo = 0;
hi = 0;
}
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx, top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx, x, y, down) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root, make) {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function () { };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function () { };
}
const canvas = canvasEl;
const ctx = ctxOrNull;
const status = statusEl;
const btn = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto() { return !reduce && !paused && idle > IDLE_STEPS; }
function sync() {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit() {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host = {
ctx,
canvas,
root,
reduce,
touch() {
idle = 0;
sync();
},
auto: isAuto,
refresh() {
if (scene) {
scene.draw();
}
},
say(text) {
if (text === lastText) {
return;
}
if (!reduce && clock - lastSayAt < 250) {
return;
}
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target, type, fn, opts) {
target.addEventListener(type, fn, opts);
cleaners.push(function () { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms) {
if (!scene) {
return;
}
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function () {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const GAIN_TARGET = 0.8;
const GAIN_RISE = 0.012;
const GAIN_FALL = 0.35;
const SCROLL_PX = 2;
const PER_STEP = 80;
const MAX_GAIN = 8;
const MID_Y = 140;
const HALF_Y = 82;
const PEAK_DECAY = 0.996;
const BURST_EVERY = 150;
const REDUCE_APPEND = 30;
const TXT_GAIN = '自動ゲイン';
const TXT_CLIP = 'クリップ';
// 自動ゲイン 한 걸음 — 최근 최댓값을 따라 목표 크기가 되도록 이득을 조절한다 (올릴 땐 천천히, 내릴 땐 빠르게)
function stepGain(gain, peakHold, target, rise, fall) {
const want = clamp(target / Math.max(peakHold, 0.0001), 0.5, MAX_GAIN);
return gain + (want - gain) * (want < gain ? fall : rise);
}
function makeRecordScene(h) {
const nCols = Math.floor(W / SCROLL_PX);
const rawLo = [];
const rawHi = [];
const gains = [];
for (let i = 0; i < nCols; i += 1) {
rawLo.push(0);
rawHi.push(0);
gains.push(1);
}
let gain = 1;
let peakHold = 0.1;
let n = 0;
let k = 0;
let burst = 0;
function level() {
const quiet = 0.1 + 0.05 * Math.sin(k * 0.05);
return quiet + burst * 0.85;
}
function append() {
const lv = level();
let lo = 1e9;
let hi = -1e9;
for (let i = 0; i < PER_STEP; i += 1) {
const ph = (n + i) * 0.19;
const v = lv * (Math.sin(ph) * 0.8 + Math.sin(ph * 2.3 + 0.5) * 0.25) + (hash1(n + i, 21) - 0.5) * 0.04;
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
n += PER_STEP;
const stepPeak = Math.max(Math.abs(lo), Math.abs(hi));
peakHold = Math.max(stepPeak, peakHold * PEAK_DECAY);
gain = stepGain(gain, peakHold, GAIN_TARGET, GAIN_RISE, GAIN_FALL);
rawLo.shift();
rawHi.shift();
gains.shift();
rawLo.push(lo);
rawHi.push(hi);
gains.push(gain);
}
function clap() {
burst = 1;
if (h.reduce) {
// 움직임 줄이기에서는 흐름 없이 박수 뒤 서른 칸을 한꺼번에 이어 붙여 보여 준다
for (let i = 0; i < REDUCE_APPEND; i += 1) {
k += 1;
append();
burst *= 0.93;
}
}
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointerdown', function () { clap(); });
h.listen(h.canvas, 'keydown', function (e) {
const key = e.key;
if (key === 'Enter' || key === ' ') {
e.preventDefault();
clap();
}
});
for (let i = 0; i < nCols + 20; i += 1) {
k += 1;
append();
}
return {
step() {
k += 1;
if (h.auto() && k % BURST_EVERY === 0) {
burst = 1;
}
append();
burst *= 0.93;
if (h.reduce) {
burst = burst < 0.02 ? 0 : burst;
}
},
draw() {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
ctx.fillStyle = rgba(CREAM, 0.14);
ctx.fillRect(0, MID_Y, W - 24, 1);
let clipped = false;
for (let c = 0; c < nCols; c += 1) {
const x = c * SCROLL_PX;
const g = gains[c];
// 이득을 곱하기 전 모습은 옅게
ctx.fillStyle = rgba(LILAC, 0.3);
ctx.fillRect(x, MID_Y - rawHi[c] * HALF_Y, SCROLL_PX, Math.max(1, (rawHi[c] - rawLo[c]) * HALF_Y));
const hi = rawHi[c] * g;
const lo = rawLo[c] * g;
const over = hi > 1 || lo < -1;
if (over && c > nCols - 12) {
clipped = true;
}
const y1 = MID_Y - clamp(hi, -1, 1) * HALF_Y;
const y2 = MID_Y - clamp(lo, -1, 1) * HALF_Y;
ctx.fillStyle = over ? rgba(PINK, 1) : rgba(MINT, 1);
ctx.fillRect(x, y1, SCROLL_PX, Math.max(1, y2 - y1));
}
// 이득 눈금 — 1배 위치에 가로선
const mx = W - 16;
ctx.fillStyle = rgba(CREAM, 0.14);
ctx.fillRect(mx, 40, 8, 196);
const gy = 236 - clamp(gain / MAX_GAIN, 0, 1) * 196;
ctx.fillStyle = rgba(BUTTER, 1);
ctx.fillRect(mx, gy, 8, 236 - gy);
ctx.fillStyle = rgba(CREAM, 0.8);
ctx.fillRect(mx - 3, 236 - 196 / MAX_GAIN, 14, 1);
// 녹음 표시
ctx.beginPath();
ctx.arc(W - 40, 22, 5, 0, PI2);
ctx.fillStyle = (Math.floor(k / 20) % 2 === 0 || h.reduce) ? rgba(PINK, 1) : rgba(PINK, 0.25);
ctx.fill();
h.say(clipped ? TXT_CLIP + ': ' + gain.toFixed(1) + ' x' : TXT_GAIN + ': ' + gain.toFixed(1) + ' x');
}
};
}
boot(document.getElementById('wfRoot'), makeRecordScene);
})();import { useEffect, useRef } from "react";
import styles from "./WfLiveRecordAppendAutoGain.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const IDLE_STEPS = 220;
const REDUCE_STEP_MS = 900;
const REDUCE_WARM_STEPS = 40;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 순간';
type RGB = [number, number, number];
// 파스텔 팔레트와 어두운 바탕 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const INK: RGB = [23, 20, 26];
const BG_TOP: RGB = [24, 21, 40];
const BG_BOT: RGB = [48, 42, 78];
interface Host {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
root: HTMLElement;
reduce: boolean;
touch(): void;
auto(): boolean;
refresh(): void;
say(text: string): void;
local(e: MouseEvent): [number, number];
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void;
}
interface Scene {
step(): void;
draw(): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string { return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')'; }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 사인 두 개를 음절마다 갈아 끼우고 엔벨로프를 씌운 합성 신호 (소리는 재생하지 않고 수치만 만든다)
function synth(n: number, seed: number): Float32Array {
const rnd = lcg(seed);
const out = new Float32Array(n);
let i = 0;
let p1 = 0;
let p2 = 0;
let peak = 0.0001;
while (i < n) {
const len = 1800 + Math.floor(rnd() * 4200);
const amp = 0.18 + rnd() * 0.82;
const rest = rnd() < 0.18;
const f1 = 0.02 + rnd() * 0.09;
const f2 = f1 * (1.5 + rnd());
const stop = Math.min(n, i + len);
for (let k = i; k < stop; k += 1) {
const x = (k - i) / len;
const env = Math.min(1, x / 0.06) * Math.pow(1 - x, 0.8);
p1 += PI2 * f1;
p2 += PI2 * f2;
const tone = Math.sin(p1) * 0.7 + Math.sin(p2) * 0.3;
const gain = rest ? 0.03 : amp * env;
const v = gain * tone + (rnd() - 0.5) * 0.08 * (rest ? 0.4 : amp);
out[k] = v;
const a = Math.abs(v);
if (a > peak) { peak = a; }
}
i = stop;
}
const norm = 0.92 / peak;
for (let k = 0; k < n; k += 1) { out[k] *= norm; }
return out;
}
// 표본 구간 [a, b) 의 최솟값과 최댓값 — 결과는 rangeLo, rangeHi 에 둔다
let rangeLo = 0;
let rangeHi = 0;
function scanRange(sig: Float32Array, a: number, b: number): void {
const from = Math.max(0, Math.floor(a));
const to = Math.min(sig.length, Math.max(from + 1, Math.ceil(b)));
let lo = 1e9;
let hi = -1e9;
for (let i = from; i < to; i += 1) {
const v = sig[i];
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
if (lo > hi) { lo = 0; hi = 0; }
rangeLo = lo;
rangeHi = hi;
}
function paintBg(ctx: CanvasRenderingContext2D, top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 가짜 포인터 — 시연 동안 손이 움직이는 것처럼 보이게 그린다
function drawCursor(ctx: CanvasRenderingContext2D, x: number, y: number, down: boolean): void {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 15);
ctx.lineTo(4, 11.5);
ctx.lineTo(7.5, 18);
ctx.lineTo(10, 16.8);
ctx.lineTo(6.6, 10.4);
ctx.lineTo(11.5, 10.2);
ctx.closePath();
ctx.fillStyle = rgba(CREAM, 1);
ctx.strokeStyle = rgba(INK, 1);
ctx.lineWidth = 1.4;
ctx.fill();
ctx.stroke();
if (down) {
ctx.beginPath();
ctx.arc(0, 0, 9, 0, PI2);
ctx.strokeStyle = rgba(PINK, 0.9);
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.restore();
}
function boot(root: HTMLElement, make: (h: Host) => Scene): () => void {
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const btnEl = root.querySelector('[data-role="btn"]');
if (!(canvasEl instanceof HTMLCanvasElement) || !statusEl || !(btnEl instanceof HTMLButtonElement)) {
return function (): void { /* 필요한 요소가 없으면 아무것도 하지 않는다 */ };
}
const ctxOrNull = canvasEl.getContext('2d');
if (!ctxOrNull) {
return function (): void { /* 2d 문맥이 없으면 아무것도 하지 않는다 */ };
}
const canvas: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxOrNull;
const status: Element = statusEl;
const btn: HTMLButtonElement = btnEl;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const cleaners: Array<() => void> = [];
let paused = false;
let idle = IDLE_STEPS + 1;
let clock = 0;
let lastSayAt = -1e9;
let lastText = status.textContent || '';
let scene: Scene | null = null;
let rafId = 0;
let acc = 0;
let lastTs = -1;
function isAuto(): boolean { return !reduce && !paused && idle > IDLE_STEPS; }
function sync(): void {
const on = isAuto();
if (root.classList.contains('is-demo') !== on) {
root.classList.toggle('is-demo', on);
}
}
function fit(): void {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(r.width * dpr));
const ch = Math.max(1, Math.round(r.height * dpr));
canvas.width = cw;
canvas.height = ch;
ctx.setTransform(cw / W, 0, 0, ch / H, 0, 0);
}
const host: Host = {
ctx,
canvas,
root,
reduce,
touch(): void {
idle = 0;
sync();
},
auto: isAuto,
refresh(): void {
if (scene) { scene.draw(); }
},
say(text: string): void {
if (text === lastText) { return; }
if (!reduce && clock - lastSayAt < 250) { return; }
lastText = text;
lastSayAt = clock;
// 시연이 혼자 돌 때는 읽어 주지 않고, 사람이 만졌을 때만 알린다
status.setAttribute('aria-live', isAuto() ? 'off' : 'polite');
status.textContent = text;
},
local(e: MouseEvent): [number, number] {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / Math.max(1, r.width), 0, W), clamp((e.clientY - r.top) * H / Math.max(1, r.height), 0, H)];
},
listen(target: EventTarget, type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions): void {
target.addEventListener(type, fn, opts);
cleaners.push(function (): void { target.removeEventListener(type, fn, opts); });
}
};
function advance(ms: number): void {
if (!scene) { return; }
const n = Math.max(1, Math.round(ms / FIXED_DT));
for (let i = 0; i < n; i += 1) {
idle += 1;
clock += FIXED_DT;
scene.step();
}
}
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused && scene) {
acc += dt;
while (acc >= FIXED_DT) {
idle += 1;
clock += FIXED_DT;
scene.step();
acc -= FIXED_DT;
}
sync();
scene.draw();
}
rafId = requestAnimationFrame(frame);
}
scene = make(host);
btn.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
fit();
if (reduce) {
root.classList.remove('is-demo');
advance(REDUCE_WARM_STEPS * FIXED_DT);
}
scene.draw();
host.listen(btn, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
host.refresh();
return;
}
paused = !paused;
btn.textContent = paused ? BTN_PLAY : BTN_PAUSE;
sync();
});
host.listen(window, 'resize', function (): void {
fit();
host.refresh();
});
if (!reduce) {
rafId = requestAnimationFrame(frame);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
};
}
const GAIN_TARGET = 0.8;
const GAIN_RISE = 0.012;
const GAIN_FALL = 0.35;
const SCROLL_PX = 2;
const PER_STEP = 80;
const MAX_GAIN = 8;
const MID_Y = 140;
const HALF_Y = 82;
const PEAK_DECAY = 0.996;
const BURST_EVERY = 150;
const REDUCE_APPEND = 30;
const TXT_GAIN = '자동 이득';
const TXT_CLIP = '넘침';
// 자동 이득 한 걸음 — 최근 최댓값을 따라 목표 크기가 되도록 이득을 조절한다 (올릴 땐 천천히, 내릴 땐 빠르게)
function stepGain(gain: number, peakHold: number, target: number, rise: number, fall: number): number {
const want = clamp(target / Math.max(peakHold, 0.0001), 0.5, MAX_GAIN);
return gain + (want - gain) * (want < gain ? fall : rise);
}
function makeRecordScene(h: Host): Scene {
const nCols = Math.floor(W / SCROLL_PX);
const rawLo: number[] = [];
const rawHi: number[] = [];
const gains: number[] = [];
for (let i = 0; i < nCols; i += 1) { rawLo.push(0); rawHi.push(0); gains.push(1); }
let gain = 1;
let peakHold = 0.1;
let n = 0;
let k = 0;
let burst = 0;
function level(): number {
const quiet = 0.1 + 0.05 * Math.sin(k * 0.05);
return quiet + burst * 0.85;
}
function append(): void {
const lv = level();
let lo = 1e9;
let hi = -1e9;
for (let i = 0; i < PER_STEP; i += 1) {
const ph = (n + i) * 0.19;
const v = lv * (Math.sin(ph) * 0.8 + Math.sin(ph * 2.3 + 0.5) * 0.25) + (hash1(n + i, 21) - 0.5) * 0.04;
if (v < lo) { lo = v; }
if (v > hi) { hi = v; }
}
n += PER_STEP;
const stepPeak = Math.max(Math.abs(lo), Math.abs(hi));
peakHold = Math.max(stepPeak, peakHold * PEAK_DECAY);
gain = stepGain(gain, peakHold, GAIN_TARGET, GAIN_RISE, GAIN_FALL);
rawLo.shift();
rawHi.shift();
gains.shift();
rawLo.push(lo);
rawHi.push(hi);
gains.push(gain);
}
function clap(): void {
burst = 1;
if (h.reduce) {
// 움직임 줄이기에서는 흐름 없이 박수 뒤 서른 칸을 한꺼번에 이어 붙여 보여 준다
for (let i = 0; i < REDUCE_APPEND; i += 1) {
k += 1;
append();
burst *= 0.93;
}
}
h.touch();
h.refresh();
}
h.listen(h.canvas, 'pointerdown', function (): void { clap(); });
h.listen(h.canvas, 'keydown', function (e: Event): void {
const key = (e as KeyboardEvent).key;
if (key === 'Enter' || key === ' ') {
e.preventDefault();
clap();
}
});
for (let i = 0; i < nCols + 20; i += 1) {
k += 1;
append();
}
return {
step(): void {
k += 1;
if (h.auto() && k % BURST_EVERY === 0) { burst = 1; }
append();
burst *= 0.93;
if (h.reduce) { burst = burst < 0.02 ? 0 : burst; }
},
draw(): void {
const ctx = h.ctx;
paintBg(ctx, BG_TOP, BG_BOT);
ctx.fillStyle = rgba(CREAM, 0.14);
ctx.fillRect(0, MID_Y, W - 24, 1);
let clipped = false;
for (let c = 0; c < nCols; c += 1) {
const x = c * SCROLL_PX;
const g = gains[c];
// 이득을 곱하기 전 모습은 옅게
ctx.fillStyle = rgba(LILAC, 0.3);
ctx.fillRect(x, MID_Y - rawHi[c] * HALF_Y, SCROLL_PX, Math.max(1, (rawHi[c] - rawLo[c]) * HALF_Y));
const hi = rawHi[c] * g;
const lo = rawLo[c] * g;
const over = hi > 1 || lo < -1;
if (over && c > nCols - 12) { clipped = true; }
const y1 = MID_Y - clamp(hi, -1, 1) * HALF_Y;
const y2 = MID_Y - clamp(lo, -1, 1) * HALF_Y;
ctx.fillStyle = over ? rgba(PINK, 1) : rgba(MINT, 1);
ctx.fillRect(x, y1, SCROLL_PX, Math.max(1, y2 - y1));
}
// 이득 눈금 — 1배 위치에 가로선
const mx = W - 16;
ctx.fillStyle = rgba(CREAM, 0.14);
ctx.fillRect(mx, 40, 8, 196);
const gy = 236 - clamp(gain / MAX_GAIN, 0, 1) * 196;
ctx.fillStyle = rgba(BUTTER, 1);
ctx.fillRect(mx, gy, 8, 236 - gy);
ctx.fillStyle = rgba(CREAM, 0.8);
ctx.fillRect(mx - 3, 236 - 196 / MAX_GAIN, 14, 1);
// 녹음 표시
ctx.beginPath();
ctx.arc(W - 40, 22, 5, 0, PI2);
ctx.fillStyle = (Math.floor(k / 20) % 2 === 0 || h.reduce) ? rgba(PINK, 1) : rgba(PINK, 0.25);
ctx.fill();
h.say(clipped ? TXT_CLIP + ': ' + gain.toFixed(1) + ' x' : TXT_GAIN + ': ' + gain.toFixed(1) + ' x');
}
};
}
export default function WfLiveRecordAppendAutoGain() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
return boot(root, makeRecordScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.wf} is-demo`} ref={rootRef}>
<canvas className={styles.canvas} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="녹음 중인 소리가 오른쪽에서 이어 붙고 자동 이득이 작은 소리를 끌어올리는 파형" />
<p className={styles.status} data-role="status" aria-live="polite">자동 이득</p>
<button className={styles.btn} data-role="btn" type="button">멈춤</button>
<p className={styles.caption}>녹음 중인 소리가 오른쪽에서 이어 붙고 자동 이득이 작은 소리를 끌어올리는 파형</p>
</div>
</main>
);
}09 · 録音中の音が右から継ぎ足され、自動ゲインが小さな音を引き上げる波形 — 計測 累積面積 33.41% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function append() {
const lv = level();
let lo = 1e9;
let hi = -1e9;
for (let i = 0; i < PER_STEP; i += 1) {
const ph = (n + i) * 0.19;
const v = lv * (Math.sin(ph) * 0.8 + Math.sin(ph * 2.3 + 0.5) * 0.25) + (hash1(n + i, 21) - 0.5) * 0.04;
if (v < lo) {
lo = v;
}
if (v > hi) {
hi = v;
}
}
n += PER_STEP;
const stepPeak = Math.max(Math.abs(lo), Math.abs(hi));
peakHold = Math.max(stepPeak, peakHold * PEAK_DECAY);
gain = stepGain(gain, peakHold, GAIN_TARGET, GAIN_RISE, GAIN_FALL);
rawLo.shift();
rawHi.shift();
gains.shift();
rawLo.push(lo);
rawHi.push(hi);
gains.push(gain);
}
値が波形の印象を変えるところ — 束の幅・戻りの係数・吸着の半径
九つの場面をブラウザで最後にまとめて書き出したとき、24枚のフレームのあいだで画素がどれだけ変わったかを先に置きます。累積面積は一度でも変わった画素の割合、動いたフレームは隣り合う二枚を比べた23回のうち変化が見つかった回数です。
| 場面 | 累積面積 | 強度の平均 | 動いたフレーム |
|---|---|---|---|
| 01 最大・最小の柱 | 38.4% | 67.9 | 23/23 |
| 02 二乗平均の輪郭 | 29.1% | 78.5 | 23/23 |
| 03 再生位置の分割 | 13.2% | 99.5 | 23/23 |
| 04 ホイール拡大のアンカー | 35.1% | 80.8 | 16/23 |
| 05 ゼロ交差吸着の切り出し | 17.9% | 78.5 | 23/23 |
| 06 立ち上がりエッジのトリガー | 32.3% | 69.8 | 23/23 |
| 07 極座標の輪 | 22.1% | 59.6 | 23/23 |
| 08 頂点保持のイコライザー | 18.6% | 133.9 | 23/23 |
| 09 録音の自動ゲイン | 33.4% | 110.4 | 23/23 |
03は再生位置が通った柱だけ色が変わる場面なので面積が13.2%と最も小さく、04は23回の比較のうち7回で画面が変わりませんでした。ここから下の数字はレンダリングではなく、各場面の関数をNode.js から直接呼んで数えた値です。
BUCKET_PX を3から8に広げると柱が160本から60本に減る
柱一本が受け持つ標本は270個から720個に増えます。1pxごとに480列で束ねた輪郭を正解として比べると、平均の誤差は0.030から0.083へ、最大の誤差は0.62から0.82へ広がり、大きな音のすぐ隣にある細い音がその柱に飲み込まれます。棒が太く見える代わりに細部が消える取引なので、細部が大事な編集画面には3のほうが向いています。
RELEASE_K を0.25に上げると余韻が38列から9列に縮む
合成信号では、瞬間の二乗平均と輪郭の平均の隔たりが0.026から0.008へ狭まり、輪郭が瞬間値にぴたりと張り付きます。この係数は列ごとに掛ける値なので、時定数は一列が抱える標本の数である60個に結び付いています。見える区間を広げて一列がもっと多くの標本を抱えると、同じ0.06でも余韻が長く感じられます。
SNAP_RADIUS を40に広げると離した場所をすべて引き寄せるが最大11.7px動かす
05の信号にはゼロ交差が23個あり、間隔は平均20.2px、最も狭いところで13.6、最も広いところで23.5です。つまみを10pxから470pxまで1px刻みで離してみると、半径6は離した場所の60%だけを引き寄せて平均1.8px、最大6.0px動かし、半径40は100%を引き寄せて平均5.1px、最大11.7px動かします。デモの台本で二度目に離す329pxは、両隣の交差の317.2と340.7がそれぞれ11.8pxと11.7px離れているため、半径6ではその場に残ります。
小さな音を持ち上げるには2.8秒、拍手のあとにゲインを下げるには11ステップかかる
09で入力の最大値が0.12なら目標のゲインは0.8 / 0.12 = 6.67で、GAIN_RISE 0.012ではその90%に届くまで178ステップ、約2.8秒かかります。逆に最大値0.95の拍手が入ると、GAIN_FALL 0.35のおかげでゲインは6.5から0.9未満まで11ステップで下がります。上げるときは遅く下げるときは速くしたのは、02の立ち上がりと戻りを向きだけ逆にした同じ考え方です。
どこで壊れるか — 束が広いとき・端で拡大するとき・レベルが信号を超えるとき
波形がおかしく見える地点を場面ごとに拾い、ソースの関数をNode.js から呼んで数字で押さえました。ブラウザで速さを測った項目はここに含めていません。
画面外の予熱がないと左端の輪郭が半分の高さから始まる
02の followEnvelope は let e = 0 から出発し、前の列の値を引き継ぎます。WARM_COLS の30列を画面の外で先に回している今は画面の最初の列の輪郭が0.242ですが、予熱を外すとおよそ半分の0.116から始まり、最初の40列で差が最大0.126まで開いて、画面の左にありもしない傾きが生まれます。
画面の端で拡大するとポインターの下の標本がずれる
ポインターを330pxや右端に近い460pxに置いて拡大しても、倍率4・16・96でアンカーはぴったりその位置に留まります。ところが波形の先頭に近い標本300番を画面の20pxに置いて拡大すると、開始点が0より下に行けないため、アンカーの画面上の位置は倍率1・4・16・96で3.3、13.3、20、20pxと動きます。最初の数段でポインターの下の瞬間が横へずれるのは viewStartFor の clamp のせいで、端を越えて空の区間を見せないためには、このずれを受け入れるしかありません。
再生位置が柱の隙間に落ちると割るものがない
03の柱は胴4pxに隙間2pxです。0.5px刻みで置ける960地点のうち、胴の内側でクリップ二つに割るのは58.3%で、残りは隙間か境目なので片方の色だけで塗ります。座標が論理座標なので、装置の画素比が1の画面では0.5pxの境目がアンチエイリアスでにじんで見え、2の画面では装置の画素にぴったり合います。
二つのつまみは24px以内に近づけない
05の MIN_SPAN 24pxは、吸着が終わったあとに limit() でもう一度かかります。ゼロ交差の平均間隔20pxより広いので、隣同士の二つの交差をそれぞれ端にする短い選択を作るなら、この値を下げる必要があります。
ホールドオフ40でもトリガーの跳ね返りは0にならない
06を400ステップ回し、トリガーの地点が60標本以内で跳ね返った回数を数えると、HOLDOFF 0で22回、40で9回です。トリガーが捕まえた窓の最初の標本は0.143の幅に収まりますが、フリーランの窓の最初の標本は1.676の幅を行き来するので、上の段が下の段よりずっと静かに見える理由はこの数字にあります。
レベルが信号の最大値を超えるとトリガーはフリーランと同じ窓に退く
06の入力は最大値が0.73なので、レベルを LEVEL_LIMIT の0.7以内で動かすかぎり、+0.7でも−0.7でも300ステップのあいだ一度もトリガーを取り逃しません。制限を外してレベルを0.99に置くと300回すべてで越える地点が見つからず、上の段はバッファの最後の一画面を描いて、下のフリーランと同じ形になります。
拍手の直後の数列はピンクのクリップで刻まれる
09でゲインがまだ高い最初の数列は、ゲインを掛けた値が1を超えてピンクの列が刻まれ、状態表示が「クリップ」に変わります。反対に無音が長く続くと peakHold が縮み続けてゲインが上限の8まで上がり、そのときは雑音まで8倍に大きくなります。
07の上ではタッチでページをスクロールできない
07はキャンバス全体が touch-action: none なので、輪の上に指を置いたままではページが動きません。04は pan-y で縦のスクロールをブラウザに任せて横のドラッグだけをアプリが使い、06は逆に pan-x、05はつまみの要素だけが none です。長い記事の真ん中に07を置くなら、輪の周りにスクロール用の余白を残しておくほうが安全です。
遅い端末でも頂点の印が落ちる時間は変わらない
08の棒と頂点の印は16msの固定ステップでしか変わらないので、画面のリフレッシュレートが違っても、頂点から底まで留まる時間を含めて85フレームという値は同じです。タブを離れて戻っても一度に追いつくのは10ステップだけなので、そのあいだに落ちていたはずの頂点の印が一気に消えることはなく、続きから下りてきます。
九つの場面の index.html・style.scss と React 版をまとめた zip は k2eeh529 を入力すると開き、上の定数を自分で書き換えて試すにはそのファイルが手元に必要です。
変形三つと波形場面のアクセシビリティ — キーボード・読み上げ・動きを減らす設定
| 名前 | 値 | 感じ |
|---|---|---|
| 束の幅を変える | 01 BUCKET_PX 3 → 8 | 柱が160本から60本に減って平均の誤差が0.030から0.083に広がり、棒は太くなりますが細い音が大きな隣に埋もれます。 |
| 戻りの係数を変える | 02 RELEASE_K 0.06 → 0.25 | 10%を下回るまで38列かかっていた余韻が9列に縮み、輪郭が瞬間値をぴったり追って震えます。 |
| 吸着の半径を変える | 05 SNAP_RADIUS 6 → 40 | 離した場所の60%しか引き寄せなかったつまみが100%引き寄せられ、その代わり平均5.1px、最大11.7pxまで手を離れて動かされます。 |
表の三行は、値の節で示した関数呼び出しの結果をそのまま縮めたものです。
九つの場面はキーボードだけで扱えます。キャンバスにはどれも tabindex="0" があり、05は二つのつまみにも別に付いています。preventDefault() は場面が処理する矢印キー・Home・End・+/-・Enter・Space でだけ呼び、04は倍率が実際に変わるホイール入力でだけ呼びます。スライダーの役割は三つの場面にあり、03のキャンバスは aria-valuetext を 3.0 / 6.0 s の形で、07のキャンバスは 77 deg の形で30ステップごとか入力の直後に更新し、05のつまみは標本番号0から1919を値が変わったときだけ書き込みます。
状態表示の <p> は HTML では aria-live="polite" で始まりますが、デモがひとりで回っているあいだは off に切り替わり、スクリーンリーダーが仮のポインターの動きを読み上げないようにしています。人が触れるか、動きを減らす設定がオンなら再び polite に戻り、250ms以内に重なった更新は飛ばします。フォーカスの枠は、九つの場面の style.css に :focus-visible で入っています。
prefers-reduced-motion がオンの画面では requestAnimationFrame のループを始めません。最初に40ステップだけ進めて一枚目の画面を作り、一時停止ボタンの位置に出る「次の瞬間」を押すたびに、900ms分にあたる56ステップを進めてから一度だけ描きます。入力を補間なしでその場の画面に反映するのは03・04・05・08・09の五つで、03は押した位置へ再生位置が移り、04は目標の倍率へ一気に変わり、05のつまみは吸着先にすぐ置かれ、08は棒と頂点の印が押した高さにすぐ立ち、09は拍手のあとの30列をまとめて継ぎ足します。
audio waveform css だけで足りるか?
棒が何本か拍に関係なく上下する飾りなら、CSS だけで作れます。けれどもこの記事が扱う仕事、つまり何万もの標本から柱の端を選び、輪郭を追い、切り出す境目を0に合わせることは値を数える作業なので、スクリプトが受け持ちます。舞台を決める .wf の幅は min(92vw, calc(160vh - 12.8vw))、比率は aspect-ratio: 480 / 300 で、描画は480×300の論理座標に対して行うため、窓を広げても狭めても柱の本数は変わりません。
ブラウザの音声 API で実際の音を受けて描く方法は オーディオビジュアライザー 作り方 9選 にあり、再生ボタンと進み具合のバーを持つプレイヤーの外側は 音楽 プレイヤー デザイン 9種 と見比べると、03の再生位置をどこに載せるかの見当がつきます。05の二つのつまみの仕組みは レンジスライダー ui 9種 のつまみ二つのスライダーと似ていて、正弦波の合成で描く曲線そのものに興味があれば リサージュ曲線 作り方9選 へ続きます。requestAnimationFrame の扱いは MDN の requestAnimationFrame、柱を二色に割る仕組みは clip() の説明に沿っています。
css audio waveform animation のコードを使い回す前によくある質問
本物の録音ファイルの波形に差し替えるにはどこを直しますか?
01・02・03・04・07は、場面の関数の一行目にある synth(SAMPLE_N, 459) のような呼び出しが数の配列を作っているので、そこに自分の標本の配列を入れれば差し替わります。01は値に HALF_Y の84を掛けて高さを決めるため、−1から1の範囲に揃えた配列でないと柱が舞台からはみ出します。ファイルを読み込んで標本にほどく部分は、この記事のデモには入っていません。
css audio waveform animation で標本が数百万個あると重くなりませんか?
01の bucketColumns は描くたびに見えている標本43,200個を一度ずつ走査するので、見える区間が長くなるほど仕事もそのぶん増えます。04は上の概観の帯120列を場面を作るときに一度だけ束ねておき、その後は数え直しません。長い音源なら、このように前もって束ねた配列を倍率ごとに分けて持つのが出発点になります。
画面の幅が変わると柱の数も変わりますか?
変わりません。fit() がキャンバスの実際の幅に devicePixelRatio を掛けてバックバッファだけを取り直し、setTransform で480×300の座標を引き伸ばして描くからです。340px以下ではキャプション・ボタン・状態表示の文字が10pxに縮むだけでキャンバスの比率は変わらず、レンダリングの測定ではスマホ幅でのはみ出しは九つの場面とも見つかりませんでした。









