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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 입력 신호 — 주파수가 천천히 떠도는 비대칭 파형에 빠른 잔물결을 얹었다. 표본 번호만으로 값이 정해진다
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초가 걸리고, 바늘에서 뒤처진 각도 behind 만큼 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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번의 비교 가운데 일곱 번은 화면이 그대로였다. 아래 숫자는 렌더가 아니라 각 장면의 함수를 노드에서 직접 불러 센 값이다.
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 의 공격·해제와 같은 발상을 방향만 뒤집은 것이다.
어디서 깨지는가 — 묶음이 넓을 때·가장자리에서 확대할 때·레벨이 신호를 넘을 때
파형이 이상해 보이는 지점을 장면별로 골라 소스 함수를 노드에서 불러 숫자로 짚었다. 브라우저에서 속도를 재 본 항목은 여기에 없다.
화면 밖 예열이 없으면 왼쪽 끝 윤곽이 절반 높이에서 시작한다
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 안에서 움직이면 300스텝 동안 한 번도 트리거를 놓치지 않는다(+0.7 과 −0.7 모두). 한계를 풀어 레벨을 0.99 에 두면 300번 모두 넘는 지점을 못 찾고, 위 칸은 버퍼의 마지막 한 화면을 그려 아래 프리런과 같은 모양이 된다.
박수 직후 몇 열은 분홍 넘침으로 찍힌다
09 의 이득이 아직 높은 첫 몇 열에서는 이득을 곱한 값이 1 을 넘어 분홍 열이 찍히고 상태 알약이 넘침 으로 바뀐다. 반대로 무음이 오래 가면 peakHold 가 계속 줄어 이득이 상한 8 까지 올라가고, 그때는 잡음까지 여덟 배로 커진다.
07 위에서는 터치로 페이지를 스크롤할 수 없다
07 은 캔버스 전체가 touch-action: none 이라 고리 위에 손가락을 올린 채로는 페이지가 움직이지 않는다. 04 는 pan-y 라 세로 스크롤을 브라우저에 맡기고 가로 끌기만 앱이 쓰며, 06 은 반대로 pan-x 이고 05 는 손잡이 요소만 none 이다. 긴 글 한가운데 07 을 넣는다면 고리 둘레에 스크롤할 여백을 남겨 두는 편이 안전하다.
느린 기기에서도 꼭대기 표시의 낙하 시간은 같다
08 의 막대와 꼭대기 표시는 16ms 고정 스텝으로만 바뀌므로 화면 주사율이 달라도 머무는 시간을 포함해 꼭대기에서 바닥까지 85프레임이라는 값은 같다. 탭을 비웠다 돌아오면 한 번에 10스텝만 따라잡으니, 그동안 떨어졌어야 할 꼭대기 표시가 한꺼번에 사라지지 않고 이어서 내려온다.
아홉 장면의 index.html·style.scss 와 리액트 판을 묶은 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 는 박수 뒤 서른 열을 한꺼번에 이어 붙인다.
audio waveform css 만으로 충분한가?
막대 몇 개가 박자 없이 오르내리는 장식이라면 CSS 만으로 된다. 하지만 이 편이 다루는 일, 곧 수만 개 표본에서 기둥 끝을 고르고 윤곽을 따라가고 잘라 낼 경계를 0 에 맞추는 일은 값을 세는 일이라 스크립트가 맡는다. CSS 쪽 몫은 무대 크기가 거의 전부인데, .wf 는 width: min(92vw, calc(160vh - 12.8vw)) 에 aspect-ratio: 480 / 300 이고 좌표는 480×300 논리 좌표라 창 크기가 바뀌어도 기둥 수는 그대로다.
브라우저 오디오 API 로 실제 소리를 받아 그리는 쪽은 오디오 비주얼라이저 만들기 에 있고, 재생 버튼과 진행 막대가 있는 플레이어 껍데기는 css 오디오 플레이어 디자인 과 같이 보면 03 의 재생 위치를 어디에 얹을지 감이 온다. 05 의 두 손잡이 구조는 범위 슬라이더 편의 두 엄지 슬라이더와 닮았고, 사인 합성으로 그리는 곡선 자체가 궁금하면 리사주 곡선 으로 이어진다. 그리는 방식의 근거는 requestAnimationFrame 과 clip() 문서에서 확인했다.
css audio waveform animation 코드를 가져다 쓰기 전에 자주 묻는 질문
진짜 녹음 파일의 파형으로 바꾸려면 어디를 고치나?
01·02·03·04·07 은 장면 함수 첫 줄의 synth(SAMPLE_N, 시드) 가 숫자 배열을 만드니, 그 자리에 내 표본 배열을 넣으면 된다. 01 은 값에 HALF_Y 84 를 곱해 높이를 정하므로 −1~1 범위로 맞춘 배열이어야 기둥이 무대 안에 들어온다. 파일을 읽어 표본으로 푸는 부분은 이 편의 데모에 없다.
css audio waveform animation 에 표본이 수백만 개면 무거워지지 않나?
01 의 bucketColumns 는 그릴 때마다 보이는 표본 43,200 개를 한 번씩 훑으니, 보이는 구간이 길어질수록 그만큼 일이 늘어난다. 04 는 위쪽 개요 띠 120칸을 장면이 만들어질 때 한 번만 묶어 두고 다시 세지 않는데, 긴 음원이라면 이처럼 미리 묶은 배열을 배율별로 따로 두는 쪽이 출발점이다.
화면 폭이 바뀌면 기둥 수도 바뀌나?
바뀌지 않는다. fit() 이 캔버스 실제 폭에 devicePixelRatio 를 곱해 백버퍼만 다시 잡고 setTransform 으로 480×300 좌표를 늘려 그리기 때문이다. 340px 이하에서는 캡션·버튼·상태 알약 글자가 10px 로 줄 뿐 캔버스 비율은 그대로이고, 렌더 측정에서 폰 폭 넘침은 아홉 장면 모두 없었다.









