ツリーマップ 9種 — 正方形に近く割る配置から値に合わせて流れる並べ替えまで
ツリーマップはセルをどう割るのでしょうか。01番は行にセルを足すたびに最も細長い比を測って正方形に近く閉じ、02番は値が変わるとセルがばねで新しい位置へ向かい、06番はセルの大きさを測って文字を縮めたり隠したりします。ツリーマップ9種は、配置を計算する関数とその結果だけを描くキャンバスを分けて作ったデモです。
ツリーマップは面積を読む絵ではなく面積を分ける計算です — ツリーマップを作るときは値の一覧を四角ひとつにすき間なく埋めるところから始まります
01 sc-treemap-slice-vs-squarify-aspect
02 sc-treemap-value-change-spring-reflow
03 sc-treemap-drill-down-zoom-nest
04 sc-treemap-delta-diverging-heat-fill
05 sc-treemap-nested-header-padding-inset
06 sc-treemap-label-fit-measure-hide
07 sc-treemap-live-rank-cross-swap
08 sc-treemap-circle-pack-morph-compare
09 sc-treemap-hero-disk-usage-folder-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 細長い帯ではなく正方形に近く割る配置
- 02 値が変わるとセルがばねで新しい位置へ向かう並べ替え
- 03 セルを押すと中の子セルへ拡大するドリルダウン
- 04 増減率ひとつで塗るパステルの発散色
- 05 グループの見出しと内側の余白で層を分ける入れ子のセル
- 06 セルの大きさを測って文字を縮めたり隠したりするラベル合わせ
- 07 値が流れて順位が入れ替わるときに場所を替えるセル
- 08 同じデータを四角いセルと円で行き来して比べる変形
- 09 フォルダごとの容量で埋まるディスク使用量のヒーロー
九つの場面が共有する設計はひとつです。セルの位置は描くコードが決めず、squarify と slice が返した四角形の一覧が決め、画面はその一覧と場面の時計だけを読んで描きます。ライブラリ・WebGL・CDNは使わず、乱数はシードを固定した生成器ひとつだけなので、同じシードは同じ盤を作ります。
値の一覧がセルの面積になる方法
ツリーマップとは、値の一覧を四角ひとつにすき間なく埋めて、セルの面積を値に比例させるチャートです。値は BASE に大きい順で24個入っていて、場面ごとに先頭の何個かを切って使い、名前は映像・写真・音楽のような架空の項目なので実在の会社やサービスの名前はありません。
配置の関数が計算し、画面はその結果だけを描く
slice は値の配列と四角形ひとつを受け取り、セルごとに { x, y, w, h } を返す純粋な関数です。squarify も同じ形なので、描くコードはこの四角形を塗って文字を載せるだけで、面積を自分で計算しません。
// 一方向のスライス — 行に分けず入力の順に縦の帯だけで切る
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
ツリーマップ 9種 — 正方形に近く割る行の閉じ方・値が変わるとばねで動く並べ替え・セルを押して入るドリルダウン・増減率で塗る発散色・見出しと余白で分ける入れ子・大きさを測って合わせるラベル・順位が入れ替わるときの場所替え・四角と円の比較変形・フォルダ容量のヒーロー
ツリーマップ 見方の要点は、面積が値、色が増減率、入れ子の枠が層という分担です。並びは配置から紹介画面へ進み、01・02は配置と並べ替え、03〜05は層と色、06・07は文字と順位、08・09は変形と紹介画面です。
描画で測った九つの場面の面積・フレーム・強さ
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 行の閉じ方 | 44.1% | 19/23 | 73.9 |
| 02 ばねの並べ替え | 41.6% | 21/23 | 44.0 |
| 03 ドリルダウン | 48.0% | 15/23 | 45.4 |
| 04 発散色 | 22.4% | 23/23 | 43.0 |
| 05 入れ子の余白 | 39.5% | 18/23 | 19.0 |
| 06 ラベル合わせ | 38.7% | 22/23 | 53.2 |
| 07 順位の入れ替え | 44.2% | 23/23 | 61.6 |
| 08 円への変形 | 26.5% | 12/23 | 32.8 |
| 09 ディスクのヒーロー | 54.6% | 12/23 | 65.7 |
もっとも広いのは54.6%の09で、もっとも狭いのは22.4%の04です。強さの平均は01の73.9が最大、05の19.0が最小で、04と07は23/23なので毎フレーム動き、08と09は12/23なので半分近いフレームで画面が止まっています。
01細長い帯ではなく正方形に近く割る配置
ディスク容量や予算の内訳をセルで見せるとき、セルが細長くなると小さな値が目に入りません。左は squarify が行にセルを足すたびに worstRow で最も細長いセルの比を測り直し、悪くなったら行を閉じ、右の slice は行を分けず縦の帯だけで切ります。セル12個での最悪の縦横比は左が1.58、右が28.73です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="同じ12個の値を、左は行にセルを足しながら比を測って行を閉じる方式で、右は一方向の帯だけで切るスライス方式で配置します。セルは0.14秒おきに一つずつ現れ、左は行が閉じるたびにミントの輪郭が走り、盤の隅の数字はそこまで現れたセルでいちばん細長い縦横比です。全部現れると左の行と右の帯が順に色で覆われ、続いていちばん細長いセルの輪郭が息づき、最後に消えてまた始まります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 01 — 줄을 닫는 배치(왼쪽)와 一方向スライス(오른쪽)를 같은 값으로 나란히 놓고, 가장 길쭉한 칸의 가로세로 비를 센다
const CELL_N = 12;
const REVEAL_T0 = 0.25;
const REVEAL_GAP = 0.14;
const LAP_S = 9.6;
const START_S = 1.7;
const REST_S = 6.5;
// 모두 나타난 뒤 줄을 하나씩(왼쪽 0.35초, 오른쪽 띠 0.2초) 다시 훑는 시각과 간격
const SWEEP_T0 = 2.0;
const ROW_SWEEP = 0.35;
const STRIP_SWEEP = 0.2;
const RING_T0 = 4.6;
let vals = [];
let lay = { rects: [], rowOf: [], rows: [] };
let strips = [];
let pinned = -1;
function revealT(i) { return REVEAL_T0 + i * REVEAL_GAP; }
function build() {
vals = BASE.slice(0, CELL_N);
lay = squarify(vals, PANEL_L);
strips = slice(vals, PANEL_R);
pinned = -1;
}
function update(dt) { if (dt < 0) {
return;
} }
// 이미 나타난 칸만 모아 가장 길쭉한 비를 잰다
function worstShown(rs) {
const shown = [];
for (let i = 0; i < rs.length; i += 1) {
if (sceneT >= revealT(i)) {
shown.push(rs[i]);
}
}
return shown.length ? worstAspect(shown) : 1;
}
function fadeAll() { return 1 - clamp((sceneT - 8.4) / 1.0, 0, 1); }
function drawSide(R, rs, worstAt) {
bed(R);
const fa = fadeAll();
for (let i = 0; i < rs.length; i += 1) {
const p = clamp((sceneT - revealT(i)) / 0.4, 0, 1);
if (p <= 0) {
continue;
}
tile(rs[i], TILES[i % 8], fa, 0.55 + 0.45 * backOut(p));
if (p >= 1) {
cellText(rs[i], NAMES[i], pct(vals[i], sumOf(vals)) + '%', fa);
}
}
// 가장 길쭉한 칸 — 줄 훑기가 끝난 RING_T0 뒤부터 고리가 숨 쉰다
if (sceneT > RING_T0 && worstAt >= 0) {
const k = 0.5 + 0.5 * Math.sin((sceneT - RING_T0) * 5);
const r = rs[worstAt];
outline(r.x + 1, r.y + 1, r.w - 2, r.h - 2, 5, 2.5, INK, (0.45 + 0.55 * k) * fa);
}
}
function argWorst(rs) {
let at = 0;
for (let i = 1; i < rs.length; i += 1) {
if (aspectOf(rs[i]) > aspectOf(rs[at])) {
at = i;
}
}
return at;
}
// 줄에 든 칸들을 감싸는 사각형
function rowBox(row) {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (const id of row) {
const r = lay.rects[id];
x0 = Math.min(x0, r.x);
y0 = Math.min(y0, r.y);
x1 = Math.max(x1, r.x + r.w);
y1 = Math.max(y1, r.y + r.h);
}
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('正方形に近く', '一方向スライス');
drawSide(PANEL_L, lay.rects, argWorst(lay.rects));
drawSide(PANEL_R, strips, argWorst(strips));
// 줄을 닫는 순간 그 줄 위로 한 번 빛이 지난다
const fa = fadeAll();
lay.rows.forEach(function (row) {
// 줄에서 가장 늦게 나타나는 칸이 나타난 직후가 줄을 닫는 순간
const age = sceneT - revealT(Math.max(...row)) - 0.2;
if (age < 0 || age > 0.5) {
return;
}
const bb = rowBox(row);
outline(bb.x + 1, bb.y + 1, bb.w - 2, bb.h - 2, 6, 3, MINT, (1 - age / 0.5) * fa);
});
// 줄 훑기 — 줄 하나의 칸 전체를 민트로, 오른쪽은 띠 하나를 장미색으로 차례로 덮는다
const rowAt = Math.floor((sceneT - SWEEP_T0) / ROW_SWEEP);
if (sceneT >= SWEEP_T0 && rowAt < lay.rows.length) {
const bb = rowBox(lay.rows[rowAt]);
box(bb.x + 1, bb.y + 1, bb.w - 2, bb.h - 2, 6, MINT, 0.5 * fa);
outline(bb.x + 1, bb.y + 1, bb.w - 2, bb.h - 2, 6, 3, INK, fa);
}
const stripAt = Math.floor((sceneT - SWEEP_T0) / STRIP_SWEEP);
if (sceneT >= SWEEP_T0 && stripAt < strips.length) {
const sr = strips[stripAt];
box(sr.x + 1, sr.y + 1, sr.w - 2, sr.h - 2, 4, ROSE, 0.5 * fa);
outline(sr.x + 1, sr.y + 1, sr.w - 2, sr.h - 2, 4, 3, INK, fa);
}
tag(worstShown(lay.rects).toFixed(1) + ' : 1', PANEL_L.x + PANEL_L.w - 6, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'right');
tag(worstShown(strips).toFixed(1) + ' : 1', PANEL_R.x + PANEL_R.w - 6, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'right');
if (pinned >= 0) {
const r = lay.rects[pinned];
outline(r.x + 2, r.y + 2, r.w - 4, r.h - 4, 4, 3, ROSE, 1);
}
}
function statusText() {
return '最悪比 {a} 対 {b}'.replace('{a}', worstAspect(lay.rects).toFixed(1)).replace('{b}', worstAspect(strips).toFixed(1));
}
function hits() {
return lay.rects.map(function (r, i) {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 値 {v} ・縦横比 {a}'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{a}', aspectOf(r).toFixed(1)) };
});
}
function onAct(i) { pinned = pinned === i ? -1 : i; }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SliceVsSquarifyAspect - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SliceVsSquarifyAspect.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SliceVsSquarifyAspect() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 01 — 줄을 닫는 배치(왼쪽)와 한 방향 슬라이스(오른쪽)를 같은 값으로 나란히 놓고, 가장 길쭉한 칸의 가로세로 비를 센다
const CELL_N = 12;
const REVEAL_T0 = 0.25;
const REVEAL_GAP = 0.14;
const LAP_S = 9.6;
const START_S = 1.7;
const REST_S = 6.5;
// 모두 나타난 뒤 줄을 하나씩(왼쪽 0.35초, 오른쪽 띠 0.2초) 다시 훑는 시각과 간격
const SWEEP_T0 = 2.0;
const ROW_SWEEP = 0.35;
const STRIP_SWEEP = 0.2;
const RING_T0 = 4.6;
let vals: number[] = [];
let lay: Lay = { rects: [], rowOf: [], rows: [] };
let strips: Rect[] = [];
let pinned = -1;
function revealT(i: number): number { return REVEAL_T0 + i * REVEAL_GAP; }
function build(): void {
vals = BASE.slice(0, CELL_N);
lay = squarify(vals, PANEL_L);
strips = slice(vals, PANEL_R);
pinned = -1;
}
function update(dt: number): void { if (dt < 0) { return; } }
// 이미 나타난 칸만 모아 가장 길쭉한 비를 잰다
function worstShown(rs: Rect[]): number {
const shown: Rect[] = [];
for (let i = 0; i < rs.length; i += 1) { if (sceneT >= revealT(i)) { shown.push(rs[i]); } }
return shown.length ? worstAspect(shown) : 1;
}
function fadeAll(): number { return 1 - clamp((sceneT - 8.4) / 1.0, 0, 1); }
function drawSide(R: Rect, rs: Rect[], worstAt: number): void {
bed(R);
const fa = fadeAll();
for (let i = 0; i < rs.length; i += 1) {
const p = clamp((sceneT - revealT(i)) / 0.4, 0, 1);
if (p <= 0) { continue; }
tile(rs[i], TILES[i % 8], fa, 0.55 + 0.45 * backOut(p));
if (p >= 1) { cellText(rs[i], NAMES[i], pct(vals[i], sumOf(vals)) + '%', fa); }
}
// 가장 길쭉한 칸 — 줄 훑기가 끝난 RING_T0 뒤부터 고리가 숨 쉰다
if (sceneT > RING_T0 && worstAt >= 0) {
const k = 0.5 + 0.5 * Math.sin((sceneT - RING_T0) * 5);
const r = rs[worstAt];
outline(r.x + 1, r.y + 1, r.w - 2, r.h - 2, 5, 2.5, INK, (0.45 + 0.55 * k) * fa);
}
}
function argWorst(rs: Rect[]): number {
let at = 0;
for (let i = 1; i < rs.length; i += 1) { if (aspectOf(rs[i]) > aspectOf(rs[at])) { at = i; } }
return at;
}
// 줄에 든 칸들을 감싸는 사각형
function rowBox(row: number[]): Rect {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (const id of row) {
const r = lay.rects[id];
x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y);
x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h);
}
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('정사각형에 가깝게', '한 방향 슬라이스');
drawSide(PANEL_L, lay.rects, argWorst(lay.rects));
drawSide(PANEL_R, strips, argWorst(strips));
// 줄을 닫는 순간 그 줄 위로 한 번 빛이 지난다
const fa = fadeAll();
lay.rows.forEach(function (row: number[]): void {
// 줄에서 가장 늦게 나타나는 칸이 나타난 직후가 줄을 닫는 순간
const age = sceneT - revealT(Math.max(...row)) - 0.2;
if (age < 0 || age > 0.5) { return; }
const bb = rowBox(row);
outline(bb.x + 1, bb.y + 1, bb.w - 2, bb.h - 2, 6, 3, MINT, (1 - age / 0.5) * fa);
});
// 줄 훑기 — 줄 하나의 칸 전체를 민트로, 오른쪽은 띠 하나를 장미색으로 차례로 덮는다
const rowAt = Math.floor((sceneT - SWEEP_T0) / ROW_SWEEP);
if (sceneT >= SWEEP_T0 && rowAt < lay.rows.length) {
const bb = rowBox(lay.rows[rowAt]);
box(bb.x + 1, bb.y + 1, bb.w - 2, bb.h - 2, 6, MINT, 0.5 * fa);
outline(bb.x + 1, bb.y + 1, bb.w - 2, bb.h - 2, 6, 3, INK, fa);
}
const stripAt = Math.floor((sceneT - SWEEP_T0) / STRIP_SWEEP);
if (sceneT >= SWEEP_T0 && stripAt < strips.length) {
const sr = strips[stripAt];
box(sr.x + 1, sr.y + 1, sr.w - 2, sr.h - 2, 4, ROSE, 0.5 * fa);
outline(sr.x + 1, sr.y + 1, sr.w - 2, sr.h - 2, 4, 3, INK, fa);
}
tag(worstShown(lay.rects).toFixed(1) + ' : 1', PANEL_L.x + PANEL_L.w - 6, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'right');
tag(worstShown(strips).toFixed(1) + ' : 1', PANEL_R.x + PANEL_R.w - 6, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'right');
if (pinned >= 0) {
const r = lay.rects[pinned];
outline(r.x + 2, r.y + 2, r.w - 4, r.h - 4, 4, 3, ROSE, 1);
}
}
function statusText(): string {
return '최악 비 {a} 대 {b}'.replace('{a}', worstAspect(lay.rects).toFixed(1)).replace('{b}', worstAspect(strips).toFixed(1));
}
function hits(): Hit[] {
return lay.rects.map(function (r: Rect, i: number): Hit {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 값 {v} · 가로세로 비 {a}'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{a}', aspectOf(r).toFixed(1)) };
});
}
function onAct(i: number): void { pinned = pinned === i ? -1 : i; }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'같은 값 12개를 왼쪽은 줄에 칸을 더하며 비를 재 줄을 닫는 방식으로, 오른쪽은 한 방향 띠로만 자르는 슬라이스 방식으로 배치한다. 칸이 0.14초 간격으로 하나씩 튀어나오고 왼쪽은 줄이 닫힐 때마다 민트 윤곽이 지나가며, 판 구석 숫자는 지금까지 나타난 칸 가운데 가장 길쭉한 칸의 가로세로 비다. 모두 나타나면 왼쪽 줄과 오른쪽 띠가 하나씩 색으로 덮이며 훑이고, 이어 가장 길쭉한 칸에 윤곽이 숨 쉬다가 끝에서 사라졌다 다시 시작한다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 · 細長い帯ではなく正方形に近く割る配置 — 計測 累積面積 44.126% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
02値が変わるとセルがばねで新しい位置へ向かう並べ替え
値がリアルタイムで変わるダッシュボードでセルが瞬間移動すると、どのセルがどこへ行ったか目が追えません。springStep がセルの四つの辺をそれぞれ減衰ばねで目標へ引き、目標は値が変わった瞬間だけ relayout が計算し直し、右の盤は目標をそのまま描くのでセルが跳ねます。文書のセルの値が2.6倍になった一回のあと、すべてのセルが目標の0.5px以内に入って留まる時刻は1.45秒で、最大の行きすぎは24.0pxです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="12個のセルの値が4回変わり、そのたびに配置を計算し直します。左のセルは四辺がばねで新しい位置を追い、移動中は目標の輪郭が残り、右のセルは新しい位置へ一気に飛びます。値が変わったセルには輪郭が光ります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 02 — 값이 바뀌는 순간에만 배치를 다시 계산해 목표 사각형을 얻고, 칸의 네 변이 스프링으로 따라간다(왼쪽). 오른쪽은 목표로 바로 건너뛴다
const CELL_N = 12;
const SPRING_K = 60;
const ZETA = 0.5;
const LAP_S = 9.6;
const START_S = 0.9;
const REST_S = 8.8;
// (시각, 칸 번호, 곱) — 값이 바뀌는 네 번
const CHANGES = [[0.4, 3, 2.6], [2.5, 0, 0.35], [4.6, 6, 3.4], [6.7, 9, 4.5]];
let vals = [];
let curE = [];
let velE = [];
let tgtL = [];
let tgtR = [];
let flashAt = [];
let lastNote = '';
function relayout() {
tgtL = squarify(vals, PANEL_L).rects;
tgtR = squarify(vals, PANEL_R).rects;
}
function applyChange(i, factor) {
const before = vals[i];
vals[i] = clamp(vals[i] * factor, 0.8, 90);
relayout();
flashAt[i] = sceneT;
lastNote = '{n} 値 {a} → {b}'.replace('{n}', NAMES[i]).replace('{a}', String(Math.round(before))).replace('{b}', String(Math.round(vals[i])));
}
function build() {
vals = BASE.slice(0, CELL_N);
relayout();
curE = tgtL.map(edgesOf);
velE = tgtL.map(function () { return [0, 0, 0, 0]; });
flashAt = vals.map(function () { return -9; });
lastNote = '';
CHANGES.forEach(function (c) {
script.push({ t: c[0], f: function () { applyChange(c[1], c[2]); } });
});
}
function update(dt) {
if (dt <= 0) {
return;
}
for (let i = 0; i < curE.length; i += 1) {
springStep(curE[i], velE[i], edgesOf(tgtL[i]), SPRING_K, ZETA, dt);
}
}
function drawPanel(R, rs) {
bed(R);
for (let i = 0; i < rs.length; i += 1) {
tile(rs[i], TILES[i % 8], 1, 1);
cellText(rs[i], NAMES[i], String(Math.round(vals[i])), 1);
const age = sceneT - flashAt[i];
if (age >= 0 && age < 0.9) {
outline(rs[i].x + 1, rs[i].y + 1, rs[i].w - 2, rs[i].h - 2, 5, 3, INK, 1 - age / 0.9);
}
}
}
function currentRects() { return curE.map(rectOf); }
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('ばねで追従', '即座に移動');
drawPanel(PANEL_L, currentRects());
drawPanel(PANEL_R, tgtR);
// 목표 윤곽 — 칸이 아직 가는 중일 때만 보인다
for (let i = 0; i < curE.length; i += 1) {
const cr = rectOf(curE[i]);
const gap = Math.abs(cr.x - tgtL[i].x) + Math.abs(cr.y - tgtL[i].y) + Math.abs(cr.w - tgtL[i].w) + Math.abs(cr.h - tgtL[i].h);
if (gap > 3) {
outline(tgtL[i].x + 1, tgtL[i].y + 1, tgtL[i].w - 2, tgtL[i].h - 2, 5, 1.5, INK2, clamp(gap / 40, 0, 0.7));
}
}
}
function statusText() { return lastNote === '' ? '値が変わると再配置' : lastNote; }
function hits() {
return currentRects().map(function (r, i) {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 値 {v} ・全体の {p}%'.replace('{n}', NAMES[i]).replace('{v}', String(Math.round(vals[i]))).replace('{p}', pct(vals[i], sumOf(vals))) };
});
}
// 고른 칸의 값을 1.7배로 — 배치를 다시 계산하고 칸이 스프링으로 따라간다
function onAct(i) { applyChange(i, 1.7); }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ValueChangeSpringReflow - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ValueChangeSpringReflow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ValueChangeSpringReflow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 02 — 값이 바뀌는 순간에만 배치를 다시 계산해 목표 사각형을 얻고, 칸의 네 변이 스프링으로 따라간다(왼쪽). 오른쪽은 목표로 바로 건너뛴다
const CELL_N = 12;
const SPRING_K = 60;
const ZETA = 0.5;
const LAP_S = 9.6;
const START_S = 0.9;
const REST_S = 8.8;
// (시각, 칸 번호, 곱) — 값이 바뀌는 네 번
const CHANGES: Array<[number, number, number]> = [[0.4, 3, 2.6], [2.5, 0, 0.35], [4.6, 6, 3.4], [6.7, 9, 4.5]];
let vals: number[] = [];
let curE: number[][] = [];
let velE: number[][] = [];
let tgtL: Rect[] = [];
let tgtR: Rect[] = [];
let flashAt: number[] = [];
let lastNote = '';
function relayout(): void {
tgtL = squarify(vals, PANEL_L).rects;
tgtR = squarify(vals, PANEL_R).rects;
}
function applyChange(i: number, factor: number): void {
const before = vals[i];
vals[i] = clamp(vals[i] * factor, 0.8, 90);
relayout();
flashAt[i] = sceneT;
lastNote = '{n} 값 {a} → {b}'.replace('{n}', NAMES[i]).replace('{a}', String(Math.round(before))).replace('{b}', String(Math.round(vals[i])));
}
function build(): void {
vals = BASE.slice(0, CELL_N);
relayout();
curE = tgtL.map(edgesOf);
velE = tgtL.map(function (): number[] { return [0, 0, 0, 0]; });
flashAt = vals.map(function (): number { return -9; });
lastNote = '';
CHANGES.forEach(function (c: [number, number, number]): void {
script.push({ t: c[0], f: function (): void { applyChange(c[1], c[2]); } });
});
}
function update(dt: number): void {
if (dt <= 0) { return; }
for (let i = 0; i < curE.length; i += 1) { springStep(curE[i], velE[i], edgesOf(tgtL[i]), SPRING_K, ZETA, dt); }
}
function drawPanel(R: Rect, rs: Rect[]): void {
bed(R);
for (let i = 0; i < rs.length; i += 1) {
tile(rs[i], TILES[i % 8], 1, 1);
cellText(rs[i], NAMES[i], String(Math.round(vals[i])), 1);
const age = sceneT - flashAt[i];
if (age >= 0 && age < 0.9) {
outline(rs[i].x + 1, rs[i].y + 1, rs[i].w - 2, rs[i].h - 2, 5, 3, INK, 1 - age / 0.9);
}
}
}
function currentRects(): Rect[] { return curE.map(rectOf); }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('스프링으로 따라감', '바로 건너뜀');
drawPanel(PANEL_L, currentRects());
drawPanel(PANEL_R, tgtR);
// 목표 윤곽 — 칸이 아직 가는 중일 때만 보인다
for (let i = 0; i < curE.length; i += 1) {
const cr = rectOf(curE[i]);
const gap = Math.abs(cr.x - tgtL[i].x) + Math.abs(cr.y - tgtL[i].y) + Math.abs(cr.w - tgtL[i].w) + Math.abs(cr.h - tgtL[i].h);
if (gap > 3) { outline(tgtL[i].x + 1, tgtL[i].y + 1, tgtL[i].w - 2, tgtL[i].h - 2, 5, 1.5, INK2, clamp(gap / 40, 0, 0.7)); }
}
}
function statusText(): string { return lastNote === '' ? '값이 바뀌면 다시 배치' : lastNote; }
function hits(): Hit[] {
return currentRects().map(function (r: Rect, i: number): Hit {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 값 {v} · 전체의 {p}%'.replace('{n}', NAMES[i]).replace('{v}', String(Math.round(vals[i]))).replace('{p}', pct(vals[i], sumOf(vals))) };
});
}
// 고른 칸의 값을 1.7배로 — 배치를 다시 계산하고 칸이 스프링으로 따라간다
function onAct(i: number): void { applyChange(i, 1.7); }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'열두 칸의 값이 네 번 바뀌고 바뀔 때마다 배치를 다시 계산한다. 왼쪽 칸은 네 변이 스프링으로 새 자리를 따라가며 가는 동안 목표 윤곽이 남고, 오른쪽 칸은 새 자리로 바로 건너뛴다. 값이 바뀐 칸에는 윤곽이 번쩍인다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}02 · 値が変わるとセルがばねで新しい位置へ向かう並べ替え — 計測 累積面積 41.64% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
03セルを押すと中の子セルへ拡大するドリルダウン
フォルダやカテゴリのように層のあるデータでは、セルを選ぶとそのセルが画面全体へ育つ動きがドリルダウンです。viewRect(p) が見る四角形をグループの四角形まで補間し、toScreen が世界座標をその四角形基準の画面座標へ移し、拡大が終わると kidLayout が同じ squarify で内側のセルを埋めます。最初のグループは横×1.92・縦×1.65、三つ目のグループは横×2.08・縦×2.60に広がり、縦横比の違うグループは均等でない拡大になります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="6つのグループのセルから一つを選ぶと、左はそのセルが盤全体になるようにビューを変換して残りを外へ押し出し、拡大が終わると内側のセルが一つずつ現れます。右は変換なしで内側の場面へ切り替わります。最初のグループに入って出たあと、三つ目のグループに入って出ます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 03 — 그룹 칸 하나를 골라 그 사각형이 판 전체가 되는 뷰 변환을 계산하고(왼쪽), 같은 변환으로 나머지 칸을 밖으로 민다. 오른쪽은 변환 없이 장면만 바꾼다
const ZOOM_T = 1.0;
const LAP_S = 10.4;
const START_S = 0.3;
const REST_S = 3.6;
// 그룹 여섯 — 이름 번호와 안쪽 칸 값
const GROUPS = [
groupOf(0, [leafOf(6, 30), leafOf(7, 22), leafOf(8, 14), leafOf(9, 9), leafOf(10, 5)]),
groupOf(1, [leafOf(11, 24), leafOf(12, 14), leafOf(13, 9), leafOf(14, 5)]),
groupOf(2, [leafOf(15, 18), leafOf(16, 12), leafOf(17, 8), leafOf(18, 6), leafOf(19, 3)]),
groupOf(3, [leafOf(20, 14), leafOf(21, 9), leafOf(22, 6), leafOf(23, 3)]),
groupOf(4, [leafOf(6, 10), leafOf(9, 7), leafOf(12, 5), leafOf(15, 3)]),
groupOf(5, [leafOf(8, 8), leafOf(11, 5), leafOf(14, 3), leafOf(17, 2)]),
];
let rootRects = [];
let rootRectsR = [];
// zg 는 들어간 그룹(없으면 -1), zT0 는 들어가거나 나오기 시작한 시각, zDir 은 1 이 들어가기·-1 이 나오기
let zg = -1;
let zT0 = -9;
let zDir = 1;
function build() {
const sums = GROUPS.map(function (g) { return g.v; });
rootRects = squarify(sums, PANEL_L).rects;
rootRectsR = squarify(sums, PANEL_R).rects;
zg = -1;
zT0 = -9;
zDir = 1;
// 첫 번째로 큰 그룹, 한 번 나왔다가 세 번째 그룹으로
script.push({ t: 0.4, f: function () { enter(0); } });
script.push({ t: 4.5, f: function () { leave(); } });
script.push({ t: 5.9, f: function () { enter(2); } });
script.push({ t: 9.2, f: function () { leave(); } });
}
function enter(g) { zg = g; zT0 = sceneT; zDir = 1; }
function leave() { if (zg >= 0) {
zT0 = sceneT;
zDir = -1;
} }
// 들어가는 정도 0~1 — 나오는 중이면 거꾸로
function zoomP() {
if (zg < 0) {
return 0;
}
const k = clamp((sceneT - zT0) / ZOOM_T, 0, 1);
return ease(zDir === 1 ? k : 1 - k);
}
function update(dt) {
if (dt < 0) {
return;
}
// 나오기가 끝나면 그룹에서 완전히 빠져나온다
if (zg >= 0 && zDir === -1 && sceneT - zT0 >= ZOOM_T) {
zg = -1;
}
}
// 보는 사각형 V 를 판 전체로 펴는 변환 — 월드 사각형을 화면 사각형으로
function viewRect(p) {
if (zg < 0) {
return PANEL_L;
}
const g = rootRects[zg];
return { x: lerp(PANEL_L.x, g.x, p), y: lerp(PANEL_L.y, g.y, p), w: lerp(PANEL_L.w, g.w, p), h: lerp(PANEL_L.h, g.h, p) };
}
function toScreen(r, v) {
const sx = PANEL_L.w / v.w;
const sy = PANEL_L.h / v.h;
return { x: PANEL_L.x + (r.x - v.x) * sx, y: PANEL_L.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
}
function kidLayout(R) {
return squarify(GROUPS[zg].kids.map(function (k) { return k.v; }), R).rects;
}
// 들어가기가 끝난 뒤 안쪽 칸이 하나씩 나타나는 정도
function kidsK(i) { return clamp((sceneT - zT0 - ZOOM_T - i * 0.09) / 0.35, 0, 1); }
function inside() { return zg >= 0 && zDir === 1 && sceneT - zT0 >= ZOOM_T; }
function drawLeft() {
bed(PANEL_L);
ctx.save();
ctx.beginPath();
ctx.rect(PANEL_L.x, PANEL_L.y, PANEL_L.w, PANEL_L.h);
ctx.clip();
const p = zoomP();
const v = viewRect(p);
const groupA = zg >= 0 && inside() ? 0 : 1;
for (let i = 0; i < rootRects.length; i += 1) {
const r = toScreen(rootRects[i], v);
tile(r, TILES[i % 8], i === zg ? groupA : 1, 1);
if (i !== zg || p < 1) {
cellText(r, NAMES[i], '', 1 - (i === zg ? p : 0));
}
}
if (zg >= 0 && p >= 1 && zDir === 1) {
const ks = kidLayout(PANEL_L);
for (let i = 0; i < ks.length; i += 1) {
const k = kidsK(i);
tile(ks[i], TILES[(zg + 1 + i) % 8], 1, 0.5 + 0.5 * backOut(k));
cellText(ks[i], NAMES[GROUPS[zg].kids[i].name], String(GROUPS[zg].kids[i].v), k);
}
}
ctx.restore();
}
function drawRight() {
bed(PANEL_R);
if (zg >= 0 && zDir === 1) {
const ks = kidLayout(PANEL_R);
const since = sceneT - zT0;
for (let i = 0; i < ks.length; i += 1) {
tile(ks[i], TILES[(zg + 1 + i) % 8], 1, 1);
cellText(ks[i], NAMES[GROUPS[zg].kids[i].name], String(GROUPS[zg].kids[i].v), 1);
}
// 장면이 바뀌는 첫 순간 한 번 번쩍 — 어디서 왔는지는 남지 않는다
if (since < 0.25) {
box(PANEL_R.x, PANEL_R.y, PANEL_R.w, PANEL_R.h, 8, PAPER, 0.7 * (1 - since / 0.25));
}
}
else {
for (let i = 0; i < rootRectsR.length; i += 1) {
tile(rootRectsR[i], TILES[i % 8], 1, 1);
cellText(rootRectsR[i], NAMES[i], '', 1);
}
}
}
function crumb() {
return zg >= 0 && zDir === 1 && zoomP() > 0.5 ? '全体 ›' + ' ' + NAMES[GROUPS[zg].name] : '全体';
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('変換で入る', '場面を差し替え');
drawLeft();
drawRight();
tag(crumb(), PANEL_L.x + 8, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'left');
if (zg >= 0 && zDir === 1) {
tag(NAMES[GROUPS[zg].name], PANEL_R.x + 8, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'left');
}
}
function statusText() {
if (zg >= 0 && zDir === 1) {
return '{n} の中 — {c}マス・合計 {s}'.replace('{n}', NAMES[GROUPS[zg].name]).replace('{c}', String(GROUPS[zg].kids.length)).replace('{s}', String(GROUPS[zg].v));
}
return 'グループ {g}個・セル {c}個'.replace('{g}', String(GROUPS.length)).replace('{c}', String(GROUPS.reduce(function (a, g) { return a + g.kids.length; }, 0)));
}
function hits() {
if (zg >= 0 && zDir === 1 && inside()) {
return kidLayout(PANEL_L).map(function (r, i) {
const k = GROUPS[zg].kids[i];
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 値 {v} ・グループの {p}%'.replace('{n}', NAMES[k.name]).replace('{v}', String(k.v)).replace('{p}', pct(k.v, GROUPS[zg].v)) };
});
}
const v = viewRect(zoomP());
return rootRects.map(function (g, i) {
const r = toScreen(g, v);
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 合計 {s} ・Enter で入る'.replace('{n}', NAMES[GROUPS[i].name]).replace('{s}', String(GROUPS[i].v)) };
});
}
// 그룹 칸을 고르면 들어가고, 안쪽에서 아무 칸이나 고르면 나온다
function onAct(i) {
if (zg < 0 || zDir === -1) {
enter(clamp(i, 0, GROUPS.length - 1));
}
else {
leave();
}
}
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DrillDownZoomNest - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DrillDownZoomNest.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DrillDownZoomNest() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 03 — 그룹 칸 하나를 골라 그 사각형이 판 전체가 되는 뷰 변환을 계산하고(왼쪽), 같은 변환으로 나머지 칸을 밖으로 민다. 오른쪽은 변환 없이 장면만 바꾼다
const ZOOM_T = 1.0;
const LAP_S = 10.4;
const START_S = 0.3;
const REST_S = 3.6;
// 그룹 여섯 — 이름 번호와 안쪽 칸 값
const GROUPS: Tree[] = [
groupOf(0, [leafOf(6, 30), leafOf(7, 22), leafOf(8, 14), leafOf(9, 9), leafOf(10, 5)]),
groupOf(1, [leafOf(11, 24), leafOf(12, 14), leafOf(13, 9), leafOf(14, 5)]),
groupOf(2, [leafOf(15, 18), leafOf(16, 12), leafOf(17, 8), leafOf(18, 6), leafOf(19, 3)]),
groupOf(3, [leafOf(20, 14), leafOf(21, 9), leafOf(22, 6), leafOf(23, 3)]),
groupOf(4, [leafOf(6, 10), leafOf(9, 7), leafOf(12, 5), leafOf(15, 3)]),
groupOf(5, [leafOf(8, 8), leafOf(11, 5), leafOf(14, 3), leafOf(17, 2)]),
];
let rootRects: Rect[] = [];
let rootRectsR: Rect[] = [];
// zg 는 들어간 그룹(없으면 -1), zT0 는 들어가거나 나오기 시작한 시각, zDir 은 1 이 들어가기·-1 이 나오기
let zg = -1;
let zT0 = -9;
let zDir = 1;
function build(): void {
const sums = GROUPS.map(function (g: Tree): number { return g.v; });
rootRects = squarify(sums, PANEL_L).rects;
rootRectsR = squarify(sums, PANEL_R).rects;
zg = -1;
zT0 = -9;
zDir = 1;
// 첫 번째로 큰 그룹, 한 번 나왔다가 세 번째 그룹으로
script.push({ t: 0.4, f: function (): void { enter(0); } });
script.push({ t: 4.5, f: function (): void { leave(); } });
script.push({ t: 5.9, f: function (): void { enter(2); } });
script.push({ t: 9.2, f: function (): void { leave(); } });
}
function enter(g: number): void { zg = g; zT0 = sceneT; zDir = 1; }
function leave(): void { if (zg >= 0) { zT0 = sceneT; zDir = -1; } }
// 들어가는 정도 0~1 — 나오는 중이면 거꾸로
function zoomP(): number {
if (zg < 0) { return 0; }
const k = clamp((sceneT - zT0) / ZOOM_T, 0, 1);
return ease(zDir === 1 ? k : 1 - k);
}
function update(dt: number): void {
if (dt < 0) { return; }
// 나오기가 끝나면 그룹에서 완전히 빠져나온다
if (zg >= 0 && zDir === -1 && sceneT - zT0 >= ZOOM_T) { zg = -1; }
}
// 보는 사각형 V 를 판 전체로 펴는 변환 — 월드 사각형을 화면 사각형으로
function viewRect(p: number): Rect {
if (zg < 0) { return PANEL_L; }
const g = rootRects[zg];
return { x: lerp(PANEL_L.x, g.x, p), y: lerp(PANEL_L.y, g.y, p), w: lerp(PANEL_L.w, g.w, p), h: lerp(PANEL_L.h, g.h, p) };
}
function toScreen(r: Rect, v: Rect): Rect {
const sx = PANEL_L.w / v.w;
const sy = PANEL_L.h / v.h;
return { x: PANEL_L.x + (r.x - v.x) * sx, y: PANEL_L.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
}
function kidLayout(R: Rect): Rect[] {
return squarify(GROUPS[zg].kids.map(function (k: Tree): number { return k.v; }), R).rects;
}
// 들어가기가 끝난 뒤 안쪽 칸이 하나씩 나타나는 정도
function kidsK(i: number): number { return clamp((sceneT - zT0 - ZOOM_T - i * 0.09) / 0.35, 0, 1); }
function inside(): boolean { return zg >= 0 && zDir === 1 && sceneT - zT0 >= ZOOM_T; }
function drawLeft(): void {
bed(PANEL_L);
ctx.save();
ctx.beginPath();
ctx.rect(PANEL_L.x, PANEL_L.y, PANEL_L.w, PANEL_L.h);
ctx.clip();
const p = zoomP();
const v = viewRect(p);
const groupA = zg >= 0 && inside() ? 0 : 1;
for (let i = 0; i < rootRects.length; i += 1) {
const r = toScreen(rootRects[i], v);
tile(r, TILES[i % 8], i === zg ? groupA : 1, 1);
if (i !== zg || p < 1) { cellText(r, NAMES[i], '', 1 - (i === zg ? p : 0)); }
}
if (zg >= 0 && p >= 1 && zDir === 1) {
const ks = kidLayout(PANEL_L);
for (let i = 0; i < ks.length; i += 1) {
const k = kidsK(i);
tile(ks[i], TILES[(zg + 1 + i) % 8], 1, 0.5 + 0.5 * backOut(k));
cellText(ks[i], NAMES[GROUPS[zg].kids[i].name], String(GROUPS[zg].kids[i].v), k);
}
}
ctx.restore();
}
function drawRight(): void {
bed(PANEL_R);
if (zg >= 0 && zDir === 1) {
const ks = kidLayout(PANEL_R);
const since = sceneT - zT0;
for (let i = 0; i < ks.length; i += 1) {
tile(ks[i], TILES[(zg + 1 + i) % 8], 1, 1);
cellText(ks[i], NAMES[GROUPS[zg].kids[i].name], String(GROUPS[zg].kids[i].v), 1);
}
// 장면이 바뀌는 첫 순간 한 번 번쩍 — 어디서 왔는지는 남지 않는다
if (since < 0.25) { box(PANEL_R.x, PANEL_R.y, PANEL_R.w, PANEL_R.h, 8, PAPER, 0.7 * (1 - since / 0.25)); }
} else {
for (let i = 0; i < rootRectsR.length; i += 1) {
tile(rootRectsR[i], TILES[i % 8], 1, 1);
cellText(rootRectsR[i], NAMES[i], '', 1);
}
}
}
function crumb(): string {
return zg >= 0 && zDir === 1 && zoomP() > 0.5 ? '전체 ›' + ' ' + NAMES[GROUPS[zg].name] : '전체';
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('변환으로 들어감', '장면을 바꿔치기');
drawLeft();
drawRight();
tag(crumb(), PANEL_L.x + 8, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'left');
if (zg >= 0 && zDir === 1) { tag(NAMES[GROUPS[zg].name], PANEL_R.x + 8, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'left'); }
}
function statusText(): string {
if (zg >= 0 && zDir === 1) {
return '{n} 안 — 칸 {c}개 · 합계 {s}'.replace('{n}', NAMES[GROUPS[zg].name]).replace('{c}', String(GROUPS[zg].kids.length)).replace('{s}', String(GROUPS[zg].v));
}
return '그룹 {g}개 · 칸 {c}개'.replace('{g}', String(GROUPS.length)).replace('{c}', String(GROUPS.reduce(function (a: number, g: Tree): number { return a + g.kids.length; }, 0)));
}
function hits(): Hit[] {
if (zg >= 0 && zDir === 1 && inside()) {
return kidLayout(PANEL_L).map(function (r: Rect, i: number): Hit {
const k = GROUPS[zg].kids[i];
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 값 {v} · 그룹의 {p}%'.replace('{n}', NAMES[k.name]).replace('{v}', String(k.v)).replace('{p}', pct(k.v, GROUPS[zg].v)) };
});
}
const v = viewRect(zoomP());
return rootRects.map(function (g: Rect, i: number): Hit {
const r = toScreen(g, v);
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 합계 {s} · Enter 로 들어가기'.replace('{n}', NAMES[GROUPS[i].name]).replace('{s}', String(GROUPS[i].v)) };
});
}
// 그룹 칸을 고르면 들어가고, 안쪽에서 아무 칸이나 고르면 나온다
function onAct(i: number): void {
if (zg < 0 || zDir === -1) { enter(clamp(i, 0, GROUPS.length - 1)); } else { leave(); }
}
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'여섯 그룹 칸 가운데 하나를 고르면 왼쪽은 그 칸이 판 전체가 되도록 뷰를 변환해 나머지를 밖으로 밀어내고 확대가 끝나면 안쪽 칸이 하나씩 나타나며, 오른쪽은 변환 없이 안쪽 칸 장면으로 바로 바뀐다. 첫 그룹에 들어갔다 나온 뒤 세 번째 그룹에 들어갔다 나온다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 · セルを押すと中の子セルへ拡大するドリルダウン — 計測 累積面積 48.039% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function viewRect(p) {
if (zg < 0) {
return PANEL_L;
}
const g = rootRects[zg];
return { x: lerp(PANEL_L.x, g.x, p), y: lerp(PANEL_L.y, g.y, p), w: lerp(PANEL_L.w, g.w, p), h: lerp(PANEL_L.h, g.h, p) };
}
function toScreen(r, v) {
const sx = PANEL_L.w / v.w;
const sy = PANEL_L.h / v.h;
return { x: PANEL_L.x + (r.x - v.x) * sx, y: PANEL_L.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
}
04増減率ひとつで塗るパステルの発散色
上がり下がりをひと目で見せる相場やスコアの盤では、面積を値が、色を増減率が受け持つとセル一枚が二つの情報を別々に語ります。divergeColor(d) は絶対値が0.12以下なら中立色のままにし、超えたら ease でバラ色(負)かミント色(正)まで補間し、右の valueColor は値から色を取って同じ情報を二度言います。4セットとも0.12以下のセルが4個ずつで、上がり・下がりのセル数は6・4、5・5、6・4、5・5です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="14個のセルの面積は値が決め、色は増減率が決めます。左は増減率0を真ん中に置いてピンクとミントの間を補間し、0に近い値はグレーに寄せ、増減率のセットが6回入れ替わるときセルごとに0.05秒ずつ遅れて切り替わり色が盤を横切って広がります。右は色も値から取った単色のラベンダーです"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 04 — 칸의 넓이는 값이 정하고 색은 증감률이 정한다. 증감률을 -1 에서 1 로 눌러 담아 0 을 가운데 두고 양쪽 파스텔 끝 사이를 보간하며, 0 근처는 회색에 눌러 둔다(왼쪽). 오른쪽은 색도 값에서 뽑는다
const CELL_N = 14;
const GRAY_NEAR_ZERO = 0.12;
const LAP_S = 9.6;
const START_S = 0.2;
const REST_S = 7.8;
// 증감률 세트 넷 — 칸마다 -1 에서 1 사이, 0 에 가까운 칸이 섞여 있다
const DELTA_SETS = [
[0.62, -0.35, 0.08, -0.82, 0.4, 0.05, -0.2, 0.9, -0.06, 0.3, -0.55, 0.15, -0.1, 0.7],
[-0.5, 0.7, -0.04, 0.25, -0.75, 0.12, 0.55, -0.3, 0.1, -0.9, 0.35, -0.08, 0.6, -0.4],
[0.2, 0.05, 0.85, -0.6, 0.03, -0.45, 0.3, 0.66, -0.7, 0.07, 0.5, -0.25, -0.02, 0.45],
[-0.8, -0.1, 0.45, 0.55, -0.3, 0.8, -0.65, 0.1, 0.35, -0.5, 0.06, 0.7, -0.35, -0.12],
];
let vals = [];
let rectsL = [];
let rectsR = [];
let dShow = [];
let dTgt = [];
let due = [];
let nextSet = [];
function build() {
vals = BASE.slice(0, CELL_N);
rectsL = squarify(vals, PANEL_L).rects;
rectsR = squarify(vals, PANEL_R).rects;
dShow = DELTA_SETS[lap % 2].slice(0, CELL_N);
dTgt = dShow.slice();
due = vals.map(function () { return -1; });
nextSet = vals.map(function () { return 0; });
// 여섯 번 새 증감률 세트로 — 칸마다 0.05초씩 늦게 바뀌어 색이 판을 가로질러 번진다
[0.4, 1.8, 3.2, 4.6, 6.0, 7.4].forEach(function (t, s) {
script.push({ t: t, f: function () { arrive(DELTA_SETS[(lap % 2 + s + 1) % 4]); } });
});
}
function arrive(set) {
for (let i = 0; i < CELL_N; i += 1) {
due[i] = sceneT + i * 0.05;
nextSet[i] = set[i];
}
}
function update(dt) {
if (dt <= 0) {
return;
}
const k = 1 - Math.exp(-dt * 4);
for (let i = 0; i < CELL_N; i += 1) {
if (due[i] >= 0 && sceneT >= due[i]) {
dTgt[i] = nextSet[i];
due[i] = -1;
}
dShow[i] += (dTgt[i] - dShow[i]) * k;
}
}
// 증감률 d 의 색 — 0 가까이는 중립 회색, 멀수록 한쪽 끝색. 0 부근 GRAY_NEAR_ZERO 안은 색을 아예 얹지 않는다
function divergeColor(d) {
const a = clamp(Math.abs(d), 0, 1);
const k = a <= GRAY_NEAR_ZERO ? 0 : ease((a - GRAY_NEAR_ZERO) / (1 - GRAY_NEAR_ZERO));
return mix(NEUTRAL, d < 0 ? ROSE : MINT, k);
}
// 대비판 — 색을 증감률이 아닌 값에서 뽑는다(큰 값일수록 진한 라벤더)
function valueColor(v) { return mix([236, 232, 250], [184, 170, 236], clamp(v / BASE[0], 0, 1)); }
function signed(d) { const p = Math.round(d * 100); return (p > 0 ? '+' : '') + p + '%'; }
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('増減率で塗る', '値で塗る');
bed(PANEL_L);
bed(PANEL_R);
for (let i = 0; i < CELL_N; i += 1) {
tile(rectsL[i], divergeColor(dShow[i]), 1, 1);
cellText(rectsL[i], NAMES[i], signed(dShow[i]), 1);
tile(rectsR[i], valueColor(vals[i]), 1, 1);
cellText(rectsR[i], NAMES[i], String(vals[i]), 1);
}
}
function statusText() {
let up = 0;
let down = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (dShow[i] > GRAY_NEAR_ZERO) {
up += 1;
}
else if (dShow[i] < -GRAY_NEAR_ZERO) {
down += 1;
}
}
return '上昇 {u}マス・下降 {d}マス'.replace('{u}', String(up)).replace('{d}', String(down));
}
function hits() {
return rectsL.map(function (r, i) {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 値 {v} ・増減 {d}'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{d}', signed(dTgt[i])) };
});
}
// 고른 칸의 증감 방향을 뒤집는다
function onAct(i) { dTgt[i] = -dTgt[i]; }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DeltaDivergingHeatFill - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DeltaDivergingHeatFill.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DeltaDivergingHeatFill() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 04 — 칸의 넓이는 값이 정하고 색은 증감률이 정한다. 증감률을 -1 에서 1 로 눌러 담아 0 을 가운데 두고 양쪽 파스텔 끝 사이를 보간하며, 0 근처는 회색에 눌러 둔다(왼쪽). 오른쪽은 색도 값에서 뽑는다
const CELL_N = 14;
const GRAY_NEAR_ZERO = 0.12;
const LAP_S = 9.6;
const START_S = 0.2;
const REST_S = 7.8;
// 증감률 세트 넷 — 칸마다 -1 에서 1 사이, 0 에 가까운 칸이 섞여 있다
const DELTA_SETS: number[][] = [
[0.62, -0.35, 0.08, -0.82, 0.4, 0.05, -0.2, 0.9, -0.06, 0.3, -0.55, 0.15, -0.1, 0.7],
[-0.5, 0.7, -0.04, 0.25, -0.75, 0.12, 0.55, -0.3, 0.1, -0.9, 0.35, -0.08, 0.6, -0.4],
[0.2, 0.05, 0.85, -0.6, 0.03, -0.45, 0.3, 0.66, -0.7, 0.07, 0.5, -0.25, -0.02, 0.45],
[-0.8, -0.1, 0.45, 0.55, -0.3, 0.8, -0.65, 0.1, 0.35, -0.5, 0.06, 0.7, -0.35, -0.12],
];
let vals: number[] = [];
let rectsL: Rect[] = [];
let rectsR: Rect[] = [];
let dShow: number[] = [];
let dTgt: number[] = [];
let due: number[] = [];
let nextSet: number[] = [];
function build(): void {
vals = BASE.slice(0, CELL_N);
rectsL = squarify(vals, PANEL_L).rects;
rectsR = squarify(vals, PANEL_R).rects;
dShow = DELTA_SETS[lap % 2].slice(0, CELL_N);
dTgt = dShow.slice();
due = vals.map(function (): number { return -1; });
nextSet = vals.map(function (): number { return 0; });
// 여섯 번 새 증감률 세트로 — 칸마다 0.05초씩 늦게 바뀌어 색이 판을 가로질러 번진다
[0.4, 1.8, 3.2, 4.6, 6.0, 7.4].forEach(function (t: number, s: number): void {
script.push({ t: t, f: function (): void { arrive(DELTA_SETS[(lap % 2 + s + 1) % 4]); } });
});
}
function arrive(set: number[]): void {
for (let i = 0; i < CELL_N; i += 1) { due[i] = sceneT + i * 0.05; nextSet[i] = set[i]; }
}
function update(dt: number): void {
if (dt <= 0) { return; }
const k = 1 - Math.exp(-dt * 4);
for (let i = 0; i < CELL_N; i += 1) {
if (due[i] >= 0 && sceneT >= due[i]) { dTgt[i] = nextSet[i]; due[i] = -1; }
dShow[i] += (dTgt[i] - dShow[i]) * k;
}
}
// 증감률 d 의 색 — 0 가까이는 중립 회색, 멀수록 한쪽 끝색. 0 부근 GRAY_NEAR_ZERO 안은 색을 아예 얹지 않는다
function divergeColor(d: number): RGB {
const a = clamp(Math.abs(d), 0, 1);
const k = a <= GRAY_NEAR_ZERO ? 0 : ease((a - GRAY_NEAR_ZERO) / (1 - GRAY_NEAR_ZERO));
return mix(NEUTRAL, d < 0 ? ROSE : MINT, k);
}
// 대비판 — 색을 증감률이 아닌 값에서 뽑는다(큰 값일수록 진한 라벤더)
function valueColor(v: number): RGB { return mix([236, 232, 250], [184, 170, 236], clamp(v / BASE[0], 0, 1)); }
function signed(d: number): string { const p = Math.round(d * 100); return (p > 0 ? '+' : '') + p + '%'; }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('증감률로 칠함', '값으로 칠함');
bed(PANEL_L);
bed(PANEL_R);
for (let i = 0; i < CELL_N; i += 1) {
tile(rectsL[i], divergeColor(dShow[i]), 1, 1);
cellText(rectsL[i], NAMES[i], signed(dShow[i]), 1);
tile(rectsR[i], valueColor(vals[i]), 1, 1);
cellText(rectsR[i], NAMES[i], String(vals[i]), 1);
}
}
function statusText(): string {
let up = 0;
let down = 0;
for (let i = 0; i < CELL_N; i += 1) {
if (dShow[i] > GRAY_NEAR_ZERO) { up += 1; } else if (dShow[i] < -GRAY_NEAR_ZERO) { down += 1; }
}
return '오름 {u}칸 · 내림 {d}칸'.replace('{u}', String(up)).replace('{d}', String(down));
}
function hits(): Hit[] {
return rectsL.map(function (r: Rect, i: number): Hit {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 값 {v} · 증감 {d}'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{d}', signed(dTgt[i])) };
});
}
// 고른 칸의 증감 방향을 뒤집는다
function onAct(i: number): void { dTgt[i] = -dTgt[i]; }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'열네 칸의 넓이는 값이 정하고 색은 증감률이 정한다. 왼쪽은 증감률 0 을 가운데 두고 분홍과 민트 사이를 보간하며 0 근처는 회색으로 눌러 두고, 증감률 세트가 여섯 번 바뀔 때 칸마다 0.05초씩 늦게 바뀌어 색이 판을 가로질러 번진다. 오른쪽은 색도 값에서 뽑은 라벤더 단색이다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 · 増減率ひとつで塗るパステルの発散色 — 計測 累積面積 22.384% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function divergeColor(d) {
const a = clamp(Math.abs(d), 0, 1);
const k = a <= GRAY_NEAR_ZERO ? 0 : ease((a - GRAY_NEAR_ZERO) / (1 - GRAY_NEAR_ZERO));
return mix(NEUTRAL, d < 0 ? ROSE : MINT, k);
}
// 対比版 — 色を増減率ではなく値から取る(大きい値ほど濃いラベンダー)
function valueColor(v) { return mix([236, 232, 250], [184, 170, 236], clamp(v / BASE[0], 0, 1)); }
function signed(d) { const p = Math.round(d * 100); return (p > 0 ? '+' : '') + p + '%'; }
05グループの見出しと内側の余白で層を分ける入れ子のセル
部署・チーム・個人のように層が重なる構成は、見出しと余白がないと層の境目が見えません。layNode が見出しの帯を切り分け、padAt の余白だけ縮めた四角形で子を再び squarify し、左は深さごとに余白へ0.55を掛けて減らし、右はどの層にも同じ余白を与えます。余白8pxでの葉の面積が盤に占める割合は左が40.1%、右が19.1%で、いちばん小さい葉の短い辺は5.2px対0pxです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="4つのグループが3つの束に、束が3〜4枚の葉に分かれる入れ子のセルが層ごとに現れます。左はヘッダー帯を切り離して層が深いほど余白を縮めるので内側のセルが残り、右はどの層にも同じ余白を与えるので深いセルが細くなったり消えたりします。層ごとに余白の帯が順にローズ色で照らされ、隅の数字は見える葉の数と全体の葉の数です"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 05 — 그룹 칸 위쪽에 머리글 띠를 떼고 남은 사각형을 안쪽 여백만큼 줄여 그 안에서 다시 배치한다. 왼쪽은 층이 깊어질수록 여백을 PAD_DECAY 배씩 줄이고, 오른쪽은 모든 층에 같은 여백을 준다
const HEADER_H = 12;
const PAD = 8;
const PAD_DECAY = 0.55;
const MIN_SHOWN = 5;
const LAP_S = 9.8;
const START_S = 3.3;
const REST_S = 6.0;
// 층마다 여백 띠를 장미색으로 비추는 시각과 간격 — 깊이 0·1·2 순서
const PADLIGHT_T0 = 4.2;
const PADLIGHT_GAP = 0.9;
let treeRoot = leafOf(0, 1);
let boxL = { r: PANEL_L, inner: PANEL_L, depth: 0, name: 0, v: 1, at: 0, kids: [] };
let boxR = { r: PANEL_R, inner: PANEL_R, depth: 0, name: 0, v: 1, at: 0, kids: [] };
let pinned = null;
// 층 번호마다 나타나는 시각을 세는 카운터
let seen = [0, 0, 0, 0];
function makeTree(seed) {
const rnd = seeded(seed);
const groups = [];
for (let g = 0; g < 4; g += 1) {
const subs = [];
for (let s = 0; s < 3; s += 1) {
const leaves = [];
const n = 3 + Math.floor(rnd() * 2);
for (let k = 0; k < n; k += 1) {
leaves.push(leafOf(4 + (g * 5 + s * 3 + k) % 20, 3 + Math.round(rnd() * rnd() * 22)));
}
subs.push(groupOf(g * 3 + s + 6, leaves));
}
groups.push(groupOf(g, subs));
}
return groupOf(0, groups);
}
function padAt(depth, flat) { return flat ? PAD : PAD * Math.pow(PAD_DECAY, depth); }
// 노드 n 을 사각형 R 에 놓는다. 머리글(깊이 1·2)을 떼고 안쪽 여백만큼 줄인 사각형에서 자식을 다시 배치
function layNode(n, R, depth, flat) {
const b = { r: R, inner: R, depth: depth, name: n.name, v: n.v, at: 0, kids: [] };
seen[depth] += 1;
b.at = depth === 0 ? 0 : (depth === 1 ? 0.4 + (seen[1] - 1) * 0.15 : (depth === 2 ? 1.5 + (seen[2] - 1) * 0.08 : 2.8 + (seen[3] - 1) * 0.03));
if (n.kids.length === 0) {
return b;
}
const p = padAt(depth, flat);
const head = depth === 0 ? 0 : HEADER_H;
const innerR = { x: R.x + p, y: R.y + head + (depth === 0 ? p : 0), w: Math.max(0, R.w - 2 * p), h: Math.max(0, R.h - head - p - (depth === 0 ? p : 0)) };
b.inner = innerR;
const lay = squarify(n.kids.map(function (k) { return k.v; }), innerR);
b.kids = n.kids.map(function (k, i) { return layNode(k, lay.rects[i], depth + 1, flat); });
return b;
}
function leaves(b, out) {
if (b.kids.length === 0) {
out.push(b);
}
else {
for (const k of b.kids) {
leaves(k, out);
}
}
return out;
}
function shownCount(b) {
let c = 0;
for (const l of leaves(b, [])) {
if (Math.min(l.r.w, l.r.h) >= MIN_SHOWN) {
c += 1;
}
}
return c;
}
function build() {
treeRoot = makeTree(5071 + (lap % 2) * 97);
seen = [0, 0, 0, 0];
boxL = layNode(treeRoot, PANEL_L, 0, false);
seen = [0, 0, 0, 0];
boxR = layNode(treeRoot, PANEL_R, 0, true);
pinned = null;
}
function update(dt) { if (dt < 0) {
return;
} }
function fadeAll() { return 1 - clamp((sceneT - 8.6) / 1.0, 0, 1); }
function drawBox(b, group) {
const p = clamp((sceneT - b.at) / 0.4, 0, 1);
if (b.depth === 0) {
for (const k of b.kids) {
drawBox(k, group);
}
return;
}
if (p <= 0) {
return;
}
const a = fadeAll();
const base = TILES[group % 8];
const col = b.depth === 1 ? mix(base, INK, 0.14) : (b.depth === 2 ? base : mix(base, [255, 255, 255], 0.75));
const pop = 0.7 + 0.3 * backOut(p);
if (Math.min(b.r.w, b.r.h) >= 1.5) {
tile(b.r, col, a * p, pop);
}
if (b.depth <= 2 && b.r.w > 20 && b.r.h > HEADER_H + 2) {
const nm = NAMES[b.name];
if (b.depth === 1) {
box(b.r.x + 1, b.r.y + 1, b.r.w - 2, HEADER_H - 1, 4, mix(base, INK, 0.28), 0.9 * a * p);
}
if (estW(nm, 9) + 8 <= b.r.w) {
text(nm, b.r.x + 5, b.r.y + 1 + HEADER_H / 2, INK, a * p, 9, 700, 'left');
}
}
for (const k of b.kids) {
drawBox(k, group);
}
}
// 깊이 d 인 칸의 여백 띠(바깥 사각형에서 안쪽 사각형을 뺀 틀)를 한 번 비춘다
function padLight(b, d, k) {
if (b.kids.length === 0) {
return;
}
if (b.depth === d && Math.min(b.r.w, b.r.h) >= 1.5) {
ctx.beginPath();
ctx.rect(b.r.x, b.r.y, b.r.w, b.r.h);
ctx.rect(b.inner.x, b.inner.y, b.inner.w, b.inner.h);
ctx.fillStyle = rgba(ROSE, 0.85 * k);
ctx.fill('evenodd');
}
for (const c of b.kids) {
padLight(c, d, k);
}
}
function drawPanel(R, b) {
bed(R);
b.kids.forEach(function (g, i) { drawBox(g, i); });
for (let d = 0; d < 3; d += 1) {
const age = sceneT - PADLIGHT_T0 - d * PADLIGHT_GAP;
if (age >= 0 && age < PADLIGHT_GAP) {
padLight(b, d, Math.sin(age / PADLIGHT_GAP * Math.PI) * fadeAll());
}
}
tag(shownCount(b) + ' / ' + leaves(b, []).length, R.x + R.w - 6, R.y + R.h - 12, R === PANEL_L ? INK : PAPER, R === PANEL_L ? PAPER : INK, 10, 'right');
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('層ごとに余白を縮小', '全層で同じ余白');
drawPanel(PANEL_L, boxL);
drawPanel(PANEL_R, boxR);
if (pinned) {
outline(pinned.r.x + 1, pinned.r.y + 1, pinned.r.w - 2, pinned.r.h - 2, 4, 3, ROSE, 1);
}
}
function statusText() {
return '見えるセル {a} 対 {b}'.replace('{a}', String(shownCount(boxL))).replace('{b}', String(shownCount(boxR)));
}
// 그룹과 안쪽 묶음(깊이 1·2)을 차례로 — 키보드로 한 칸씩
function flatBoxes() {
const out = [];
for (const g of boxL.kids) {
out.push(g);
for (const s of g.kids) {
out.push(s);
}
}
return out;
}
function hits() {
return flatBoxes().map(function (b) {
return { x: b.r.x, y: b.r.y, w: b.r.w, h: b.r.h, label: '{n} 合計 {v} ・深さ {d}'.replace('{n}', NAMES[b.name]).replace('{v}', String(b.v)).replace('{d}', String(b.depth)) };
});
}
function onAct(i) { const b = flatBoxes()[i]; pinned = pinned === b ? null : b; }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// NestedHeaderPaddingInset - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./NestedHeaderPaddingInset.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function NestedHeaderPaddingInset() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 05 — 그룹 칸 위쪽에 머리글 띠를 떼고 남은 사각형을 안쪽 여백만큼 줄여 그 안에서 다시 배치한다. 왼쪽은 층이 깊어질수록 여백을 PAD_DECAY 배씩 줄이고, 오른쪽은 모든 층에 같은 여백을 준다
const HEADER_H = 12;
const PAD = 8;
const PAD_DECAY = 0.55;
const MIN_SHOWN = 5;
const LAP_S = 9.8;
const START_S = 3.3;
const REST_S = 6.0;
type Box = { r: Rect; inner: Rect; depth: number; name: number; v: number; at: number; kids: Box[] };
// 층마다 여백 띠를 장미색으로 비추는 시각과 간격 — 깊이 0·1·2 순서
const PADLIGHT_T0 = 4.2;
const PADLIGHT_GAP = 0.9;
let treeRoot: Tree = leafOf(0, 1);
let boxL: Box = { r: PANEL_L, inner: PANEL_L, depth: 0, name: 0, v: 1, at: 0, kids: [] };
let boxR: Box = { r: PANEL_R, inner: PANEL_R, depth: 0, name: 0, v: 1, at: 0, kids: [] };
let pinned: Box | null = null;
// 층 번호마다 나타나는 시각을 세는 카운터
let seen: number[] = [0, 0, 0, 0];
function makeTree(seed: number): Tree {
const rnd = seeded(seed);
const groups: Tree[] = [];
for (let g = 0; g < 4; g += 1) {
const subs: Tree[] = [];
for (let s = 0; s < 3; s += 1) {
const leaves: Tree[] = [];
const n = 3 + Math.floor(rnd() * 2);
for (let k = 0; k < n; k += 1) { leaves.push(leafOf(4 + (g * 5 + s * 3 + k) % 20, 3 + Math.round(rnd() * rnd() * 22))); }
subs.push(groupOf(g * 3 + s + 6, leaves));
}
groups.push(groupOf(g, subs));
}
return groupOf(0, groups);
}
function padAt(depth: number, flat: boolean): number { return flat ? PAD : PAD * Math.pow(PAD_DECAY, depth); }
// 노드 n 을 사각형 R 에 놓는다. 머리글(깊이 1·2)을 떼고 안쪽 여백만큼 줄인 사각형에서 자식을 다시 배치
function layNode(n: Tree, R: Rect, depth: number, flat: boolean): Box {
const b: Box = { r: R, inner: R, depth: depth, name: n.name, v: n.v, at: 0, kids: [] };
seen[depth] += 1;
b.at = depth === 0 ? 0 : (depth === 1 ? 0.4 + (seen[1] - 1) * 0.15 : (depth === 2 ? 1.5 + (seen[2] - 1) * 0.08 : 2.8 + (seen[3] - 1) * 0.03));
if (n.kids.length === 0) { return b; }
const p = padAt(depth, flat);
const head = depth === 0 ? 0 : HEADER_H;
const innerR: Rect = { x: R.x + p, y: R.y + head + (depth === 0 ? p : 0), w: Math.max(0, R.w - 2 * p), h: Math.max(0, R.h - head - p - (depth === 0 ? p : 0)) };
b.inner = innerR;
const lay = squarify(n.kids.map(function (k: Tree): number { return k.v; }), innerR);
b.kids = n.kids.map(function (k: Tree, i: number): Box { return layNode(k, lay.rects[i], depth + 1, flat); });
return b;
}
function leaves(b: Box, out: Box[]): Box[] {
if (b.kids.length === 0) { out.push(b); } else { for (const k of b.kids) { leaves(k, out); } }
return out;
}
function shownCount(b: Box): number {
let c = 0;
for (const l of leaves(b, [])) { if (Math.min(l.r.w, l.r.h) >= MIN_SHOWN) { c += 1; } }
return c;
}
function build(): void {
treeRoot = makeTree(5071 + (lap % 2) * 97);
seen = [0, 0, 0, 0];
boxL = layNode(treeRoot, PANEL_L, 0, false);
seen = [0, 0, 0, 0];
boxR = layNode(treeRoot, PANEL_R, 0, true);
pinned = null;
}
function update(dt: number): void { if (dt < 0) { return; } }
function fadeAll(): number { return 1 - clamp((sceneT - 8.6) / 1.0, 0, 1); }
function drawBox(b: Box, group: number): void {
const p = clamp((sceneT - b.at) / 0.4, 0, 1);
if (b.depth === 0) { for (const k of b.kids) { drawBox(k, group); } return; }
if (p <= 0) { return; }
const a = fadeAll();
const base = TILES[group % 8];
const col = b.depth === 1 ? mix(base, INK, 0.14) : (b.depth === 2 ? base : mix(base, [255, 255, 255], 0.75));
const pop = 0.7 + 0.3 * backOut(p);
if (Math.min(b.r.w, b.r.h) >= 1.5) { tile(b.r, col, a * p, pop); }
if (b.depth <= 2 && b.r.w > 20 && b.r.h > HEADER_H + 2) {
const nm = NAMES[b.name];
if (b.depth === 1) { box(b.r.x + 1, b.r.y + 1, b.r.w - 2, HEADER_H - 1, 4, mix(base, INK, 0.28), 0.9 * a * p); }
if (estW(nm, 9) + 8 <= b.r.w) { text(nm, b.r.x + 5, b.r.y + 1 + HEADER_H / 2, INK, a * p, 9, 700, 'left'); }
}
for (const k of b.kids) { drawBox(k, group); }
}
// 깊이 d 인 칸의 여백 띠(바깥 사각형에서 안쪽 사각형을 뺀 틀)를 한 번 비춘다
function padLight(b: Box, d: number, k: number): void {
if (b.kids.length === 0) { return; }
if (b.depth === d && Math.min(b.r.w, b.r.h) >= 1.5) {
ctx.beginPath();
ctx.rect(b.r.x, b.r.y, b.r.w, b.r.h);
ctx.rect(b.inner.x, b.inner.y, b.inner.w, b.inner.h);
ctx.fillStyle = rgba(ROSE, 0.85 * k);
ctx.fill('evenodd');
}
for (const c of b.kids) { padLight(c, d, k); }
}
function drawPanel(R: Rect, b: Box): void {
bed(R);
b.kids.forEach(function (g: Box, i: number): void { drawBox(g, i); });
for (let d = 0; d < 3; d += 1) {
const age = sceneT - PADLIGHT_T0 - d * PADLIGHT_GAP;
if (age >= 0 && age < PADLIGHT_GAP) { padLight(b, d, Math.sin(age / PADLIGHT_GAP * Math.PI) * fadeAll()); }
}
tag(shownCount(b) + ' / ' + leaves(b, []).length, R.x + R.w - 6, R.y + R.h - 12, R === PANEL_L ? INK : PAPER, R === PANEL_L ? PAPER : INK, 10, 'right');
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('층마다 여백을 줄임', '모든 층에 같은 여백');
drawPanel(PANEL_L, boxL);
drawPanel(PANEL_R, boxR);
if (pinned) { outline(pinned.r.x + 1, pinned.r.y + 1, pinned.r.w - 2, pinned.r.h - 2, 4, 3, ROSE, 1); }
}
function statusText(): string {
return '보이는 칸 {a} 대 {b}'.replace('{a}', String(shownCount(boxL))).replace('{b}', String(shownCount(boxR)));
}
// 그룹과 안쪽 묶음(깊이 1·2)을 차례로 — 키보드로 한 칸씩
function flatBoxes(): Box[] {
const out: Box[] = [];
for (const g of boxL.kids) { out.push(g); for (const s of g.kids) { out.push(s); } }
return out;
}
function hits(): Hit[] {
return flatBoxes().map(function (b: Box): Hit {
return { x: b.r.x, y: b.r.y, w: b.r.w, h: b.r.h, label: '{n} 합계 {v} · 깊이 {d}'.replace('{n}', NAMES[b.name]).replace('{v}', String(b.v)).replace('{d}', String(b.depth)) };
});
}
function onAct(i: number): void { const b = flatBoxes()[i]; pinned = pinned === b ? null : b; }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'네 그룹이 묶음 셋으로, 묶음이 잎 셋 또는 넷으로 나뉘는 중첩 칸이 층별로 나타난다. 왼쪽은 머리글 띠를 떼고 층마다 여백을 줄여 안쪽 칸이 남고, 오른쪽은 모든 층에 같은 여백을 줘 깊은 칸이 가늘어지거나 사라진다. 깊이마다 여백 띠가 장미색으로 차례로 비치고, 구석 숫자는 보이는 잎 수와 전체 잎 수다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 · グループの見出しと内側の余白で層を分ける入れ子のセル — 計測 累積面積 39.463% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function padAt(depth, flat) { return flat ? PAD : PAD * Math.pow(PAD_DECAY, depth); }
// ノード n を四角形 R に置く。見出し(深さ1・2)を切り分け、内側の余白だけ縮めた四角形で子を配置し直す
function layNode(n, R, depth, flat) {
const b = { r: R, inner: R, depth: depth, name: n.name, v: n.v, at: 0, kids: [] };
seen[depth] += 1;
b.at = depth === 0 ? 0 : (depth === 1 ? 0.4 + (seen[1] - 1) * 0.15 : (depth === 2 ? 1.5 + (seen[2] - 1) * 0.08 : 2.8 + (seen[3] - 1) * 0.03));
if (n.kids.length === 0) {
return b;
}
const p = padAt(depth, flat);
const head = depth === 0 ? 0 : HEADER_H;
const innerR = { x: R.x + p, y: R.y + head + (depth === 0 ? p : 0), w: Math.max(0, R.w - 2 * p), h: Math.max(0, R.h - head - p - (depth === 0 ? p : 0)) };
b.inner = innerR;
const lay = squarify(n.kids.map(function (k) { return k.v; }), innerR);
b.kids = n.kids.map(function (k, i) { return layNode(k, lay.rects[i], depth + 1, flat); });
return b;
}
06セルの大きさを測って文字を縮めたり隠したりするラベル合わせ
小さなセルが多いチャートでは、名前がセルより長くて隣へあふれます。fitPx が estW で文字の幅を見積もり、15pxから2pxずつ下げてセルに収まる最大のサイズを探し、8px未満なら0を返して名前を隠しますが、右は12px固定なのであふれが隣の上にそのまま見えます。日本語の名前で14セルを数えると、盤の幅が1.0倍で隠れる4・あふれる5、0.45倍で6・8、0.25倍で9・12です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="14個のセルの盤の幅が縮んだり広がったりします。左はセルの大きさを測って文字サイズを一段ずつ下げ、それでも入らなければ名前を隠して数字だけを残し、右は測らないので文字が隣のセルにはみ出します。隅の数字は隠したラベルの数とはみ出したラベルの数です"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 06 — 칸 안쪽 크기를 재서 라벨을 맞춘다. 들어가면 그대로, 모자라면 글자 크기를 한 단계(FONT_STEP)씩 낮추고, 최소(HIDE_MIN)에서도 넘치면 이름을 감추고 숫자만 남긴다(왼쪽). 오른쪽은 크기를 재지 않는다
const CELL_N = 14;
const LAP_S = 9.8;
const START_S = 0.4;
const REST_S = 3.2;
const FIXED_PX = 12;
let vals = [];
let sqk = 1;
let sqTgt = 1;
let rectsL = [];
let rectsR = [];
let hiddenL = 0;
let overR = 0;
function relayout() {
const wL = { x: PANEL_L.x, y: PANEL_L.y, w: PANEL_L.w * sqk, h: PANEL_L.h };
const wR = { x: PANEL_R.x, y: PANEL_R.y, w: PANEL_R.w * sqk, h: PANEL_R.h };
rectsL = squarify(vals, wL).rects;
rectsR = squarify(vals, wR).rects;
}
function build() {
vals = BASE.slice(0, CELL_N);
sqk = 1;
sqTgt = 1;
relayout();
// 판 폭을 줄였다 늘렸다 두 번 — 칸이 가늘어질수록 라벨이 줄어들다 사라진다
[[0.5, 0.25], [4.7, 1], [6.5, 0.45], [8.6, 1]].forEach(function (c) {
script.push({ t: c[0], f: function () { sqTgt = c[1]; } });
});
}
function update(dt) {
if (dt <= 0) {
return;
}
sqk += (sqTgt - sqk) * (1 - Math.exp(-dt * 2.6));
relayout();
}
function drawLeft() {
bed(PANEL_L);
hiddenL = 0;
for (let i = 0; i < CELL_N; i += 1) {
const r = rectsL[i];
tile(r, TILES[i % 8], 1, 1);
const px = fitPx(NAMES[i], r);
if (px > 0) {
text(NAMES[i], r.x + r.w / 2, r.y + r.h / 2 - (r.h >= px * 2.6 ? px * 0.55 : 0), INK, 1, px, 700, 'center');
if (r.h >= px * 2.6 && estW(String(vals[i]), px - 3) <= r.w - 6) {
text(String(vals[i]), r.x + r.w / 2, r.y + r.h / 2 + px * 0.75, INK2, 1, px - 3, 600, 'center');
}
}
else {
hiddenL += 1;
// 이름을 감춘 칸은 숫자만 — 숫자도 안 들어가면 비워 둔다
if (estW(String(vals[i]), HIDE_MIN) <= r.w - 4 && r.h >= 12) {
text(String(vals[i]), r.x + r.w / 2, r.y + r.h / 2, INK2, 1, HIDE_MIN, 700, 'center');
}
}
}
}
function drawRight() {
bed(PANEL_R);
overR = 0;
for (let i = 0; i < CELL_N; i += 1) {
tile(rectsR[i], TILES[i % 8], 1, 1);
}
// 글자는 모든 칸을 칠한 뒤에 — 넘친 글자가 이웃 칸 위에 얹힌다
for (let i = 0; i < CELL_N; i += 1) {
const r = rectsR[i];
if (estW(NAMES[i], FIXED_PX) > r.w - 6 || FIXED_PX * 1.3 > r.h - 4) {
overR += 1;
}
text(NAMES[i], r.x + r.w / 2, r.y + r.h / 2, INK, 1, FIXED_PX, 700, 'center');
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('測って合わせる', '測らない');
drawLeft();
drawRight();
tag(String(hiddenL) + ' / ' + CELL_N, PANEL_L.x + PANEL_L.w - 6, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'right');
tag(String(overR) + ' / ' + CELL_N, PANEL_R.x + PANEL_R.w - 6, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'right');
}
function statusText() {
return 'ラベル非表示 {a}・はみ出し {b}'.replace('{a}', String(hiddenL)).replace('{b}', String(overR));
}
function hits() {
return rectsL.map(function (r, i) {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 値 {v} ・盤の幅 {w}%'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{w}', String(Math.round(sqk * 100))) };
});
}
// 고른 칸 아무 데나 — 판 폭을 좁혔다 넓힌다
function onAct(_i) { sqTgt = sqTgt > 0.8 ? 0.25 : 1; }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// LabelFitMeasureHide - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./LabelFitMeasureHide.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LabelFitMeasureHide() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 06 — 칸 안쪽 크기를 재서 라벨을 맞춘다. 들어가면 그대로, 모자라면 글자 크기를 한 단계(FONT_STEP)씩 낮추고, 최소(HIDE_MIN)에서도 넘치면 이름을 감추고 숫자만 남긴다(왼쪽). 오른쪽은 크기를 재지 않는다
const CELL_N = 14;
const LAP_S = 9.8;
const START_S = 0.4;
const REST_S = 3.2;
const FIXED_PX = 12;
let vals: number[] = [];
let sqk = 1;
let sqTgt = 1;
let rectsL: Rect[] = [];
let rectsR: Rect[] = [];
let hiddenL = 0;
let overR = 0;
function relayout(): void {
const wL: Rect = { x: PANEL_L.x, y: PANEL_L.y, w: PANEL_L.w * sqk, h: PANEL_L.h };
const wR: Rect = { x: PANEL_R.x, y: PANEL_R.y, w: PANEL_R.w * sqk, h: PANEL_R.h };
rectsL = squarify(vals, wL).rects;
rectsR = squarify(vals, wR).rects;
}
function build(): void {
vals = BASE.slice(0, CELL_N);
sqk = 1;
sqTgt = 1;
relayout();
// 판 폭을 줄였다 늘렸다 두 번 — 칸이 가늘어질수록 라벨이 줄어들다 사라진다
[[0.5, 0.25], [4.7, 1], [6.5, 0.45], [8.6, 1]].forEach(function (c: number[]): void {
script.push({ t: c[0], f: function (): void { sqTgt = c[1]; } });
});
}
function update(dt: number): void {
if (dt <= 0) { return; }
sqk += (sqTgt - sqk) * (1 - Math.exp(-dt * 2.6));
relayout();
}
function drawLeft(): void {
bed(PANEL_L);
hiddenL = 0;
for (let i = 0; i < CELL_N; i += 1) {
const r = rectsL[i];
tile(r, TILES[i % 8], 1, 1);
const px = fitPx(NAMES[i], r);
if (px > 0) {
text(NAMES[i], r.x + r.w / 2, r.y + r.h / 2 - (r.h >= px * 2.6 ? px * 0.55 : 0), INK, 1, px, 700, 'center');
if (r.h >= px * 2.6 && estW(String(vals[i]), px - 3) <= r.w - 6) { text(String(vals[i]), r.x + r.w / 2, r.y + r.h / 2 + px * 0.75, INK2, 1, px - 3, 600, 'center'); }
} else {
hiddenL += 1;
// 이름을 감춘 칸은 숫자만 — 숫자도 안 들어가면 비워 둔다
if (estW(String(vals[i]), HIDE_MIN) <= r.w - 4 && r.h >= 12) { text(String(vals[i]), r.x + r.w / 2, r.y + r.h / 2, INK2, 1, HIDE_MIN, 700, 'center'); }
}
}
}
function drawRight(): void {
bed(PANEL_R);
overR = 0;
for (let i = 0; i < CELL_N; i += 1) { tile(rectsR[i], TILES[i % 8], 1, 1); }
// 글자는 모든 칸을 칠한 뒤에 — 넘친 글자가 이웃 칸 위에 얹힌다
for (let i = 0; i < CELL_N; i += 1) {
const r = rectsR[i];
if (estW(NAMES[i], FIXED_PX) > r.w - 6 || FIXED_PX * 1.3 > r.h - 4) { overR += 1; }
text(NAMES[i], r.x + r.w / 2, r.y + r.h / 2, INK, 1, FIXED_PX, 700, 'center');
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('크기를 재서 맞춤', '크기를 재지 않음');
drawLeft();
drawRight();
tag(String(hiddenL) + ' / ' + CELL_N, PANEL_L.x + PANEL_L.w - 6, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'right');
tag(String(overR) + ' / ' + CELL_N, PANEL_R.x + PANEL_R.w - 6, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'right');
}
function statusText(): string {
return '라벨 숨김 {a} · 넘침 {b}'.replace('{a}', String(hiddenL)).replace('{b}', String(overR));
}
function hits(): Hit[] {
return rectsL.map(function (r: Rect, i: number): Hit {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} 값 {v} · 판 폭 {w}%'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{w}', String(Math.round(sqk * 100))) };
});
}
// 고른 칸 아무 데나 — 판 폭을 좁혔다 넓힌다
function onAct(_i: number): void { sqTgt = sqTgt > 0.8 ? 0.25 : 1; }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'열네 칸 판의 폭이 줄었다 늘었다 한다. 왼쪽은 칸 크기를 재서 글자 크기를 한 단계씩 낮추다 안 들어가면 이름을 감추고 숫자만 남기며, 오른쪽은 크기를 재지 않아 글자가 이웃 칸으로 넘친다. 구석 숫자는 감춘 라벨 수와 넘친 라벨 수다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}06 · セルの大きさを測って文字を縮めたり隠したりするラベル合わせ — 計測 累積面積 38.724% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
07値が流れて順位が入れ替わるときに場所を替えるセル
リアルタイムの順位がある占有率や訪問数の盤は、値が少し流れるたびに配置を計算し直すとセルが震えます。relayoutL は orderKey が変わるか、値が前回の配置の値から20%を超えてずれたフレームだけ呼ばれ、順位が入れ替わる二つのセルがばねですれ違って場所を替えます。一ラウンド約9.8秒のあいだの再計算は左が31回、右が527回です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="9個のセルの値が0.5秒ごとに引き直した速度でゆっくり流れ、順位が入れ替わります。左は順位が変わったか値が20%以上ずれたときだけ配置を計算し直してセルがばねで場所を入れ替え、右は毎フレーム計算し直すので値に乗ったノイズでセルが震え続けます。セルごとに順位の番号が付きます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 07 — 고정 수열(LCG)이 만든 작은 속도로 값이 천천히 흐른다. 왼쪽은 큰 순서가 바뀌는 프레임(또는 값이 20% 넘게 벌어진 프레임)에만 배치를 다시 계산하고 칸이 스프링으로 자리를 바꾼다. 오른쪽은 매 프레임 다시 계산한다
const CELL_N = 9;
const LCG_STEP = 0.5;
const RANK_SPRING_K = 90;
const LAP_S = 9.8;
const START_S = 1.6;
const REST_S = 6.0;
const START_VALUES = [20, 19, 18, 16, 15, 14, 12, 11, 10];
let val = [];
let vel = [];
let rnd = seeded(1);
let rndFrame = seeded(2);
let usedVals = [];
let usedKey = '';
let curE = [];
let velE = [];
let tgtL = [];
let tgtR = [];
let calcL = 0;
let calcR = 0;
function orderKey(v) {
const idx = v.map(function (_x, i) { return i; });
idx.sort(function (a, b) { return v[b] - v[a] || a - b; });
return idx.join(',');
}
function rankOf(i) {
let r = 1;
for (let j = 0; j < val.length; j += 1) {
if (val[j] > val[i] || (val[j] === val[i] && j < i)) {
r += 1;
}
}
return r;
}
function relayoutL() {
usedVals = val.slice();
usedKey = orderKey(val);
tgtL = squarify(usedVals, PANEL_L).rects;
calcL += 1;
}
function build() {
val = START_VALUES.slice();
vel = val.map(function () { return 0; });
rnd = seeded(7011 + lap * 13);
rndFrame = seeded(31 + lap);
calcL = 0;
calcR = 0;
tgtL = squarify(val, PANEL_L).rects;
usedVals = val.slice();
usedKey = orderKey(val);
curE = tgtL.map(edgesOf);
velE = tgtL.map(function () { return [0, 0, 0, 0]; });
tgtR = squarify(val, PANEL_R).rects;
// LCG_STEP 초마다 칸마다 새 속도(−2.5 에서 2.5)를 뽑는다
for (let t = 0.2; t < LAP_S - 0.3; t += LCG_STEP) {
script.push({ t: t, f: function () { for (let i = 0; i < CELL_N; i += 1) {
vel[i] = (rnd() - 0.5) * 5;
} } });
}
}
function update(dt) {
if (dt <= 0) {
return;
}
for (let i = 0; i < CELL_N; i += 1) {
val[i] += vel[i] * dt;
if (val[i] < 6) {
val[i] = 6;
vel[i] = Math.abs(vel[i]);
}
if (val[i] > 30) {
val[i] = 30;
vel[i] = -Math.abs(vel[i]);
}
vel[i] *= Math.exp(-dt * 0.5);
}
let apart = false;
for (let i = 0; i < CELL_N; i += 1) {
if (Math.abs(val[i] - usedVals[i]) > usedVals[i] * 0.2) {
apart = true;
}
}
if (apart || orderKey(val) !== usedKey) {
relayoutL();
}
for (let i = 0; i < CELL_N; i += 1) {
springStep(curE[i], velE[i], edgesOf(tgtL[i]), RANK_SPRING_K, 0.6, dt);
}
// 오른쪽은 프레임마다 다시 계산하고 값에 프레임 잡음(±2.5%)이 얹힌다
tgtR = squarify(val.map(function (v) { return v * (1 + (rndFrame() - 0.5) * 0.05); }), PANEL_R).rects;
calcR += 1;
}
function drawPanel(R, rs) {
bed(R);
for (let i = 0; i < CELL_N; i += 1) {
tile(rs[i], TILES[i % 8], 1, 1);
cellText(rs[i], NAMES[i], '#' + rankOf(i), 1);
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('順位が変わる時だけ計算', '毎フレーム計算');
drawPanel(PANEL_L, curE.map(rectOf));
drawPanel(PANEL_R, tgtR);
}
function statusText() {
return '再計算 {a}回 対 {b}回'.replace('{a}', String(calcL)).replace('{b}', String(calcR));
}
function hits() {
return curE.map(rectOf).map(function (r, i) {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} ・順位 {r} ・値 {v}'.replace('{n}', NAMES[i]).replace('{r}', String(rankOf(i))).replace('{v}', String(Math.round(val[i]))) };
});
}
// 고른 칸을 위로 밀어 올린다 — 값이 오르다 다른 칸을 지나면 순위가 바뀐다
function onAct(i) { vel[i] = 6; }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// LiveRankCrossSwap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./LiveRankCrossSwap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LiveRankCrossSwap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 07 — 고정 수열(LCG)이 만든 작은 속도로 값이 천천히 흐른다. 왼쪽은 큰 순서가 바뀌는 프레임(또는 값이 20% 넘게 벌어진 프레임)에만 배치를 다시 계산하고 칸이 스프링으로 자리를 바꾼다. 오른쪽은 매 프레임 다시 계산한다
const CELL_N = 9;
const LCG_STEP = 0.5;
const RANK_SPRING_K = 90;
const LAP_S = 9.8;
const START_S = 1.6;
const REST_S = 6.0;
const START_VALUES: number[] = [20, 19, 18, 16, 15, 14, 12, 11, 10];
let val: number[] = [];
let vel: number[] = [];
let rnd: () => number = seeded(1);
let rndFrame: () => number = seeded(2);
let usedVals: number[] = [];
let usedKey = '';
let curE: number[][] = [];
let velE: number[][] = [];
let tgtL: Rect[] = [];
let tgtR: Rect[] = [];
let calcL = 0;
let calcR = 0;
function orderKey(v: number[]): string {
const idx = v.map(function (_x: number, i: number): number { return i; });
idx.sort(function (a: number, b: number): number { return v[b] - v[a] || a - b; });
return idx.join(',');
}
function rankOf(i: number): number {
let r = 1;
for (let j = 0; j < val.length; j += 1) { if (val[j] > val[i] || (val[j] === val[i] && j < i)) { r += 1; } }
return r;
}
function relayoutL(): void {
usedVals = val.slice();
usedKey = orderKey(val);
tgtL = squarify(usedVals, PANEL_L).rects;
calcL += 1;
}
function build(): void {
val = START_VALUES.slice();
vel = val.map(function (): number { return 0; });
rnd = seeded(7011 + lap * 13);
rndFrame = seeded(31 + lap);
calcL = 0;
calcR = 0;
tgtL = squarify(val, PANEL_L).rects;
usedVals = val.slice();
usedKey = orderKey(val);
curE = tgtL.map(edgesOf);
velE = tgtL.map(function (): number[] { return [0, 0, 0, 0]; });
tgtR = squarify(val, PANEL_R).rects;
// LCG_STEP 초마다 칸마다 새 속도(−2.5 에서 2.5)를 뽑는다
for (let t = 0.2; t < LAP_S - 0.3; t += LCG_STEP) {
script.push({ t: t, f: function (): void { for (let i = 0; i < CELL_N; i += 1) { vel[i] = (rnd() - 0.5) * 5; } } });
}
}
function update(dt: number): void {
if (dt <= 0) { return; }
for (let i = 0; i < CELL_N; i += 1) {
val[i] += vel[i] * dt;
if (val[i] < 6) { val[i] = 6; vel[i] = Math.abs(vel[i]); }
if (val[i] > 30) { val[i] = 30; vel[i] = -Math.abs(vel[i]); }
vel[i] *= Math.exp(-dt * 0.5);
}
let apart = false;
for (let i = 0; i < CELL_N; i += 1) { if (Math.abs(val[i] - usedVals[i]) > usedVals[i] * 0.2) { apart = true; } }
if (apart || orderKey(val) !== usedKey) { relayoutL(); }
for (let i = 0; i < CELL_N; i += 1) { springStep(curE[i], velE[i], edgesOf(tgtL[i]), RANK_SPRING_K, 0.6, dt); }
// 오른쪽은 프레임마다 다시 계산하고 값에 프레임 잡음(±2.5%)이 얹힌다
tgtR = squarify(val.map(function (v: number): number { return v * (1 + (rndFrame() - 0.5) * 0.05); }), PANEL_R).rects;
calcR += 1;
}
function drawPanel(R: Rect, rs: Rect[]): void {
bed(R);
for (let i = 0; i < CELL_N; i += 1) {
tile(rs[i], TILES[i % 8], 1, 1);
cellText(rs[i], NAMES[i], '#' + rankOf(i), 1);
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('순위가 바뀔 때만 계산', '매 프레임 계산');
drawPanel(PANEL_L, curE.map(rectOf));
drawPanel(PANEL_R, tgtR);
}
function statusText(): string {
return '다시 계산 {a}번 대 {b}번'.replace('{a}', String(calcL)).replace('{b}', String(calcR));
}
function hits(): Hit[] {
return curE.map(rectOf).map(function (r: Rect, i: number): Hit {
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} · 순위 {r} · 값 {v}'.replace('{n}', NAMES[i]).replace('{r}', String(rankOf(i))).replace('{v}', String(Math.round(val[i]))) };
});
}
// 고른 칸을 위로 밀어 올린다 — 값이 오르다 다른 칸을 지나면 순위가 바뀐다
function onAct(i: number): void { vel[i] = 6; }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'아홉 칸의 값이 0.5초마다 새로 뽑은 속도로 천천히 흘러 순위가 엇갈린다. 왼쪽은 순위가 바뀌거나 값이 20% 넘게 벌어졌을 때만 배치를 다시 계산해 칸이 스프링으로 자리를 바꾸고, 오른쪽은 매 프레임 다시 계산해 값에 얹힌 잡음 때문에 칸이 계속 떨린다. 칸마다 순위 번호가 붙는다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}07 · 値が流れて順位が入れ替わるときに場所を替えるセル — 計測 累積面積 44.235% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
let apart = false;
for (let i = 0; i < CELL_N; i += 1) {
if (Math.abs(val[i] - usedVals[i]) > usedVals[i] * 0.2) {
apart = true;
}
}
if (apart || orderKey(val) !== usedKey) {
relayoutL();
}
08同じデータを四角いセルと円で行き来して比べる変形
チャートの種類で大きさの印象が変わることは、同じデータを四角いセルと円で行き来して見せるとすぐ分かります。左の半径は r = √(面積 × FILL / π) でセルの面積を守り、右は直径を値に比例させ、pushApart が重なった円を80回押し離します。円の面積の合計は盤の60.0%対42.4%で、最大と最小の面積の比は左が約6倍、右が約32倍です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="12個のセルが四角から円へ形を変えて戻ります。左の円は面積を保つ半径で、右の円は直径を値に比例させるので大きな値がずっと大きく見え、円どうしが重なると押し合って離れます。隅の数字は円の面積の合計が盤に占める割合です"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 08 — 같은 데이터를 사각 칸과 원으로 오간다. 왼쪽은 넓이를 지키는 반지름 r = √(넓이 × FILL / π), 오른쪽은 지름을 값에 비례시킨다. 원끼리 겹치면 밀어낸다
const CELL_N = 12;
const FILL = 0.6;
const MORPH_S = 1.6;
const PUSH_ROUNDS = 80;
const LAP_S = 9.6;
const START_S = 0.5;
const REST_S = 2.6;
let vals = [];
let rectsL = [];
let rectsR = [];
let ballsL = [];
let ballsR = [];
let mp = 0;
let mTgt = 0;
// 사각형 중심에서 시작해 겹친 원을 서로 밀어내고 판 안으로 되돌리는 일을 PUSH_ROUNDS 번 되풀이
function pushApart(rs, radii, R) {
const bs = rs.map(function (r, i) { return { x: r.x + r.w / 2, y: r.y + r.h / 2, r: radii[i] }; });
for (let round = 0; round < PUSH_ROUNDS; round += 1) {
for (let i = 0; i < bs.length; i += 1) {
for (let j = i + 1; j < bs.length; j += 1) {
const dx = bs[j].x - bs[i].x;
const dy = bs[j].y - bs[i].y;
const d = Math.max(Math.hypot(dx, dy), 1e-6);
const over = bs[i].r + bs[j].r + 1.5 - d;
if (over > 0) {
const ux = dx / d;
const uy = dy / d;
// 작은 원이 더 많이 밀려난다
const wi = bs[j].r / (bs[i].r + bs[j].r);
bs[i].x -= ux * over * wi;
bs[i].y -= uy * over * wi;
bs[j].x += ux * over * (1 - wi);
bs[j].y += uy * over * (1 - wi);
}
}
}
for (const b of bs) {
b.x = clamp(b.x, R.x + b.r, R.x + R.w - b.r);
b.y = clamp(b.y, R.y + b.r, R.y + R.h - b.r);
}
}
return bs;
}
function build() {
vals = BASE.slice(0, CELL_N);
rectsL = squarify(vals, PANEL_L).rects;
rectsR = squarify(vals, PANEL_R).rects;
const radL = rectsL.map(function (r) { return Math.sqrt(r.w * r.h * FILL / Math.PI); });
// 오른쪽은 가장 큰 칸 지름을 왼쪽 가장 큰 원보다 10% 키우고 나머지를 값에 비례
const rBig = radL[0] * 1.1;
const radR = vals.map(function (v) { return rBig * v / vals[0]; });
ballsL = pushApart(rectsL, radL, PANEL_L);
ballsR = pushApart(rectsR, radR, PANEL_R);
mp = 0;
mTgt = 0;
[[0.5, 1], [4.3, 0], [5.9, 1], [8.3, 0]].forEach(function (c) {
script.push({ t: c[0], f: function () { mTgt = c[1]; } });
});
}
function update(dt) {
if (dt <= 0) {
return;
}
// 1.6초에 걸쳐 일정한 속도로 오가고 곡선은 그릴 때 입힌다
mp = mTgt > mp ? Math.min(mTgt, mp + dt / MORPH_S) : Math.max(mTgt, mp - dt / MORPH_S);
}
// 칸 하나 — 사각형과 원 사이를 p 로 오간다
function drawShape(r, b, i, p) {
const e = ease(p);
const cx = lerp(r.x + r.w / 2, b.x, e);
const cy = lerp(r.y + r.h / 2, b.y, e);
const w = lerp(r.w - 2, b.r * 2, e);
const h = lerp(r.h - 2, b.r * 2, e);
const col = TILES[i % 8];
box(cx - w / 2, cy - h / 2, w, h, lerp(5, Math.min(w, h) / 2, e), col, 1);
if (h > 12 && w > 8) {
box(cx - w / 2 + w * 0.2, cy - h / 2 + 1.5 + h * 0.05, w * 0.6, 2, 1, [255, 255, 255], 0.5);
}
cellText({ x: cx - w / 2, y: cy - h / 2, w: w, h: h }, NAMES[i], String(vals[i]), 1);
}
function areaShare(bs, R) {
let s = 0;
for (const b of bs) {
s += Math.PI * b.r * b.r;
}
return s / (R.w * R.h);
}
function draw() {
skyFill(BG_TOP, BG_BOT);
panelTags('面積を保つ円', '直径を値に比例');
bed(PANEL_L);
bed(PANEL_R);
for (let i = CELL_N - 1; i >= 0; i -= 1) {
drawShape(rectsL[i], ballsL[i], i, mp);
drawShape(rectsR[i], ballsR[i], i, mp);
}
tag(Math.round(areaShare(ballsL, PANEL_L) * 100) + '%', PANEL_L.x + PANEL_L.w - 6, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'right');
tag(Math.round(areaShare(ballsR, PANEL_R) * 100) + '%', PANEL_R.x + PANEL_R.w - 6, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'right');
}
function statusText() {
const ratioL = (ballsL[0].r * ballsL[0].r) / (ballsL[CELL_N - 1].r * ballsL[CELL_N - 1].r);
const ratioR = (ballsR[0].r * ballsR[0].r) / (ballsR[CELL_N - 1].r * ballsR[CELL_N - 1].r);
return '最大対最小の面積 {a}倍 対 {b}倍'.replace('{a}', String(Math.round(ratioL))).replace('{b}', String(Math.round(ratioR)));
}
function hits() {
return rectsL.map(function (r, i) {
const b = ballsL[i];
const e = ease(mp);
const w = lerp(r.w, b.r * 2, e);
const h = lerp(r.h, b.r * 2, e);
const cx = lerp(r.x + r.w / 2, b.x, e);
const cy = lerp(r.y + r.h / 2, b.y, e);
return { x: cx - w / 2, y: cy - h / 2, w: w, h: h, label: '{n} 値 {v} ・全体の {p}%'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{p}', pct(vals[i], sumOf(vals))) };
});
}
// 고른 칸을 누르면 모든 칸이 사각형과 원 사이를 오간다
function onAct(_i) { mTgt = mTgt > 0.5 ? 0 : 1; }
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// CirclePackMorphCompare - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./CirclePackMorphCompare.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CirclePackMorphCompare() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 08 — 같은 데이터를 사각 칸과 원으로 오간다. 왼쪽은 넓이를 지키는 반지름 r = √(넓이 × FILL / π), 오른쪽은 지름을 값에 비례시킨다. 원끼리 겹치면 밀어낸다
const CELL_N = 12;
const FILL = 0.6;
const MORPH_S = 1.6;
const PUSH_ROUNDS = 80;
const LAP_S = 9.6;
const START_S = 0.5;
const REST_S = 2.6;
type Ball = { x: number; y: number; r: number };
let vals: number[] = [];
let rectsL: Rect[] = [];
let rectsR: Rect[] = [];
let ballsL: Ball[] = [];
let ballsR: Ball[] = [];
let mp = 0;
let mTgt = 0;
// 사각형 중심에서 시작해 겹친 원을 서로 밀어내고 판 안으로 되돌리는 일을 PUSH_ROUNDS 번 되풀이
function pushApart(rs: Rect[], radii: number[], R: Rect): Ball[] {
const bs: Ball[] = rs.map(function (r: Rect, i: number): Ball { return { x: r.x + r.w / 2, y: r.y + r.h / 2, r: radii[i] }; });
for (let round = 0; round < PUSH_ROUNDS; round += 1) {
for (let i = 0; i < bs.length; i += 1) {
for (let j = i + 1; j < bs.length; j += 1) {
const dx = bs[j].x - bs[i].x;
const dy = bs[j].y - bs[i].y;
const d = Math.max(Math.hypot(dx, dy), 1e-6);
const over = bs[i].r + bs[j].r + 1.5 - d;
if (over > 0) {
const ux = dx / d;
const uy = dy / d;
// 작은 원이 더 많이 밀려난다
const wi = bs[j].r / (bs[i].r + bs[j].r);
bs[i].x -= ux * over * wi; bs[i].y -= uy * over * wi;
bs[j].x += ux * over * (1 - wi); bs[j].y += uy * over * (1 - wi);
}
}
}
for (const b of bs) {
b.x = clamp(b.x, R.x + b.r, R.x + R.w - b.r);
b.y = clamp(b.y, R.y + b.r, R.y + R.h - b.r);
}
}
return bs;
}
function build(): void {
vals = BASE.slice(0, CELL_N);
rectsL = squarify(vals, PANEL_L).rects;
rectsR = squarify(vals, PANEL_R).rects;
const radL = rectsL.map(function (r: Rect): number { return Math.sqrt(r.w * r.h * FILL / Math.PI); });
// 오른쪽은 가장 큰 칸 지름을 왼쪽 가장 큰 원보다 10% 키우고 나머지를 값에 비례
const rBig = radL[0] * 1.1;
const radR = vals.map(function (v: number): number { return rBig * v / vals[0]; });
ballsL = pushApart(rectsL, radL, PANEL_L);
ballsR = pushApart(rectsR, radR, PANEL_R);
mp = 0;
mTgt = 0;
[[0.5, 1], [4.3, 0], [5.9, 1], [8.3, 0]].forEach(function (c: number[]): void {
script.push({ t: c[0], f: function (): void { mTgt = c[1]; } });
});
}
function update(dt: number): void {
if (dt <= 0) { return; }
// 1.6초에 걸쳐 일정한 속도로 오가고 곡선은 그릴 때 입힌다
mp = mTgt > mp ? Math.min(mTgt, mp + dt / MORPH_S) : Math.max(mTgt, mp - dt / MORPH_S);
}
// 칸 하나 — 사각형과 원 사이를 p 로 오간다
function drawShape(r: Rect, b: Ball, i: number, p: number): void {
const e = ease(p);
const cx = lerp(r.x + r.w / 2, b.x, e);
const cy = lerp(r.y + r.h / 2, b.y, e);
const w = lerp(r.w - 2, b.r * 2, e);
const h = lerp(r.h - 2, b.r * 2, e);
const col = TILES[i % 8];
box(cx - w / 2, cy - h / 2, w, h, lerp(5, Math.min(w, h) / 2, e), col, 1);
if (h > 12 && w > 8) { box(cx - w / 2 + w * 0.2, cy - h / 2 + 1.5 + h * 0.05, w * 0.6, 2, 1, [255, 255, 255], 0.5); }
cellText({ x: cx - w / 2, y: cy - h / 2, w: w, h: h }, NAMES[i], String(vals[i]), 1);
}
function areaShare(bs: Ball[], R: Rect): number {
let s = 0;
for (const b of bs) { s += Math.PI * b.r * b.r; }
return s / (R.w * R.h);
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
panelTags('넓이를 지키는 원', '지름을 값에 비례');
bed(PANEL_L);
bed(PANEL_R);
for (let i = CELL_N - 1; i >= 0; i -= 1) {
drawShape(rectsL[i], ballsL[i], i, mp);
drawShape(rectsR[i], ballsR[i], i, mp);
}
tag(Math.round(areaShare(ballsL, PANEL_L) * 100) + '%', PANEL_L.x + PANEL_L.w - 6, PANEL_L.y + PANEL_L.h - 12, INK, PAPER, 10, 'right');
tag(Math.round(areaShare(ballsR, PANEL_R) * 100) + '%', PANEL_R.x + PANEL_R.w - 6, PANEL_R.y + PANEL_R.h - 12, PAPER, INK, 10, 'right');
}
function statusText(): string {
const ratioL = (ballsL[0].r * ballsL[0].r) / (ballsL[CELL_N - 1].r * ballsL[CELL_N - 1].r);
const ratioR = (ballsR[0].r * ballsR[0].r) / (ballsR[CELL_N - 1].r * ballsR[CELL_N - 1].r);
return '최대 대 최소 넓이 {a}배 대 {b}배'.replace('{a}', String(Math.round(ratioL))).replace('{b}', String(Math.round(ratioR)));
}
function hits(): Hit[] {
return rectsL.map(function (r: Rect, i: number): Hit {
const b = ballsL[i];
const e = ease(mp);
const w = lerp(r.w, b.r * 2, e);
const h = lerp(r.h, b.r * 2, e);
const cx = lerp(r.x + r.w / 2, b.x, e);
const cy = lerp(r.y + r.h / 2, b.y, e);
return { x: cx - w / 2, y: cy - h / 2, w: w, h: h, label: '{n} 값 {v} · 전체의 {p}%'.replace('{n}', NAMES[i]).replace('{v}', String(vals[i])).replace('{p}', pct(vals[i], sumOf(vals))) };
});
}
// 고른 칸을 누르면 모든 칸이 사각형과 원 사이를 오간다
function onAct(_i: number): void { mTgt = mTgt > 0.5 ? 0 : 1; }
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'열두 칸이 사각형에서 원으로 모양을 바꿨다 돌아온다. 왼쪽 원은 넓이를 지키는 반지름이고 오른쪽 원은 지름이 값에 비례해 큰 값이 훨씬 커 보이며, 원끼리 겹치면 서로 밀어낸다. 구석 숫자는 원 넓이의 합이 판에서 차지하는 비율이다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}08 · 同じデータを四角いセルと円で行き来して比べる変形 — 計測 累積面積 26.478% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
rectsL = squarify(vals, PANEL_L).rects;
rectsR = squarify(vals, PANEL_R).rects;
const radL = rectsL.map(function (r) { return Math.sqrt(r.w * r.h * FILL / Math.PI); });
// 右は最大セルの直径を左の最大の円より10%大きくし、残りを値に比例させる
const rBig = radL[0] * 1.1;
const radR = vals.map(function (v) { return rBig * v / vals[0]; });
ballsL = pushApart(rectsL, radL, PANEL_L);
ballsR = pushApart(rectsR, radR, PANEL_R);
09フォルダごとの容量で埋まるディスク使用量のヒーロー
ストレージ・予算・トラフィックの内訳を一枚で見せる紹介画面は、表がセルに変わる瞬間が第一印象です。フォルダ名と容量の表が squarify を一度通ってセルになり、最大のフォルダ(映像220 GB)へ入って戻り、sizeCheck がセルの面積の合計を盤の面積で割って容量の合計に掛けた値が合計と等しいかを検算します。フォルダ8個の合計は730 GBで、盤には Σ 730 = 730 ✓ が出ます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ツリーマップの舞台 — 矢印キーでセル移動、Enter で選ぶ、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="フォルダ8個の名前と容量の表が一度の配置計算でセルになり、いちばん大きなフォルダが自動で選ばれて中へ入ります。中ではセルの上を順に輪郭が移り、名前と容量が文章に変わり、左下の検算バッジが容量の合計とセルの面積の合計が等しいことを示します。そのあと外へ出ます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ツリーマップデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x) {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const BED = [226, 220, 242];
const INK = [40, 36, 54];
const INK2 = [66, 60, 90];
const PAPER = [250, 246, 240];
const NEUTRAL = [234, 232, 240];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const TILES = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function inset(r, p) {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r) {
if (r.w <= 1e-6 || r.h <= 1e-6) {
return 99;
}
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas, side) {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) {
s += a;
if (a > mx) {
mx = a;
}
if (a < mn) {
mn = a;
}
}
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
function squarify(vals, R) {
const n = vals.length;
const idx = [];
let total = 0;
for (let i = 0; i < n; i += 1) {
idx.push(i);
total += Math.max(vals[i], 1e-6);
}
idx.sort(function (a, b) { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects = [];
const rowOf = [];
for (let i = 0; i < n; i += 1) {
rects.push({ x: R.x, y: R.y, w: 0, h: 0 });
rowOf.push(0);
}
const rows = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) {
s += a;
}
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id, j) {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
}
else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id, j) {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 一方向スライス — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals, R) {
let total = 0;
for (const v of vals) {
total += Math.max(v, 1e-6);
}
let x = R.x;
return vals.map(function (v) {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs) {
let m = 1;
for (const r of rs) {
const a = aspectOf(r);
if (a > m) {
m = a;
}
}
return m;
}
function meanAspect(rs) {
let s = 0;
for (const r of rs) {
s += aspectOf(r);
}
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur, vel, tgt, k, zeta, dt) {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r) { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e) { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES = [
'動画', '写真', '音楽', '書類', 'ゲーム', 'アプリ', 'バックアップ', 'メール', 'キャッシュ', 'ログ', 'ツール', '設定',
'フォント', '本', '地図', '翻訳', 'スケッチ', '録音', 'テンプレ', '圧縮', '一時', '共有', '保管', 'その他',
];
// 큰 순서로 놓인 값 24개
const BASE = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
function leafOf(name, v) { return { name: name, v: v, kids: [] }; }
function groupOf(name, kids) {
let s = 0;
for (const k of kids) {
s += k.v;
}
return { name: name, v: s, kids: kids };
}
function sumOf(vals) { let s = 0; for (const v of vals) {
s += v;
} return s; }
function pct(v, total) { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s, px) { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s, r) {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
return px;
}
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 フォント 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R) {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a, b) {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r, c, a, pop) {
if (a <= 0 || pop <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) {
box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a);
}
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r, name, sub, a) {
const px = fitPx(name, r);
if (px === 0 || a <= 0) {
return;
}
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) {
text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center');
}
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
function takeOver() {
if (manual) {
return;
}
manual = true;
onTake();
wake();
if (!curOn) {
curOn = true;
ki = 0;
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor() {
if (!curOn) {
return;
}
const hs = hits();
if (hs.length === 0) {
return;
}
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel() {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow() {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest() {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill() {
for (let k = 0; k < 90; k += 1) {
sceneT += 1 / 30;
update(1 / 30);
}
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
update(SETTLE_DT);
}
showNow();
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) {
sceneT += SETTLE_DT;
update(SETTLE_DT);
}
showNow();
}
function moveCur(d) {
const n = hits().length;
if (n === 0) {
return;
}
ki = (ki + d + n) % n;
}
function actCur(i) {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) {
settleStill();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
}
else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e) {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) {
at = i;
}
}
if (at < 0) {
return;
}
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// 09 — 폴더 이름·용량 표가 한 번의 배치 계산으로 칸이 되고, 가장 큰 폴더를 자동으로 골라 하위 칸으로 들어갔다 나온다. 칸 위를 지나는 동안 이름과 용량이 문장으로 읽히고 합계는 칸 넓이의 합과 매번 맞춰 본다
const ZOOM_T = 1.0;
const MORPH_T0 = 0.5;
const MORPH_GAP = 0.04;
const LAP_S = 12.4;
const START_S = 3.05;
const REST_S = 6.0;
const R9 = PANEL_ONE;
// 폴더 여덟 — 이름 번호, 용량(GB), 안쪽 폴더 비율
const DISK_GROUPS = [
[0, 220, [0.5, 0.25, 0.15, 0.1]], [1, 150, [0.45, 0.3, 0.15, 0.1]], [2, 104, [0.4, 0.3, 0.2, 0.1]],
[5, 84, [0.5, 0.3, 0.2]], [4, 66, [0.6, 0.25, 0.15]], [8, 48, [0.5, 0.5]], [6, 36, [0.4, 0.35, 0.25]], [3, 22, [0.7, 0.3]],
];
let disk = [];
let diskTotal = 0;
let tgt = [];
let zg = -1;
let zT0 = -9;
let zDir = 1;
let hov = -1;
function buildDisk() {
return DISK_GROUPS.map(function (g, gi) {
let left = g[1];
const kids = g[2].map(function (f, k) {
const v = k === g[2].length - 1 ? left : Math.round(g[1] * f);
left -= v;
return leafOf((gi * 3 + k + 8) % 24, v);
});
return groupOf(g[0], kids);
});
}
function build() {
disk = buildDisk();
diskTotal = sumOf(disk.map(function (g) { return g.v; }));
tgt = squarify(disk.map(function (g) { return g.v; }), R9).rects;
zg = -1;
zT0 = -9;
zDir = 1;
hov = -1;
script.push({ t: 3.0, f: function () { enter(0); } });
[4.7, 5.6, 6.5, 7.4].forEach(function (t, k) {
script.push({ t: t, f: function () { if (zg >= 0) {
hov = k % disk[zg].kids.length;
} } });
});
script.push({ t: 8.4, f: function () { leave(); } });
}
function enter(g) { zg = g; zT0 = sceneT; zDir = 1; hov = -1; }
function leave() { if (zg >= 0) {
zT0 = sceneT;
zDir = -1;
hov = -1;
} }
function zoomP() {
if (zg < 0) {
return 0;
}
const k = clamp((sceneT - zT0) / ZOOM_T, 0, 1);
return ease(zDir === 1 ? k : 1 - k);
}
function update(dt) {
if (dt < 0) {
return;
}
if (zg >= 0 && zDir === -1 && sceneT - zT0 >= ZOOM_T) {
zg = -1;
}
}
// 안쪽 칸은 들어가기 시작하고 KIDS_T 초 뒤부터 하나씩 나타난다 — 확대가 끝나기 전에 시작해 빈 판이 비지 않게
const KIDS_T = 0.6;
function inside() { return zg >= 0 && zDir === 1 && sceneT - zT0 >= KIDS_T; }
// 표 한 줄의 사각형 — 여덟 줄이 판 높이를 똑같이 나눠 가진다
function rowRect(i) { return { x: R9.x, y: R9.y + i * R9.h / disk.length, w: R9.w, h: R9.h / disk.length }; }
function morphK(i) { return ease((sceneT - MORPH_T0 - i * MORPH_GAP) / 0.9); }
function cellRect(i) {
const k = morphK(i);
const a = edgesOf(rowRect(i));
const b = edgesOf(tgt[i]);
return rectOf([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k), lerp(a[3], b[3], k)]);
}
function viewRect(p) {
if (zg < 0) {
return R9;
}
const g = tgt[zg];
return { x: lerp(R9.x, g.x, p), y: lerp(R9.y, g.y, p), w: lerp(R9.w, g.w, p), h: lerp(R9.h, g.h, p) };
}
function toScreen(r, v) {
const sx = R9.w / v.w;
const sy = R9.h / v.h;
return { x: R9.x + (r.x - v.x) * sx, y: R9.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
}
function kidRects() {
return squarify(disk[zg].kids.map(function (k) { return k.v; }), R9).rects;
}
function fadeAll() { return 1 - clamp((sceneT - 11.8) / 0.6, 0, 1); }
function gb(v) { return v + ' GB'; }
function sizeCheck() {
// 칸 넓이(픽셀)의 합을 판 넓이로 나눠 용량 합에 곱한 값이 합계와 같은지 맞춰 본다
let area = 0;
let want = diskTotal;
if (inside()) {
for (const r of kidRects()) {
area += r.w * r.h;
}
want = disk[zg].v;
}
else {
for (const r of tgt) {
area += r.w * r.h;
}
}
const got = area / (R9.w * R9.h) * want;
return 'Σ ' + want + ' = ' + Math.round(got) + (Math.abs(got - want) < 0.5 ? ' ✓' : ' ✗');
}
function draw() {
skyFill(BG_TOP, BG_BOT);
const a = fadeAll();
bed(R9);
ctx.save();
ctx.beginPath();
ctx.rect(R9.x, R9.y, R9.w, R9.h);
ctx.clip();
const p = zoomP();
const v = viewRect(p);
// 아직 표 줄인 칸이 먼저 깔리고 칸이 된 것이 위에 얹힌다
const order = disk.map(function (_g, i) { return i; }).sort(function (x, y) { return morphK(x) - morphK(y) || x - y; });
for (const i of order) {
const k = morphK(i);
const r = zg >= 0 ? toScreen(cellRect(i), v) : cellRect(i);
// 들어간 그룹의 바탕 칸은 안쪽 칸이 나타나는 0.6초 동안 서서히 걷힌다
const selA = i === zg && zDir === 1 ? a * (1 - clamp((sceneT - zT0 - KIDS_T) / 0.6, 0, 1)) : a;
tile(r, TILES[i % 8], selA, 1);
// 표 글자는 칸이 되는 동안 사라지고 칸 글자가 켜진다
if (k < 1) {
text(NAMES[disk[i].name], r.x + 12, r.y + r.h / 2, INK, (1 - k) * a, 12, 700, 'left');
text(gb(disk[i].v), r.x + r.w - 12, r.y + r.h / 2, INK2, (1 - k) * a, 11, 600, 'right');
}
if (!(i === zg && p >= 1)) {
cellText(r, NAMES[disk[i].name], gb(disk[i].v), k * a * (i === zg ? 1 - p : 1));
}
}
if (inside()) {
const ks = kidRects();
for (let i = 0; i < ks.length; i += 1) {
const kk = clamp((sceneT - zT0 - KIDS_T - i * 0.1) / 0.35, 0, 1);
tile(ks[i], TILES[(zg + 1 + i) % 8], a, 0.5 + 0.5 * backOut(kk));
cellText(ks[i], NAMES[disk[zg].kids[i].name], gb(disk[zg].kids[i].v), kk * a);
if (i === hov) {
outline(ks[i].x + 1, ks[i].y + 1, ks[i].w - 2, ks[i].h - 2, 5, 3, INK, a);
}
}
}
ctx.restore();
if (sceneT > 2.0) {
tag(sizeCheck(), R9.x + 8, R9.y + R9.h - 12, INK, PAPER, 10, 'left');
}
if (zg >= 0 && p > 0.5) {
tag('全体 ›' + ' ' + NAMES[disk[zg].name], R9.x + R9.w - 8, R9.y + R9.h - 12, PAPER, INK, 10, 'right');
}
}
function statusText() {
if (inside() && hov >= 0) {
const k = disk[zg].kids[hov];
return '{n} {v} GB ・全体の {p}%'.replace('{n}', NAMES[k.name]).replace('{v}', String(k.v)).replace('{p}', pct(k.v, diskTotal));
}
return 'フォルダ {n}個・合計 {t} GB'.replace('{n}', String(disk.length)).replace('{t}', String(diskTotal));
}
function hits() {
if (inside()) {
return kidRects().map(function (r, i) {
const k = disk[zg].kids[i];
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} {v} GB ・全体の {p}%'.replace('{n}', NAMES[k.name]).replace('{v}', String(k.v)).replace('{p}', pct(k.v, diskTotal)) };
});
}
const v = viewRect(zoomP());
return disk.map(function (g, i) {
const r = zg >= 0 ? toScreen(cellRect(i), v) : cellRect(i);
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} {v} GB ・全体の {p}% ・Enter で入る'.replace('{n}', NAMES[g.name]).replace('{v}', String(g.v)).replace('{p}', pct(g.v, diskTotal)) };
});
}
// 폴더 칸을 고르면 들어가고, 안에서는 아무 칸이나 고르면 나온다
function onAct(i) {
if (zg < 0 || zDir === -1) {
enter(clamp(i, 0, disk.length - 1));
}
else {
leave();
}
}
function onTake() { return; }
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HeroDiskUsageFolderScene - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HeroDiskUsageFolderScene.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroDiskUsageFolderScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 끝에서 한 번 넘쳤다 돌아오는 곡선 — 칸이 자리에 튀어 나올 때
function backOut(x: number): number {
const c = clamp(x, 0, 1) - 1;
return 1 + 2.70158 * c * c * c + 1.70158 * c * c;
}
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 칸은 연한 면, 글자는 늘 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const BED: RGB = [226, 220, 242];
const INK: RGB = [40, 36, 54];
const INK2: RGB = [66, 60, 90];
const PAPER: RGB = [250, 246, 240];
const NEUTRAL: RGB = [234, 232, 240];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const TILES: RGB[] = [
[200, 190, 244], [168, 222, 200], [250, 228, 150], [246, 184, 200],
[172, 208, 246], [250, 208, 170], [196, 222, 170], [222, 190, 240],
];
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 트리맵 계산 ───────────────────────────────────────────
type Rect = { x: number; y: number; w: number; h: number };
// 키보드·클릭 대상 — 칸 하나의 사각형(캔버스 좌표)과 읽어 줄 문장
type Hit = { x: number; y: number; w: number; h: number; label: string };
function inset(r: Rect, p: number): Rect {
return { x: r.x + p, y: r.y + p, w: Math.max(0, r.w - 2 * p), h: Math.max(0, r.h - 2 * p) };
}
function aspectOf(r: Rect): number {
if (r.w <= 1e-6 || r.h <= 1e-6) { return 99; }
return r.w >= r.h ? r.w / r.h : r.h / r.w;
}
// 한 줄의 최악 가로세로 비 — areas 는 그 줄 칸들의 넓이, side 는 줄을 놓을 짧은 변의 길이
function worstRow(areas: number[], side: number): number {
let s = 0;
let mx = 0;
let mn = Infinity;
for (const a of areas) { s += a; if (a > mx) { mx = a; } if (a < mn) { mn = a; } }
const s2 = s * s;
const d2 = side * side;
return Math.max(d2 * mx / s2, s2 / (d2 * mn));
}
// 줄 단위 배치 — 값을 큰 순서로 세우고, 줄에 칸을 더할 때마다 그 줄의 최악 비를 다시 재서 나빠지기 직전에 줄을 닫는다
type Lay = { rects: Rect[]; rowOf: number[]; rows: number[][] };
function squarify(vals: number[], R: Rect): Lay {
const n = vals.length;
const idx: number[] = [];
let total = 0;
for (let i = 0; i < n; i += 1) { idx.push(i); total += Math.max(vals[i], 1e-6); }
idx.sort(function (a: number, b: number): number { return vals[b] - vals[a] || a - b; });
const k = R.w * R.h / total;
const rects: Rect[] = [];
const rowOf: number[] = [];
for (let i = 0; i < n; i += 1) { rects.push({ x: R.x, y: R.y, w: 0, h: 0 }); rowOf.push(0); }
const rows: number[][] = [];
let rx = R.x;
let ry = R.y;
let rw = R.w;
let rh = R.h;
let at = 0;
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row: number[] = [];
const areas: number[] = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) { break; }
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
let s = 0;
for (const a of areas) { s += a; }
if (rw >= rh) {
const cw = s / Math.max(rh, 1e-6);
let y = ry;
row.forEach(function (id: number, j: number): void {
const h = areas[j] / Math.max(cw, 1e-6);
rects[id] = { x: rx, y: y, w: cw, h: h };
rowOf[id] = rows.length;
y += h;
});
rx += cw;
rw -= cw;
} else {
const ch = s / Math.max(rw, 1e-6);
let x = rx;
row.forEach(function (id: number, j: number): void {
const w = areas[j] / Math.max(ch, 1e-6);
rects[id] = { x: x, y: ry, w: w, h: ch };
rowOf[id] = rows.length;
x += w;
});
ry += ch;
rh -= ch;
}
rows.push(row);
}
return { rects: rects, rowOf: rowOf, rows: rows };
}
// 한 방향 슬라이스 — 줄을 나누지 않고 입력 순서대로 세로 띠로만 자른다
function slice(vals: number[], R: Rect): Rect[] {
let total = 0;
for (const v of vals) { total += Math.max(v, 1e-6); }
let x = R.x;
return vals.map(function (v: number): Rect {
const w = R.w * Math.max(v, 1e-6) / total;
const r = { x: x, y: R.y, w: w, h: R.h };
x += w;
return r;
});
}
function worstAspect(rs: Rect[]): number {
let m = 1;
for (const r of rs) { const a = aspectOf(r); if (a > m) { m = a; } }
return m;
}
function meanAspect(rs: Rect[]): number {
let s = 0;
for (const r of rs) { s += aspectOf(r); }
return rs.length ? s / rs.length : 1;
}
// 스프링 — 네 변(왼·위·오른·아래)을 따로 목표로 끌어당긴다. k 는 스프링 상수, zeta 는 감쇠비
function springStep(cur: number[], vel: number[], tgt: number[], k: number, zeta: number, dt: number): void {
const c = 2 * zeta * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let j = 0; j < cur.length; j += 1) {
vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
cur[j] += vel[j] * h;
}
}
}
function edgesOf(r: Rect): number[] { return [r.x, r.y, r.x + r.w, r.y + r.h]; }
function rectOf(e: number[]): Rect { return { x: e[0], y: e[1], w: e[2] - e[0], h: e[3] - e[1] }; }
// ── 자체 가상 데이터 ──────────────────────────────────────
const NAMES: string[] = [
'영상', '사진', '음악', '문서', '게임', '앱', '백업', '메일', '캐시', '로그', '도구', '설정',
'글꼴', '책', '지도', '번역', '스케치', '녹음', '템플릿', '압축', '임시', '공유', '보관', '기타',
];
// 큰 순서로 놓인 값 24개
const BASE: number[] = [34, 27, 22, 19, 16, 14, 12, 10, 9, 8, 7, 6, 5.5, 5, 4.4, 4, 3.4, 3, 2.6, 2.3, 2, 1.7, 1.5, 1.3];
// 계층 한 칸 — name 은 NAMES 번호, 잎이면 v 가 값이고 가지면 kids 값의 합
type Tree = { name: number; v: number; kids: Tree[] };
function leafOf(name: number, v: number): Tree { return { name: name, v: v, kids: [] }; }
function groupOf(name: number, kids: Tree[]): Tree {
let s = 0;
for (const k of kids) { s += k.v; }
return { name: name, v: s, kids: kids };
}
function sumOf(vals: number[]): number { let s = 0; for (const v of vals) { s += v; } return s; }
function pct(v: number, total: number): string { return String(Math.round(v / total * 1000) / 10); }
// ── 라벨 맞춤 ─────────────────────────────────────────────
// 글자 너비는 문자 수 × 글자 크기 × CHAR_W 로 어림 — 한글 한 자가 글자 크기만큼 차지하니 CHAR_W 는 1
const CHAR_W = 1;
const FONT_MAX = 15;
const FONT_STEP = 2;
const HIDE_MIN = 8;
function estW(s: string, px: number): number { return s.length * px * CHAR_W; }
// 칸 안쪽(양쪽 3px 여백)에 들어가는 가장 큰 글자 크기 — 최소 크기에서도 넘치면 0(감춤)
function fitPx(s: string, r: Rect): number {
for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) { return px; }
}
return 0;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(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 disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 판 자리 — 왼쪽 판·오른쪽 판(대비판)·한 판짜리 히어로. 위쪽은 상태 줄·버튼 자리
const PANEL_L: Rect = { x: 12, y: 78, w: 222, h: 208 };
const PANEL_R: Rect = { x: 246, y: 78, w: 222, h: 208 };
const PANEL_ONE: Rect = { x: 12, y: 60, w: 456, h: 228 };
// 판 바닥 — 칸 사이 틈으로 비치는 연보라
function bed(R: Rect): void {
box(R.x - 3, R.y - 3, R.w + 6, R.h + 6, 10, BED, 1);
}
function panelTags(a: string, b: string): void {
tag(a, PANEL_L.x + PANEL_L.w / 2, 66, INK, PAPER, 10, 'center');
tag(b, PANEL_R.x + PANEL_R.w / 2, 66, PAPER, INK, 10, 'center');
}
// 칸 한 장 — 둥근 면과 윗면 하이라이트. pop 은 자리에 튀어 나오는 배율(1 이면 그대로)
function tile(r: Rect, c: RGB, a: number, pop: number): void {
if (a <= 0 || pop <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const w = Math.max(0, (r.w - 2) * pop);
const h = Math.max(0, (r.h - 2) * pop);
box(cx - w / 2, cy - h / 2, w, h, 5, c, a);
if (h > 12 && w > 8) { box(cx - w / 2 + 2, cy - h / 2 + 1.5, w - 4, 2, 1, [255, 255, 255], 0.5 * a); }
}
// 칸 안 글자 — 맞는 크기가 있으면 이름(과 자리가 되면 아래 줄 부제), 없으면 감춘다
function cellText(r: Rect, name: string, sub: string, a: number): void {
const px = fitPx(name, r);
if (px === 0 || a <= 0) { return; }
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
const two = sub !== '' && r.h >= px * 2.6 && estW(sub, px - 3) <= r.w - 6;
text(name, cx, cy - (two ? px * 0.55 : 0), INK, a, px, 700, 'center');
if (two) { text(sub, cx, cy + px * 0.75, INK2, a, px - 3, 600, 'center'); }
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
// 사람이 직접 하는 중이면 시간표는 멈추고 애니메이션만 흐른다
let manual = false;
// curOn 이면 키보드 칸 고르기가 켜진 것 — ki 가 hits() 안 번호
let curOn = false;
let ki = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
function takeOver(): void {
if (manual) { return; }
manual = true;
onTake();
wake();
if (!curOn) { curOn = true; ki = 0; }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (!manual) {
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 칸이 이미 움직이는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const SETTLE_FRAMES = 8;
const SETTLE_DT = 0.04;
function drawCursor(): void {
if (!curOn) { return; }
const hs = hits();
if (hs.length === 0) { return; }
const h = hs[clamp(ki, 0, hs.length - 1)];
outline(h.x + 1, h.y + 1, h.w - 2, h.h - 2, 5, 3, INK, 1);
outline(h.x - 1, h.y - 1, h.w + 2, h.h + 2, 6, 1.5, PAPER, 0.9);
}
function cursorLabel(): string {
const hs = hits();
return hs.length === 0 ? statusText() : hs[clamp(ki, 0, hs.length - 1)].label;
}
function showNow(): void {
draw();
drawCursor();
pushStatus(curOn ? cursorLabel() : statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 배치가 끝난 정지 장면 한 장
function showRest(): void {
stopLoop();
lap = 0;
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
showNow();
}
// 움직임 줄이기이거나 멈춘 채 입력이 들어오면 트윈 없이 곧바로 끝 배치로 — 시간을 3초 흘려 스프링·애니메이션을 다 끝낸다
function settleStill(): void {
for (let k = 0; k < 90; k += 1) { sceneT += 1 / 30; update(1 / 30); }
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
curOn = false;
doneSaid = false;
lap += 1;
begin();
for (let k = 0; k < SETTLE_FRAMES; k += 1) { update(SETTLE_DT); }
showNow();
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나를 부른다. 더 부를 일이 없거나 직접 하던 중이면 다음 판을 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (manual || evAt >= script.length) {
restart();
return;
}
sceneT = Math.max(sceneT, script[evAt].t);
advance(0);
for (let k = 0; k < SETTLE_FRAMES; k += 1) { sceneT += SETTLE_DT; update(SETTLE_DT); }
showNow();
}
function moveCur(d: number): void {
const n = hits().length;
if (n === 0) { return; }
ki = (ki + d + n) % n;
}
function actCur(i: number): void {
onAct(i);
// 멈춘 채·움직임 줄이기에서도 입력이 화면에 바로 반영되게 한다
if (still || paused) { settleStill(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 칸 이동, Enter·Space 는 칸 고르기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault();
takeOver();
moveCur(-1);
showNow();
} else if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault();
takeOver();
moveCur(1);
showNow();
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
takeOver();
actCur(ki);
showNow();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 누른 자리의 칸을 고른다
pad.addEventListener('click', function (e: MouseEvent): void {
const rect = cv.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width * W;
const py = (e.clientY - rect.top) / rect.height * H;
const hs = hits();
let at = -1;
for (let i = 0; i < hs.length; i += 1) {
if (px >= hs[i].x && px <= hs[i].x + hs[i].w && py >= hs[i].y && py <= hs[i].y + hs[i].h) { at = i; }
}
if (at < 0) { return; }
takeOver();
ki = at;
actCur(at);
showNow();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 배치가 끝난 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// 09 — 폴더 이름·용량 표가 한 번의 배치 계산으로 칸이 되고, 가장 큰 폴더를 자동으로 골라 하위 칸으로 들어갔다 나온다. 칸 위를 지나는 동안 이름과 용량이 문장으로 읽히고 합계는 칸 넓이의 합과 매번 맞춰 본다
const ZOOM_T = 1.0;
const MORPH_T0 = 0.5;
const MORPH_GAP = 0.04;
const LAP_S = 12.4;
const START_S = 3.05;
const REST_S = 6.0;
const R9: Rect = PANEL_ONE;
// 폴더 여덟 — 이름 번호, 용량(GB), 안쪽 폴더 비율
const DISK_GROUPS: Array<[number, number, number[]]> = [
[0, 220, [0.5, 0.25, 0.15, 0.1]], [1, 150, [0.45, 0.3, 0.15, 0.1]], [2, 104, [0.4, 0.3, 0.2, 0.1]],
[5, 84, [0.5, 0.3, 0.2]], [4, 66, [0.6, 0.25, 0.15]], [8, 48, [0.5, 0.5]], [6, 36, [0.4, 0.35, 0.25]], [3, 22, [0.7, 0.3]],
];
let disk: Tree[] = [];
let diskTotal = 0;
let tgt: Rect[] = [];
let zg = -1;
let zT0 = -9;
let zDir = 1;
let hov = -1;
function buildDisk(): Tree[] {
return DISK_GROUPS.map(function (g: [number, number, number[]], gi: number): Tree {
let left = g[1];
const kids = g[2].map(function (f: number, k: number): Tree {
const v = k === g[2].length - 1 ? left : Math.round(g[1] * f);
left -= v;
return leafOf((gi * 3 + k + 8) % 24, v);
});
return groupOf(g[0], kids);
});
}
function build(): void {
disk = buildDisk();
diskTotal = sumOf(disk.map(function (g: Tree): number { return g.v; }));
tgt = squarify(disk.map(function (g: Tree): number { return g.v; }), R9).rects;
zg = -1;
zT0 = -9;
zDir = 1;
hov = -1;
script.push({ t: 3.0, f: function (): void { enter(0); } });
[4.7, 5.6, 6.5, 7.4].forEach(function (t: number, k: number): void {
script.push({ t: t, f: function (): void { if (zg >= 0) { hov = k % disk[zg].kids.length; } } });
});
script.push({ t: 8.4, f: function (): void { leave(); } });
}
function enter(g: number): void { zg = g; zT0 = sceneT; zDir = 1; hov = -1; }
function leave(): void { if (zg >= 0) { zT0 = sceneT; zDir = -1; hov = -1; } }
function zoomP(): number {
if (zg < 0) { return 0; }
const k = clamp((sceneT - zT0) / ZOOM_T, 0, 1);
return ease(zDir === 1 ? k : 1 - k);
}
function update(dt: number): void {
if (dt < 0) { return; }
if (zg >= 0 && zDir === -1 && sceneT - zT0 >= ZOOM_T) { zg = -1; }
}
// 안쪽 칸은 들어가기 시작하고 KIDS_T 초 뒤부터 하나씩 나타난다 — 확대가 끝나기 전에 시작해 빈 판이 비지 않게
const KIDS_T = 0.6;
function inside(): boolean { return zg >= 0 && zDir === 1 && sceneT - zT0 >= KIDS_T; }
// 표 한 줄의 사각형 — 여덟 줄이 판 높이를 똑같이 나눠 가진다
function rowRect(i: number): Rect { return { x: R9.x, y: R9.y + i * R9.h / disk.length, w: R9.w, h: R9.h / disk.length }; }
function morphK(i: number): number { return ease((sceneT - MORPH_T0 - i * MORPH_GAP) / 0.9); }
function cellRect(i: number): Rect {
const k = morphK(i);
const a = edgesOf(rowRect(i));
const b = edgesOf(tgt[i]);
return rectOf([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k), lerp(a[3], b[3], k)]);
}
function viewRect(p: number): Rect {
if (zg < 0) { return R9; }
const g = tgt[zg];
return { x: lerp(R9.x, g.x, p), y: lerp(R9.y, g.y, p), w: lerp(R9.w, g.w, p), h: lerp(R9.h, g.h, p) };
}
function toScreen(r: Rect, v: Rect): Rect {
const sx = R9.w / v.w;
const sy = R9.h / v.h;
return { x: R9.x + (r.x - v.x) * sx, y: R9.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
}
function kidRects(): Rect[] {
return squarify(disk[zg].kids.map(function (k: Tree): number { return k.v; }), R9).rects;
}
function fadeAll(): number { return 1 - clamp((sceneT - 11.8) / 0.6, 0, 1); }
function gb(v: number): string { return v + ' GB'; }
function sizeCheck(): string {
// 칸 넓이(픽셀)의 합을 판 넓이로 나눠 용량 합에 곱한 값이 합계와 같은지 맞춰 본다
let area = 0;
let want = diskTotal;
if (inside()) {
for (const r of kidRects()) { area += r.w * r.h; }
want = disk[zg].v;
} else {
for (const r of tgt) { area += r.w * r.h; }
}
const got = area / (R9.w * R9.h) * want;
return 'Σ ' + want + ' = ' + Math.round(got) + (Math.abs(got - want) < 0.5 ? ' ✓' : ' ✗');
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
const a = fadeAll();
bed(R9);
ctx.save();
ctx.beginPath();
ctx.rect(R9.x, R9.y, R9.w, R9.h);
ctx.clip();
const p = zoomP();
const v = viewRect(p);
// 아직 표 줄인 칸이 먼저 깔리고 칸이 된 것이 위에 얹힌다
const order = disk.map(function (_g: Tree, i: number): number { return i; }).sort(function (x: number, y: number): number { return morphK(x) - morphK(y) || x - y; });
for (const i of order) {
const k = morphK(i);
const r = zg >= 0 ? toScreen(cellRect(i), v) : cellRect(i);
// 들어간 그룹의 바탕 칸은 안쪽 칸이 나타나는 0.6초 동안 서서히 걷힌다
const selA = i === zg && zDir === 1 ? a * (1 - clamp((sceneT - zT0 - KIDS_T) / 0.6, 0, 1)) : a;
tile(r, TILES[i % 8], selA, 1);
// 표 글자는 칸이 되는 동안 사라지고 칸 글자가 켜진다
if (k < 1) {
text(NAMES[disk[i].name], r.x + 12, r.y + r.h / 2, INK, (1 - k) * a, 12, 700, 'left');
text(gb(disk[i].v), r.x + r.w - 12, r.y + r.h / 2, INK2, (1 - k) * a, 11, 600, 'right');
}
if (!(i === zg && p >= 1)) { cellText(r, NAMES[disk[i].name], gb(disk[i].v), k * a * (i === zg ? 1 - p : 1)); }
}
if (inside()) {
const ks = kidRects();
for (let i = 0; i < ks.length; i += 1) {
const kk = clamp((sceneT - zT0 - KIDS_T - i * 0.1) / 0.35, 0, 1);
tile(ks[i], TILES[(zg + 1 + i) % 8], a, 0.5 + 0.5 * backOut(kk));
cellText(ks[i], NAMES[disk[zg].kids[i].name], gb(disk[zg].kids[i].v), kk * a);
if (i === hov) { outline(ks[i].x + 1, ks[i].y + 1, ks[i].w - 2, ks[i].h - 2, 5, 3, INK, a); }
}
}
ctx.restore();
if (sceneT > 2.0) { tag(sizeCheck(), R9.x + 8, R9.y + R9.h - 12, INK, PAPER, 10, 'left'); }
if (zg >= 0 && p > 0.5) { tag('전체 ›' + ' ' + NAMES[disk[zg].name], R9.x + R9.w - 8, R9.y + R9.h - 12, PAPER, INK, 10, 'right'); }
}
function statusText(): string {
if (inside() && hov >= 0) {
const k = disk[zg].kids[hov];
return '{n} {v} GB · 전체의 {p}%'.replace('{n}', NAMES[k.name]).replace('{v}', String(k.v)).replace('{p}', pct(k.v, diskTotal));
}
return '폴더 {n}개 · 합계 {t} GB'.replace('{n}', String(disk.length)).replace('{t}', String(diskTotal));
}
function hits(): Hit[] {
if (inside()) {
return kidRects().map(function (r: Rect, i: number): Hit {
const k = disk[zg].kids[i];
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} {v} GB · 전체의 {p}%'.replace('{n}', NAMES[k.name]).replace('{v}', String(k.v)).replace('{p}', pct(k.v, diskTotal)) };
});
}
const v = viewRect(zoomP());
return disk.map(function (g: Tree, i: number): Hit {
const r = zg >= 0 ? toScreen(cellRect(i), v) : cellRect(i);
return { x: r.x, y: r.y, w: r.w, h: r.h, label: '{n} {v} GB · 전체의 {p}% · Enter 로 들어가기'.replace('{n}', NAMES[g.name]).replace('{v}', String(g.v)).replace('{p}', pct(g.v, diskTotal)) };
});
}
// 폴더 칸을 고르면 들어가고, 안에서는 아무 칸이나 고르면 나온다
function onAct(i: number): void {
if (zg < 0 || zDir === -1) { enter(clamp(i, 0, disk.length - 1)); } else { leave(); }
}
function onTake(): void { return; }
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'트리맵 시연 무대 — 방향키로 칸 이동, Enter 로 고르기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'폴더 여덟 개의 이름·용량 표가 한 번의 배치 계산으로 칸이 되고, 가장 큰 폴더가 자동으로 골라져 안쪽 칸으로 들어간다. 안에서는 칸 위를 차례로 지나며 윤곽이 옮겨 가고 이름과 용량이 문장으로 바뀌며, 왼쪽 아래 검산 표지는 용량 합과 칸 넓이의 합이 같음을 보인다. 이어 밖으로 나온다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'트리맵 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 · フォルダごとの容量で埋まるディスク使用量のヒーロー — 計測 累積面積 54.558% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function sizeCheck() {
// セルの面積(ピクセル)の合計を盤の面積で割って容量の合計に掛けた値が合計と等しいか確かめる
let area = 0;
let want = diskTotal;
if (inside()) {
for (const r of kidRects()) {
area += r.w * r.h;
}
want = disk[zg].v;
}
else {
for (const r of tgt) {
area += r.w * r.h;
}
}
const got = area / (R9.w * R9.h) * want;
return 'Σ ' + want + ' = ' + Math.round(got) + (Math.abs(got - want) < 0.5 ? ' ✓' : ' ✗');
}
ツリーマップをコードで描くと、セルはいくつの値で決まるのでしょうか?
セル一つは値ひとつと四角形ひとつです
セル一つは、値ひとつ・配置関数が返した四角形ひとつ・その四角形の四つの辺に分かれます。辺を別々に持つと、ばねが左・上・右・下をそれぞれ目標へ引いて、セルが伸び縮みしながら動きます。
| 値 | 意味 | 使う関数 |
|---|---|---|
vals |
セルごとの値。面積がこの値に比例します | squarify・slice |
四角形 { x, y, w, h } |
配置関数がセルに与えた位置 | squarify・slice |
edgesOf(r) |
左・上・右・下の座標四つ | springStep |
kids |
セルの中に入っている子セルの一覧 | layNode・kidLayout |
合計が決まった全体にどう使うか
合計が決まった全体を分けて見せるツリーマップ 例としては、ディスク容量・予算の内訳・ポートフォリオの比率があります。09の sizeCheck はセルの面積の合計が容量の合計と等しいかを毎回確かめるので、値の一覧を替えるだけで検算がついてきます。
ダッシュボードのセルに使うなら、02と07の目標の計算を値が変わった瞬間に限る方法がそのまま合います。層のある探索画面なら、03の拡大の変換と05の見出し・余白をつなげれば足ります。
値を大きい順に並べ、行にセルを足しながら比を測り、行を閉じて四角を分け、セルの中で同じ計算をくり返すまで — ツリーマップが一枚計算される順番です
行にセルを足して比が悪くなったら行を閉じます
squarify は idx.sort で値を大きい順に並べ、セルを一つずつ足して worstRow を測り直します。新しい比 wr が今の行の cur より悪くなったら、そのセルは次の行へ回します。
while (at < n) {
const side = Math.max(Math.min(rw, rh), 1e-6);
const row = [];
const areas = [];
let cur = Infinity;
while (at < n) {
const a = Math.max(vals[idx[at]], 1e-6) * k;
const next = areas.slice();
next.push(a);
const wr = worstRow(next, side);
if (areas.length > 0 && wr > cur) {
break;
}
areas.push(a);
row.push(idx[at]);
cur = wr;
at += 1;
}
閉じた行は、残りの四角形が横に長ければ左に縦の行として、そうでなければ上に横の行として置かれ、残りの四角形でも同じことをくり返します。
セルの中で同じ計算をくり返す
05の layNode は子を squarify で置いたあと各子で layNode を呼び直し、03の kidLayout は拡大が終わったあと、グループの内側のセルに同じ関数を使います。だから層がいくつあっても配置の関数はひとつです。
ツリーマップが壊れるのは、スライスだけで切って細くなったセル・どの層も同じ余白で消えた内側のセル・大きさを測らず隣へあふれた文字です
スライスだけで切るとセルが細くなる
slice は入力の順に縦の帯だけで切るので、セル12個の最悪の縦横比が28.73、平均も14.76です。同じ値を squarify で割ると1.58と1.24なので、小さなセルも帯ではなくセルとして残ります。この二つの平均は画面には出ず、コンパイルしたJSをノードで動かして確かめた値です。
どの層にも同じ余白を与えると内側のセルが消える
05の右の盤はどの層にも8pxを与えるので、深いセルは余白を引くと残る幅が0pxに近づきます。短い辺が5px以上で見えるセルは葉41個のうち38個で、いちばん小さい葉の短い辺は0pxです。
大きさを測らないと文字が隣へあふれる
06は盤の幅を0.45倍にすると、14セルのうち6セルが名前を隠し、12px固定では8セルがあふれます。数え方は、左の fitPx が0を返したセルを隠す、右は estW(名前, 12) がセルの幅から6を引いた値を超えるか高さが足りないセルをあふれるとして、日本語の名前でノードに数えさせました。CHAR_W = 1 で文字の幅を一文字1emと見積もるので、ほかの言語の名前では個数が変わることがあります。
目標を毎フレーム計算し直すとセルが震える
07の右の盤は update のたびに値へ±2.5%の雑音を載せて配置を計算し直し、一ラウンドに527回も再計算し、左は順位が変わるか値が20%を超えてずれたときだけの31回です。同じ値が流れても、左のセルは落ち着き、右のセルは震え続けます。
ツリーマップ九場面の完成ソースは下のzipに入っていて、展開に使うパスワードは5jqmrgdvです。
セルの数とばねの強さと余白を変えると形と読みやすさはどれだけ変わるのでしょうか — セルの数・ばね・余白を変えて比べます
| 名前 | 変えた値 | 感じ |
|---|---|---|
| セルをもっと増やして | 01 CELL_N 12 → 24 |
最悪の比は squarify が1.58から2.08へ、slice が28.73から159.06へ広がり、行は7本から12本になります |
| ばねをもっと硬く | 02 SPRING_K 60 → 140 |
0.5px以内に入って留まる時刻は1.45秒から0.93秒に縮みますが、行きすぎは24.0pxから23.5pxとほぼそのままです。減衰比が0.5で固定なので、届く時間だけが縮みます |
| 余白をもっと厚く | 05 PAD 8 → 12 |
葉の面積の合計は左が40.1%から28.7%、右が19.1%から5.8%になり、見えるセルは左が41個から38個、右が38個から21個になります |
値を変える前に見ること
SPRING_K を上げると目標に届く時間は縮みますが、行きすぎの幅は減衰比 ZETA が決めるので、揺れを小さくしたいなら ZETA を上げます。02は0.5、07は0.6を使っています。
PAD は深さごとに0.55倍へ減る値なので、12pxに上げると最も深い層まで影響が広がります。上の数値は盤222×208の最初のラウンドのデータに結びついていて、盤の大きさを変えると比も面積の割合も変わります。
動きを減らす設定では配置が終わった一枚です — セルの名前と割合を文で読み上げ、キーボードでセルを選ぶアクセシビリティです
矢印キーでセルを選びEnterで押す
ステージの箱(role="application"、tabindex 0)にフォーカスを置くと、← ↑ は前のセル、→ ↓ は次のセルで、Enter・Spaceがそのセルを選びます。Pは一時停止、Nは一歩進める、Rはやり直しで、最初に矢印キーを押すと自動デモが止まって手動に移ります。
状態の行はデモのあいだ静かです
セルを指すと状態の行がそのセルの名前・値・割合を文で読みます。デモ中は aria-live="off" なので読まれず、最初に触れると polite に替わり、キャンバスは role="img" と項目ごとの説明を持ちます。一ラウンドが終わる0.6秒前に、隠れた role="status" が statusText() を一度だけ読みます。
動きを減らす設定と隠れたタブ
動きを減らす設定ではループなしで REST_S 秒の地点の配置が終わった一枚を描き、入力が入ると settleStill() が3秒ぶんの update を回して、トゥイーンなしで最後の配置をすぐ描きます。タブが隠れると止まり、戻ると時計を合わせ直します。
文字のコントラスト
セルのパステルの面の上で濃いインク(INK)は最小5.9:1、見出しの帯の上は5.2:1、補助のインク(INK2)は最小4.9:1です。盤の地の上のINKは11.3:1、ピル形ラベルの文字は14.0:1です。
ツリーマップのよくある質問 — ライブラリなしで作る方法・ツリーマップが円グラフとどう違うか・値が負や0のときの扱い
ツリーマップ 作り方にライブラリは必要ですか
キャンバス2Dがあれば足り、この編の九つの場面はライブラリ・WebGL・CDNを使いません。配置は squarify ひとつの関数で、乱数はシードを固定した生成器ひとつなので、同じ入力は同じ盤を作ります。
ツリーマップは円グラフとどう違いますか
円グラフは角度で割合を見せ、ツリーマップは四角形の面積で見せるので、セルが多いデータや層のあるデータはツリーマップが一枚に収められます。08は同じデータを四角いセルと円で行き来して見せるので、面積で読むときと直径で読むときに大きな値がどれだけ違って見えるかを比べられます。
値が負や0のときはどう扱いますか
squarify と slice は値を Math.max(v, 1e-6) に引き上げて、面積が0のセルが生まれないようにします。負の値は面積で表せないので、04のように増減は色で別に見せるのが合います。
参考として、MDNのmeasureTextの文書は文字の幅を測る方法を、requestAnimationFrameの文書と基本のアニメーションのチュートリアルは描くループを、prefers-reduced-motionの文書は動きを減らす設定を扱っています。同じ計算方式のチャートは棒グラフの編と円グラフの編で、セルを詰める配置はレンガ状レイアウトの編と組織図の編で見られます。









