09A Live Word Board That Grows With Each Vote
As votes arrive on a fixed schedule each word eases up in size, and when a grown word bumps a neighbor, only that neighbor springs to the nearest free spot while everything else stays put, so the board does not reshuffle.
Best forLive poll screen for a talk or class
REFLOW_STIFFNESSSIZE_LERPVOTE_GAP_MS

<main class="stage">
<div class="wc is-demo" id="wc">
<canvas class="wc__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A live poll board where a word grows with each vote and only the neighbors it overlaps step aside."></canvas>
<ol class="wc__list" data-role="list" aria-label="Words ranked by frequency"></ol>
<button class="wc__vote" data-role="vote" type="button">Add one vote</button>
<p class="wc__live" data-role="live" aria-live="off"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.wc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.wc__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 화면에서는 숨기고 화면 낭독기는 읽는 빈도 순 목록·알림 줄 — display: none 이 아니라 1px 상자로 줄여 접근성 트리에 남긴다
.wc__list,
.wc__live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.wc__vote {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
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;
}
.wc__vote:hover {
transform: translateY(-2px);
}
.wc__vote:active {
transform: translateY(1px);
}
.wc__vote:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wc__vote { font-size: 11px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 마지막 배치 한 장면으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.wc__vote { transition: none !important; }
.wc__vote:hover,
.wc__vote: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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.wc__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.wc__list,
.wc__live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.wc__vote {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
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);
}
.wc__vote:hover {
transform: translateY(-2px);
}
.wc__vote:active {
transform: translateY(1px);
}
.wc__vote:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wc__vote {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.wc__vote {
transition: none !important;
}
.wc__vote:hover,
.wc__vote:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("wc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 판 위에 밝은 파스텔 글자를 얹어 명도 차로 글자가 읽힌다
const BG = [30, 27, 44];
const BG_LOW = [44, 38, 64];
const GUIDE = [104, 96, 140];
const CREAM = [255, 248, 234];
const PEACH = [255, 204, 168];
const MINT = [140, 228, 196];
const SKY = [140, 204, 255];
const LEMON = [246, 230, 148];
const LILAC = [198, 176, 255];
const PINK = [255, 170, 198];
const WORD_COLORS = [PEACH, MINT, SKY, LEMON, LILAC, PINK];
// 글꼴 — 기기에 이미 있는 시스템 글꼴 목록만. 바깥에서 글꼴 파일을 불러오지 않는다
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
const FONT_WEIGHT = 700;
// 크기 = SIZE_MAX × (횟수 / 1위 횟수)^SIZE_EXPONENT. 0.5(제곱근)면 글자 넓이(크기의 제곱)가 횟수에 비례한다
const SIZE_EXPONENT = 0.5;
const SIZE_MAX = 50;
const SIZE_MIN = 10;
function sizeFor(count, top) {
return Math.max(SIZE_MIN, SIZE_MAX * Math.pow(count / top, SIZE_EXPONENT));
}
// meetings·설문에서 흔히 나오는 낱말과 횟수 — 많이 나온 순. theme 은 0 Pace · 1 Together · 2 How we work · 3 Growing
const VOCAB = [
{ text: 'collaboration', count: 24, theme: 1 },
{ text: 'communication', count: 19, theme: 1 },
{ text: 'deadline', count: 16, theme: 0 },
{ text: 'feedback', count: 14, theme: 2 },
{ text: 'schedule', count: 12, theme: 0 },
{ text: 'growth', count: 11, theme: 3 },
{ text: 'sharing', count: 10, theme: 2 },
{ text: 'trust', count: 9, theme: 1 },
{ text: 'meetings', count: 8, theme: 0 },
{ text: 'priorities', count: 8, theme: 0 },
{ text: 'rest', count: 7, theme: 3 },
{ text: 'goals', count: 7, theme: 0 },
{ text: 'kindness', count: 6, theme: 1 },
{ text: 'docs', count: 6, theme: 2 },
{ text: 'praise', count: 6, theme: 1 },
{ text: 'focus', count: 5, theme: 0 },
{ text: 'automation', count: 5, theme: 2 },
{ text: 'learning', count: 5, theme: 3 },
{ text: 'reviews', count: 4, theme: 2 },
{ text: 'cheering', count: 4, theme: 1 },
{ text: 'challenge', count: 4, theme: 3 },
{ text: 'planning', count: 4, theme: 0 },
{ text: 'questions', count: 3, theme: 2 },
{ text: 'balance', count: 3, theme: 3 },
{ text: 'thanks', count: 3, theme: 1 },
{ text: 'speed', count: 3, theme: 0 },
{ text: 'data', count: 3, theme: 2 },
{ text: 'leeway', count: 3, theme: 3 },
{ text: 'retros', count: 2, theme: 0 },
{ text: 'support', count: 2, theme: 1 },
{ text: 'experiments', count: 2, theme: 2 },
{ text: 'habits', count: 2, theme: 3 },
{ text: 'quality', count: 2, theme: 2 },
{ text: 'mood', count: 2, theme: 1 },
{ text: 'change', count: 2, theme: 3 },
{ text: 'launch', count: 2, theme: 0 },
{ text: 'cleanup', count: 1, theme: 2 },
{ text: 'roles', count: 1, theme: 1 },
{ text: 'fun', count: 1, theme: 1 },
{ text: 'notes', count: 1, theme: 0 },
{ text: 'efficiency', count: 1, theme: 0 },
{ text: 'onboarding', count: 1, theme: 2 },
{ text: 'connection', count: 1, theme: 1 },
{ text: 'ideas', count: 1, theme: 3 },
{ text: 'stability', count: 1, theme: 3 },
{ text: 'transparency', count: 1, theme: 2 },
{ text: 'customers', count: 1, theme: 2 },
{ text: 'practice', count: 1, theme: 3 }
];
// 횟수 문구 — 1 은 따로 둔다(영어판의 단수형 자리)
const COUNT_LABEL = '{n} mentions';
const COUNT_ONE = '1 mention';
const TOP_LABEL = 'Most frequent:';
// 캔버스 요약에서 1~3위 앞에 붙는 말 — 09 투표판은 자기 말로 바꾼다
let rankHeading = TOP_LABEL;
function countText(n) { return n === 1 ? COUNT_ONE : COUNT_LABEL.replace('{n}', String(n)); }
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function 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) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 배치·일정표가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 튀어나오기 — 감쇠 진동 1 − e^(−POP_DAMP·t)·cos(POP_FREQ·t), t 는 초. goals 크기를 한 번 넘겼다가 자리 잡는다
const POP_DAMP = 7;
const POP_FREQ = 11;
function popScale(t) {
if (t <= 0) {
return 0;
}
return 1 - Math.exp(-POP_DAMP * t) * Math.cos(POP_FREQ * t);
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const listEl = root.querySelector('[data-role="list"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !listEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const list = listEl;
// 캔버스 요약 — HTML 에 적힌 장면 설명 뒤에 지금 1~3위를 덧붙인다
const baseAria = cv.getAttribute('aria-label') || '';
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 -1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const 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 clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function fontOf(size) { return FONT_WEIGHT + ' ' + size.toFixed(1) + 'px ' + FONT_FAMILY; }
function roundRectPath(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// 놓인 낱말 그리기 — 상자 가운데를 중심으로 scale 배·angle 라디안 돌리고 lift 만큼 띄운다. 비트 칸을 만든 자리와 같은 기준선에 찍는다
function drawPlaced(p, alpha, scale, angle, lift, color) {
if (alpha <= 0.002 || scale <= 0.001) {
return;
}
const cx = p.x + p.w / 2;
const cy = p.y + p.h / 2;
ctx.save();
ctx.translate(cx, cy - lift);
if (angle !== 0) {
ctx.rotate(angle);
}
if (scale !== 1) {
ctx.scale(scale, scale);
}
ctx.font = fontOf(p.size);
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(color, alpha);
ctx.fillText(p.text, p.x + p.dx - cx, p.y + p.dy - cy);
ctx.restore();
}
function hitPlaced(p) {
return hoverX >= p.x && hoverX <= p.x + p.w && hoverY >= p.y && hoverY <= p.y + p.h;
}
// 포인터 아래 낱말 — 뒤에 그린 것이 위에 있으니 뒤에서부터 찾는다. shown(i) 가 거짓인 낱말은 건너뛴다
function pickPlaced(ps, shown) {
if (hoverX < 0) {
return -1;
}
for (let i = ps.length - 1; i >= 0; i -= 1) {
if (shown(i) && hitPlaced(ps[i])) {
return i;
}
}
return -1;
}
function drawHalo(x, y, w, h) {
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(CREAM, 0.9);
roundRectPath(x - 3, y - 2, w + 6, h + 4, 6);
ctx.stroke();
}
// 말풍선 — 낱말과 횟수를 크림 바탕에 진한 글자로
function drawTip(label, ax, ay) {
ctx.font = fontOf(11);
const bw = ctx.measureText(label).width + 16;
const bh = 22;
const bx = clamp(ax - bw / 2, 4, W - bw - 4);
const by = ay - bh - 8 < 4 ? ay + 10 : ay - bh - 8;
ctx.fillStyle = rgba(CREAM, 0.96);
roundRectPath(bx, by, bw, bh, 8);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, bx + bw / 2, by + bh / 2 + 0.5);
}
// 판 위 설명 글 — 어두운 알약 바탕에 작은 크림 글자. 돌려주는 값은 차지한 상자 [x, y, w, h]
function drawCaption(text, x, y, alignRight) {
ctx.font = fontOf(11);
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(BG, 0.82);
roundRectPath(bx, y, bw, bh, 8);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.92);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
return [bx, y, bw, bh];
}
// 반지름이 일정하게 늘어나는 나선 — 걸음 i 에서 각 = i × SPIRAL_TURN(라디안), 반지름 = i × SPIRAL_STEP(px).
// 한 바퀴(2π / SPIRAL_TURN 걸음)마다 반지름이 같은 폭만큼 커진다. 가로는 SPIRAL_ASPECT 배로 늘려 넓은 판을 고르게 훑는다
const SPIRAL_STEP = 0.35;
const SPIRAL_TURN = 0.1;
const SPIRAL_ASPECT = 1.6;
// 나선을 끝낼 걸음 — 반지름이 판 대각선 절반에 닿으면 그만 찾는다
const SPIRAL_LIMIT = Math.ceil(Math.hypot(W, H) / 2 / SPIRAL_STEP);
// 지금까지 나선에서 내디딘 걸음 수 — 자리 하나 찾는 데 든 일
let spiralSteps = 0;
function spiralAt(i) {
const a = i * SPIRAL_TURN;
const r = i * SPIRAL_STEP;
return [Math.cos(a) * r * SPIRAL_ASPECT, Math.sin(a) * r];
}
const POLL_WORDS = 14;
const START_VOTES = [3, 3, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1];
// 투표 일정표 — 시드 고정. 표는 VOTE_START_MS 부터 VOTE_GAP_MS 마다 하나, 선택지는 VOTE_WEIGHTS 비율로 뽑는다(앞 셋이 1위를 다툰다)
const VOTE_WEIGHTS = [5, 6, 4.5, 3, 2, 2, 1.5, 1.5, 1, 1, 1, 1, 1, 1];
const POLL_SEED = 4779;
const VOTE_TOTAL = 56;
const VOTE_START_MS = 300;
const VOTE_GAP_MS = 125;
const LOOP_MS = VOTE_START_MS + VOTE_TOTAL * VOTE_GAP_MS + 2200;
// 크기 — POLL_SIZE_REF 표에서 SIZE_MAX. 지금 크기는 틱마다 남은 차의 SIZE_LERP 만큼 가야 할 크기로 다가간다
const POLL_SIZE_REF = 18;
const SIZE_LERP = 0.12;
// 비켜서기 스프링 — 틱마다 남은 거리의 REFLOW_STIFFNESS 배를 속도에 더하고 REFLOW_DAMPING 배로 줄인다
const REFLOW_STIFFNESS = 0.18;
const REFLOW_DAMPING = 0.72;
const BOX_GAP = 4;
// 빈자리 찾기 — 밀려난 낱말의 지금 목표에서 나선을 SEARCH_LIMIT 걸음까지 돌며 처음 비는 자리
const SEARCH_LIMIT = 700;
const FLASH_MS = 520;
const SETTLE_TICKS = 600;
// 낱말이 들어가지 않는 자리 — 왼쪽 위 집계 글, 오른쪽 아래 버튼
const KEEP_OUT = [{ x: 0, y: 0, w: 210, h: 32 }, { x: W - 150, y: H - 46, w: 150, h: 46 }];
const VOTE_LABEL = '{n} votes';
const VOTE_ONE = '1 vote';
const LEADER_LABEL = 'New leader: {w}';
const TALLY_LABEL = '{n} votes · leader {w}';
const POLL_TOP_LABEL = 'Current leaders:';
rankHeading = POLL_TOP_LABEL;
const voteBtnEl = root.querySelector('[data-role="vote"]');
const liveEl = root.querySelector('[data-role="live"]');
if (!voteBtnEl || !liveEl) {
return;
}
const voteBtn = voteBtnEl;
const live = liveEl;
let options = [];
let schedule = [];
let nextVote = 0;
let leader = 0;
let totalVotes = 0;
// 지금까지 비켜선 횟수(빈자리를 새로 찾은 횟수)와 1위가 바뀐 횟수
let moves = 0;
let leaderChanges = 0;
let lastT = -1;
let fresh = true;
function votesText(n) { return n === 1 ? VOTE_ONE : VOTE_LABEL.replace('{n}', String(n)); }
function pollSize(v) { return Math.min(SIZE_MAX, Math.max(SIZE_MIN, SIZE_MAX * Math.pow(v / POLL_SIZE_REF, SIZE_EXPONENT))); }
function rectOf(o, cx, cy, size) {
const w = o.wpp * size + BOX_GAP;
const h = (o.asc + o.desc) * size + BOX_GAP;
return { x: cx - w / 2, y: cy - h / 2, w: w, h: h };
}
function touch(a, b) { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; }
// 들어갈 수 있는가 — 판 안, 막은 자리 밖, 다른 낱말의 goals 상자(가야 할 크기)와 안 겹침
function fits(i, r) {
if (r.x < 4 || r.y < 4 || r.x + r.w > W - 4 || r.y + r.h > H - 4) {
return false;
}
for (let k = 0; k < KEEP_OUT.length; k += 1) {
if (touch(r, KEEP_OUT[k])) {
return false;
}
}
for (let j = 0; j < options.length; j += 1) {
if (j === i) {
continue;
}
const o = options[j];
if (touch(r, rectOf(o, o.tx, o.ty, o.goal))) {
return false;
}
}
return true;
}
// 가까운 빈자리 — 지금 목표에서 나선을 돌며 처음 들어맞는 자리로 목표만 옮긴다(낱말은 스프링으로 따라간다)
function relocate(i) {
const o = options[i];
for (let s = 1; s < SEARCH_LIMIT; s += 1) {
const p = spiralAt(s);
const r = rectOf(o, o.tx + p[0], o.ty + p[1], o.goal);
if (fits(i, r)) {
o.tx += p[0];
o.ty += p[1];
moves += 1;
return true;
}
}
return false;
}
function build() {
options = [];
for (let i = 0; i < Math.min(POLL_WORDS, VOCAB.length); i += 1) {
const e = VOCAB[i];
ctx.font = fontOf(100);
const m = ctx.measureText(e.text);
options.push({ e: e, votes: 0, size: SIZE_MIN, goal: SIZE_MIN, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0,
wpp: (m.actualBoundingBoxLeft + m.actualBoundingBoxRight) / 100, asc: m.actualBoundingBoxAscent / 100, desc: m.actualBoundingBoxDescent / 100, flash: 0, color: WORD_COLORS[i % WORD_COLORS.length] });
}
// 일정표 — 무게 비율로 뽑은 선택지 번호를 VOTE_TOTAL 개 미리 적어 둔다
const rng = makeRng(POLL_SEED);
let sum = 0;
for (let i = 0; i < options.length; i += 1) {
sum += VOTE_WEIGHTS[i];
}
schedule = [];
for (let n = 0; n < VOTE_TOTAL; n += 1) {
let r = rng() * sum;
let pick = 0;
while (pick < options.length - 1 && r >= VOTE_WEIGHTS[pick]) {
r -= VOTE_WEIGHTS[pick];
pick += 1;
}
schedule.push(pick);
}
fresh = true;
}
// 처음 판 — 시작 표로 크기를 정하고 가운데서 나선을 따라 겹치지 않는 자리에 하나씩
function resetPoll() {
totalVotes = 0;
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.votes = START_VOTES[i];
totalVotes += o.votes;
o.goal = pollSize(o.votes);
o.size = o.goal;
o.tx = -999;
o.ty = -999;
o.vx = 0;
o.vy = 0;
o.flash = 0;
}
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
for (let s = 0; s < SPIRAL_LIMIT * 2; s += 1) {
const p = spiralAt(s);
if (fits(i, rectOf(o, W / 2 + p[0], H / 2 + 10 + p[1], o.goal))) {
o.tx = W / 2 + p[0];
o.ty = H / 2 + 10 + p[1];
break;
}
}
o.x = o.tx;
o.y = o.ty;
}
nextVote = 0;
leader = 0;
moves = 0;
leaderChanges = 0;
live.textContent = '';
}
// 1위 — 표가 가장 많은 선택지. 같으면 지금 1위가 그대로
function checkLeader() {
let best = leader;
for (let i = 0; i < options.length; i += 1) {
if (options[i].votes > options[best].votes) {
best = i;
}
}
if (best !== leader) {
leader = best;
leaderChanges += 1;
// 1위가 바뀐 때만 알림 줄을 고친다 — 시연 중에는 aria-live 가 off 라 읽지 않고, 사람이 손댄 뒤에는 polite 로 읽는다
live.textContent = LEADER_LABEL.replace('{w}', options[leader].e.text);
}
}
function castVote(i) {
const o = options[i];
o.votes += 1;
totalVotes += 1;
o.goal = pollSize(o.votes);
o.flash = FLASH_MS;
checkLeader();
}
// 한 틱 — 크기를 가야 할 크기로 다가가게 하고, goals 상자가 겹친 쌍에서 표가 적은 쪽(같으면 뒤 번호)만 빈자리로 비킨 뒤 스프링으로 따라간다
function physicsTick() {
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.size += (o.goal - o.size) * SIZE_LERP;
o.flash = Math.max(0, o.flash - FIXED_DT);
}
for (let i = 0; i < options.length; i += 1) {
for (let j = i + 1; j < options.length; j += 1) {
const a = options[i];
const b = options[j];
if (!touch(rectOf(a, a.tx, a.ty, a.goal), rectOf(b, b.tx, b.ty, b.goal))) {
continue;
}
relocate(b.votes > a.votes ? i : j);
}
}
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.vx = (o.vx + (o.tx - o.x) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
o.vy = (o.vy + (o.ty - o.y) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
o.x += o.vx;
o.y += o.vy;
}
}
function loopLength() { return LOOP_MS; }
function step(ms) {
if (fresh || (demo && ms < lastT)) {
resetPoll();
fresh = false;
}
lastT = ms;
if (demo) {
while (nextVote < schedule.length && ms >= VOTE_START_MS + nextVote * VOTE_GAP_MS) {
castVote(schedule[nextVote]);
nextVote += 1;
}
}
physicsTick();
}
// 그리지 않고 끝까지 — 스프링이 가라앉을 만큼 틱을 돌린 뒤 크기·자리를 목표에 맞춘다
function settlePoll() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
physicsTick();
}
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.size = o.goal;
o.x = o.tx;
o.y = o.ty;
o.vx = 0;
o.vy = 0;
o.flash = 0;
}
}
// 동작 줄이기 — 일정표의 표를 모두 넣고 자리를 끝까지 계산한 최종 집계 한 장면
function finalScene() {
resetPoll();
fresh = false;
while (nextVote < schedule.length) {
castVote(schedule[nextVote]);
nextVote += 1;
}
settlePoll();
}
// 사람이 손대면 — 시연 표는 멈추고, 1위가 바뀔 때 알림 줄을 화면 낭독기가 읽는다
function onWake() { live.setAttribute('aria-live', 'polite'); }
// 버튼 — 일정표의 다음 표 하나를 넣는다(끝까지 가면 처음부터 다시 돈다)
voteBtn.addEventListener('click', function () {
wake();
if (fresh) {
resetPoll();
fresh = false;
}
castVote(schedule[nextVote % schedule.length]);
nextVote += 1;
if (still) {
settlePoll();
render();
}
}, { signal: off.signal });
function draw() {
clearStage();
let hi = -1;
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
const r = rectOf(o, o.x, o.y, o.size);
if (hoverX >= r.x && hoverX <= r.x + r.w && hoverY >= r.y && hoverY <= r.y + r.h) {
hi = i;
}
// 스프링으로 옮겨 가는 중 — 지금 자리에서 목표까지 옅은 실
const far = Math.hypot(o.tx - o.x, o.ty - o.y);
if (far > 2) {
ctx.lineWidth = 1.2;
ctx.setLineDash([3, 4]);
ctx.strokeStyle = rgba(o.color, clamp(far / 30, 0, 0.6));
ctx.beginPath();
ctx.moveTo(o.x, o.y);
ctx.lineTo(o.tx, o.ty);
ctx.stroke();
ctx.setLineDash([]);
}
// 막 표를 받은 낱말 — 상자 둘레가 밝아지고 「+1」 이 떠오른다
if (o.flash > 0) {
const k = 1 - o.flash / FLASH_MS;
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(o.color, (1 - k) * 0.9);
roundRectPath(r.x - 2 - k * 4, r.y - 2 - k * 4, r.w + 4 + k * 8, r.h + 4 + k * 8, 8);
ctx.stroke();
ctx.font = fontOf(11);
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(CREAM, (1 - k) * 0.95);
ctx.fillText('+1', o.x, r.y - 2 - k * 12);
}
ctx.font = fontOf(o.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(o.color, 1);
ctx.fillText(o.e.text, o.x, o.y + (o.asc - o.desc) * o.size / 2);
if (i === leader) {
ctx.fillStyle = rgba(LEMON, 0.95);
ctx.fillRect(o.x - o.wpp * o.size / 2, r.y + r.h - 1, o.wpp * o.size, 2);
}
}
if (options.length > 0) {
drawCaption(TALLY_LABEL.replace('{n}', String(totalVotes)).replace('{w}', options[leader].e.text), 8, 8, false);
}
if (hi >= 0) {
const o = options[hi];
drawTip(o.e.text + ' · ' + votesText(o.votes), o.x, o.y - (o.asc + o.desc) * o.size / 2);
}
}
function ranking() {
const rows = options.map(function (o) { return [o.e.text, o.votes, votesText(o.votes)]; });
rows.sort(function (a, b) { return b[1] - a[1]; });
return rows;
}
// 글꼴 준비가 끝나 배치를 계산했는가
let ready = false;
// cleanup 뒤(리액트 언마운트)에 늦게 온 글꼴 준비 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (still && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (still && ready) {
render();
}
}, { signal: off.signal });
// 숨긴 목록 — 순위나 횟수가 바뀐 때만 다시 쓴다. 캔버스 요약에는 1~3위를 붙인다
let listKey = '';
function syncList() {
const rows = ranking();
const key = rows.map(function (r) { return r[0] + ':' + r[1]; }).join('|');
if (key === listKey) {
return;
}
listKey = key;
while (list.firstChild) {
list.removeChild(list.firstChild);
}
for (let i = 0; i < rows.length; i += 1) {
const li = document.createElement('li');
li.textContent = rows[i][0] + ' — ' + rows[i][2];
list.appendChild(li);
}
const top = rows.slice(0, 3).map(function (r) { return r[0] + ' ' + r[2]; }).join(', ');
cv.setAttribute('aria-label', baseAria + ' ' + rankHeading + ' ' + top);
}
function render() {
draw();
syncList();
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
step(clock % loopLength());
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 배치를 끝까지 계산한 마지막 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
render();
}
function play() {
clock = 0;
step(0);
render();
startLoop();
}
// 설정이 페이지를 연 채 바뀌면 — 켜지면 마지막 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
if (!alive) {
return;
}
fitCanvas();
build();
ready = true;
if (still) {
showStill();
}
else {
play();
}
}
// 글꼴 — 준비(document.fonts.ready)가 끝난 뒤에 글자 폭을 잰다. 준비 전에 재면 나중에 바뀐 글꼴과 비트 칸이 어긋난다
const fontSet = document.fonts;
// 늦게 도착한 글꼴 — 배치를 마친 뒤 글꼴이 새로 들어오면 글자 폭을 다시 재 배치를 다시 계산한다
fontSet.addEventListener('loadingdone', function () {
if (!ready || !alive) {
return;
}
build();
if (still) {
finalScene();
}
render();
}, { signal: off.signal });
fontSet.ready.then(begin);
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LivePollReflowSpring - vanilla 판(09-wc-live-poll-reflow-spring) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LivePollReflowSpring.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LivePollReflowSpring() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 낱말 하나 — 글자, 나온 횟수, 주제 번호(05 무리 색)
type Entry = { text: string; count: number; theme: number };
// 판에 놓인 낱말 — (x, y) 는 글자 상자 왼쪽 위, w·h 는 상자 크기, (dx, dy) 는 상자 왼쪽 위에서 글자 기준선 시작점까지
type Placed = { text: string; count: number; theme: number; size: number; x: number; y: number; w: number; h: number; dx: number; dy: number; color: RGB; shrinks: number };
// 숨긴 목록 한 줄 — 낱말, 횟수, 읽어 줄 횟수 문구
type Row = [string, number, string];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 어두운 판 위에 밝은 파스텔 글자를 얹어 명도 차로 글자가 읽힌다
const BG: RGB = [30, 27, 44];
const BG_LOW: RGB = [44, 38, 64];
const GUIDE: RGB = [104, 96, 140];
const CREAM: RGB = [255, 248, 234];
const PEACH: RGB = [255, 204, 168];
const MINT: RGB = [140, 228, 196];
const SKY: RGB = [140, 204, 255];
const LEMON: RGB = [246, 230, 148];
const LILAC: RGB = [198, 176, 255];
const PINK: RGB = [255, 170, 198];
const WORD_COLORS: RGB[] = [PEACH, MINT, SKY, LEMON, LILAC, PINK];
// 글꼴 — 기기에 이미 있는 시스템 글꼴 목록만. 바깥에서 글꼴 파일을 불러오지 않는다
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
const FONT_WEIGHT = 700;
// 크기 = SIZE_MAX × (횟수 / 1위 횟수)^SIZE_EXPONENT. 0.5(제곱근)면 글자 넓이(크기의 제곱)가 횟수에 비례한다
const SIZE_EXPONENT = 0.5;
const SIZE_MAX = 50;
const SIZE_MIN = 10;
function sizeFor(count: number, top: number): number {
return Math.max(SIZE_MIN, SIZE_MAX * Math.pow(count / top, SIZE_EXPONENT));
}
// 회의·설문에서 흔히 나오는 낱말과 횟수 — 많이 나온 순. theme 은 0 일의 속도 · 1 함께하기 · 2 일하는 방식 · 3 배우고 자라기
const VOCAB: Entry[] = [
{ text: '협업', count: 24, theme: 1 },
{ text: '소통', count: 19, theme: 1 },
{ text: '마감', count: 16, theme: 0 },
{ text: '피드백', count: 14, theme: 2 },
{ text: '일정', count: 12, theme: 0 },
{ text: '성장', count: 11, theme: 3 },
{ text: '공유', count: 10, theme: 2 },
{ text: '신뢰', count: 9, theme: 1 },
{ text: '회의', count: 8, theme: 0 },
{ text: '우선순위', count: 8, theme: 0 },
{ text: '휴식', count: 7, theme: 3 },
{ text: '목표', count: 7, theme: 0 },
{ text: '배려', count: 6, theme: 1 },
{ text: '문서', count: 6, theme: 2 },
{ text: '칭찬', count: 6, theme: 1 },
{ text: '집중', count: 5, theme: 0 },
{ text: '자동화', count: 5, theme: 2 },
{ text: '학습', count: 5, theme: 3 },
{ text: '리뷰', count: 4, theme: 2 },
{ text: '응원', count: 4, theme: 1 },
{ text: '도전', count: 4, theme: 3 },
{ text: '계획', count: 4, theme: 0 },
{ text: '질문', count: 3, theme: 2 },
{ text: '균형', count: 3, theme: 3 },
{ text: '감사', count: 3, theme: 1 },
{ text: '속도', count: 3, theme: 0 },
{ text: '데이터', count: 3, theme: 2 },
{ text: '여유', count: 3, theme: 3 },
{ text: '회고', count: 2, theme: 0 },
{ text: '지원', count: 2, theme: 1 },
{ text: '실험', count: 2, theme: 2 },
{ text: '습관', count: 2, theme: 3 },
{ text: '품질', count: 2, theme: 2 },
{ text: '분위기', count: 2, theme: 1 },
{ text: '변화', count: 2, theme: 3 },
{ text: '출시', count: 2, theme: 0 },
{ text: '정리', count: 1, theme: 2 },
{ text: '역할', count: 1, theme: 1 },
{ text: '재미', count: 1, theme: 1 },
{ text: '기록', count: 1, theme: 0 },
{ text: '효율', count: 1, theme: 0 },
{ text: '온보딩', count: 1, theme: 2 },
{ text: '연결', count: 1, theme: 1 },
{ text: '아이디어', count: 1, theme: 3 },
{ text: '안정', count: 1, theme: 3 },
{ text: '투명성', count: 1, theme: 2 },
{ text: '고객', count: 1, theme: 2 },
{ text: '연습', count: 1, theme: 3 }
];
// 횟수 문구 — 1 은 따로 둔다(영어판의 단수형 자리)
const COUNT_LABEL = '{n}회';
const COUNT_ONE = '1회';
const TOP_LABEL = '가장 많이 나온 말:';
// 캔버스 요약에서 1~3위 앞에 붙는 말 — 09 투표판은 자기 말로 바꾼다
let rankHeading = TOP_LABEL;
function countText(n: number): string { return n === 1 ? COUNT_ONE : COUNT_LABEL.replace('{n}', String(n)); }
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function 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) + ')';
}
function mixRGB(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)]; }
// 시드 고정 난수 — 같은 시드면 같은 배치·일정표가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 튀어나오기 — 감쇠 진동 1 − e^(−POP_DAMP·t)·cos(POP_FREQ·t), t 는 초. 목표 크기를 한 번 넘겼다가 자리 잡는다
const POP_DAMP = 7;
const POP_FREQ = 11;
function popScale(t: number): number {
if (t <= 0) { return 0; }
return 1 - Math.exp(-POP_DAMP * t) * Math.cos(POP_FREQ * t);
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const listEl = root.querySelector('[data-role="list"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !listEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const list: HTMLElement = listEl;
// 캔버스 요약 — HTML 에 적힌 장면 설명 뒤에 지금 1~3위를 덧붙인다
const baseAria = cv.getAttribute('aria-label') || '';
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 -1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const 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 clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function fontOf(size: number): string { return FONT_WEIGHT + ' ' + size.toFixed(1) + 'px ' + FONT_FAMILY; }
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// 놓인 낱말 그리기 — 상자 가운데를 중심으로 scale 배·angle 라디안 돌리고 lift 만큼 띄운다. 비트 칸을 만든 자리와 같은 기준선에 찍는다
function drawPlaced(p: Placed, alpha: number, scale: number, angle: number, lift: number, color: RGB): void {
if (alpha <= 0.002 || scale <= 0.001) { return; }
const cx = p.x + p.w / 2;
const cy = p.y + p.h / 2;
ctx.save();
ctx.translate(cx, cy - lift);
if (angle !== 0) { ctx.rotate(angle); }
if (scale !== 1) { ctx.scale(scale, scale); }
ctx.font = fontOf(p.size);
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(color, alpha);
ctx.fillText(p.text, p.x + p.dx - cx, p.y + p.dy - cy);
ctx.restore();
}
function hitPlaced(p: Placed): boolean {
return hoverX >= p.x && hoverX <= p.x + p.w && hoverY >= p.y && hoverY <= p.y + p.h;
}
// 포인터 아래 낱말 — 뒤에 그린 것이 위에 있으니 뒤에서부터 찾는다. shown(i) 가 거짓인 낱말은 건너뛴다
function pickPlaced(ps: Placed[], shown: (i: number) => boolean): number {
if (hoverX < 0) { return -1; }
for (let i = ps.length - 1; i >= 0; i -= 1) {
if (shown(i) && hitPlaced(ps[i])) { return i; }
}
return -1;
}
function drawHalo(x: number, y: number, w: number, h: number): void {
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(CREAM, 0.9);
roundRectPath(x - 3, y - 2, w + 6, h + 4, 6);
ctx.stroke();
}
// 말풍선 — 낱말과 횟수를 크림 바탕에 진한 글자로
function drawTip(label: string, ax: number, ay: number): void {
ctx.font = fontOf(11);
const bw = ctx.measureText(label).width + 16;
const bh = 22;
const bx = clamp(ax - bw / 2, 4, W - bw - 4);
const by = ay - bh - 8 < 4 ? ay + 10 : ay - bh - 8;
ctx.fillStyle = rgba(CREAM, 0.96);
roundRectPath(bx, by, bw, bh, 8);
ctx.fill();
ctx.fillStyle = rgba(BG, 1);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, bx + bw / 2, by + bh / 2 + 0.5);
}
// 판 위 설명 글 — 어두운 알약 바탕에 작은 크림 글자. 돌려주는 값은 차지한 상자 [x, y, w, h]
function drawCaption(text: string, x: number, y: number, alignRight: boolean): [number, number, number, number] {
ctx.font = fontOf(11);
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(BG, 0.82);
roundRectPath(bx, y, bw, bh, 8);
ctx.fill();
ctx.fillStyle = rgba(CREAM, 0.92);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
return [bx, y, bw, bh];
}
// 반지름이 일정하게 늘어나는 나선 — 걸음 i 에서 각 = i × SPIRAL_TURN(라디안), 반지름 = i × SPIRAL_STEP(px).
// 한 바퀴(2π / SPIRAL_TURN 걸음)마다 반지름이 같은 폭만큼 커진다. 가로는 SPIRAL_ASPECT 배로 늘려 넓은 판을 고르게 훑는다
const SPIRAL_STEP = 0.35;
const SPIRAL_TURN = 0.1;
const SPIRAL_ASPECT = 1.6;
// 나선을 끝낼 걸음 — 반지름이 판 대각선 절반에 닿으면 그만 찾는다
const SPIRAL_LIMIT = Math.ceil(Math.hypot(W, H) / 2 / SPIRAL_STEP);
// 지금까지 나선에서 내디딘 걸음 수 — 자리 하나 찾는 데 든 일
let spiralSteps = 0;
function spiralAt(i: number): Pt {
const a = i * SPIRAL_TURN;
const r = i * SPIRAL_STEP;
return [Math.cos(a) * r * SPIRAL_ASPECT, Math.sin(a) * r];
}
// 투표 선택지 — 표 수(votes), 지금 크기(size)와 가야 할 크기(goal), 지금 자리(x, y 는 글자 상자 가운데)와 가야 할 자리(tx, ty), 스프링 속도
type Option = { e: Entry; votes: number; size: number; goal: number; x: number; y: number; vx: number; vy: number; tx: number; ty: number; wpp: number; asc: number; desc: number; flash: number; color: RGB };
type Rect = { x: number; y: number; w: number; h: number };
const POLL_WORDS = 14;
const START_VOTES = [3, 3, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1];
// 투표 일정표 — 시드 고정. 표는 VOTE_START_MS 부터 VOTE_GAP_MS 마다 하나, 선택지는 VOTE_WEIGHTS 비율로 뽑는다(앞 셋이 1위를 다툰다)
const VOTE_WEIGHTS = [5, 6, 4.5, 3, 2, 2, 1.5, 1.5, 1, 1, 1, 1, 1, 1];
const POLL_SEED = 4779;
const VOTE_TOTAL = 56;
const VOTE_START_MS = 300;
const VOTE_GAP_MS = 125;
const LOOP_MS = VOTE_START_MS + VOTE_TOTAL * VOTE_GAP_MS + 2200;
// 크기 — POLL_SIZE_REF 표에서 SIZE_MAX. 지금 크기는 틱마다 남은 차의 SIZE_LERP 만큼 가야 할 크기로 다가간다
const POLL_SIZE_REF = 18;
const SIZE_LERP = 0.12;
// 비켜서기 스프링 — 틱마다 남은 거리의 REFLOW_STIFFNESS 배를 속도에 더하고 REFLOW_DAMPING 배로 줄인다
const REFLOW_STIFFNESS = 0.18;
const REFLOW_DAMPING = 0.72;
const BOX_GAP = 4;
// 빈자리 찾기 — 밀려난 낱말의 지금 목표에서 나선을 SEARCH_LIMIT 걸음까지 돌며 처음 비는 자리
const SEARCH_LIMIT = 700;
const FLASH_MS = 520;
const SETTLE_TICKS = 600;
// 낱말이 들어가지 않는 자리 — 왼쪽 위 집계 글, 오른쪽 아래 버튼
const KEEP_OUT: Rect[] = [{ x: 0, y: 0, w: 210, h: 32 }, { x: W - 150, y: H - 46, w: 150, h: 46 }];
const VOTE_LABEL = '{n}표';
const VOTE_ONE = '1표';
const LEADER_LABEL = '1위가 바뀌었어요: {w}';
const TALLY_LABEL = '총 {n}표 · 1위 {w}';
const POLL_TOP_LABEL = '지금 표가 많은 순:';
rankHeading = POLL_TOP_LABEL;
const voteBtnEl = root.querySelector('[data-role="vote"]') as HTMLButtonElement | null;
const liveEl = root.querySelector('[data-role="live"]') as HTMLElement | null;
if (!voteBtnEl || !liveEl) { return; }
const voteBtn: HTMLButtonElement = voteBtnEl;
const live: HTMLElement = liveEl;
let options: Option[] = [];
let schedule: number[] = [];
let nextVote = 0;
let leader = 0;
let totalVotes = 0;
// 지금까지 비켜선 횟수(빈자리를 새로 찾은 횟수)와 1위가 바뀐 횟수
let moves = 0;
let leaderChanges = 0;
let lastT = -1;
let fresh = true;
function votesText(n: number): string { return n === 1 ? VOTE_ONE : VOTE_LABEL.replace('{n}', String(n)); }
function pollSize(v: number): number { return Math.min(SIZE_MAX, Math.max(SIZE_MIN, SIZE_MAX * Math.pow(v / POLL_SIZE_REF, SIZE_EXPONENT))); }
function rectOf(o: Option, cx: number, cy: number, size: number): Rect {
const w = o.wpp * size + BOX_GAP;
const h = (o.asc + o.desc) * size + BOX_GAP;
return { x: cx - w / 2, y: cy - h / 2, w: w, h: h };
}
function touch(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; }
// 들어갈 수 있는가 — 판 안, 막은 자리 밖, 다른 낱말의 목표 상자(가야 할 크기)와 안 겹침
function fits(i: number, r: Rect): boolean {
if (r.x < 4 || r.y < 4 || r.x + r.w > W - 4 || r.y + r.h > H - 4) { return false; }
for (let k = 0; k < KEEP_OUT.length; k += 1) { if (touch(r, KEEP_OUT[k])) { return false; } }
for (let j = 0; j < options.length; j += 1) {
if (j === i) { continue; }
const o = options[j];
if (touch(r, rectOf(o, o.tx, o.ty, o.goal))) { return false; }
}
return true;
}
// 가까운 빈자리 — 지금 목표에서 나선을 돌며 처음 들어맞는 자리로 목표만 옮긴다(낱말은 스프링으로 따라간다)
function relocate(i: number): boolean {
const o = options[i];
for (let s = 1; s < SEARCH_LIMIT; s += 1) {
const p = spiralAt(s);
const r = rectOf(o, o.tx + p[0], o.ty + p[1], o.goal);
if (fits(i, r)) {
o.tx += p[0];
o.ty += p[1];
moves += 1;
return true;
}
}
return false;
}
function build(): void {
options = [];
for (let i = 0; i < Math.min(POLL_WORDS, VOCAB.length); i += 1) {
const e = VOCAB[i];
ctx.font = fontOf(100);
const m = ctx.measureText(e.text);
options.push({ e: e, votes: 0, size: SIZE_MIN, goal: SIZE_MIN, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0,
wpp: (m.actualBoundingBoxLeft + m.actualBoundingBoxRight) / 100, asc: m.actualBoundingBoxAscent / 100, desc: m.actualBoundingBoxDescent / 100, flash: 0, color: WORD_COLORS[i % WORD_COLORS.length] });
}
// 일정표 — 무게 비율로 뽑은 선택지 번호를 VOTE_TOTAL 개 미리 적어 둔다
const rng = makeRng(POLL_SEED);
let sum = 0;
for (let i = 0; i < options.length; i += 1) { sum += VOTE_WEIGHTS[i]; }
schedule = [];
for (let n = 0; n < VOTE_TOTAL; n += 1) {
let r = rng() * sum;
let pick = 0;
while (pick < options.length - 1 && r >= VOTE_WEIGHTS[pick]) { r -= VOTE_WEIGHTS[pick]; pick += 1; }
schedule.push(pick);
}
fresh = true;
}
// 처음 판 — 시작 표로 크기를 정하고 가운데서 나선을 따라 겹치지 않는 자리에 하나씩
function resetPoll(): void {
totalVotes = 0;
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.votes = START_VOTES[i];
totalVotes += o.votes;
o.goal = pollSize(o.votes);
o.size = o.goal;
o.tx = -999;
o.ty = -999;
o.vx = 0;
o.vy = 0;
o.flash = 0;
}
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
for (let s = 0; s < SPIRAL_LIMIT * 2; s += 1) {
const p = spiralAt(s);
if (fits(i, rectOf(o, W / 2 + p[0], H / 2 + 10 + p[1], o.goal))) { o.tx = W / 2 + p[0]; o.ty = H / 2 + 10 + p[1]; break; }
}
o.x = o.tx;
o.y = o.ty;
}
nextVote = 0;
leader = 0;
moves = 0;
leaderChanges = 0;
live.textContent = '';
}
// 1위 — 표가 가장 많은 선택지. 같으면 지금 1위가 그대로
function checkLeader(): void {
let best = leader;
for (let i = 0; i < options.length; i += 1) { if (options[i].votes > options[best].votes) { best = i; } }
if (best !== leader) {
leader = best;
leaderChanges += 1;
// 1위가 바뀐 때만 알림 줄을 고친다 — 시연 중에는 aria-live 가 off 라 읽지 않고, 사람이 손댄 뒤에는 polite 로 읽는다
live.textContent = LEADER_LABEL.replace('{w}', options[leader].e.text);
}
}
function castVote(i: number): void {
const o = options[i];
o.votes += 1;
totalVotes += 1;
o.goal = pollSize(o.votes);
o.flash = FLASH_MS;
checkLeader();
}
// 한 틱 — 크기를 가야 할 크기로 다가가게 하고, 목표 상자가 겹친 쌍에서 표가 적은 쪽(같으면 뒤 번호)만 빈자리로 비킨 뒤 스프링으로 따라간다
function physicsTick(): void {
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.size += (o.goal - o.size) * SIZE_LERP;
o.flash = Math.max(0, o.flash - FIXED_DT);
}
for (let i = 0; i < options.length; i += 1) {
for (let j = i + 1; j < options.length; j += 1) {
const a = options[i];
const b = options[j];
if (!touch(rectOf(a, a.tx, a.ty, a.goal), rectOf(b, b.tx, b.ty, b.goal))) { continue; }
relocate(b.votes > a.votes ? i : j);
}
}
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.vx = (o.vx + (o.tx - o.x) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
o.vy = (o.vy + (o.ty - o.y) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
o.x += o.vx;
o.y += o.vy;
}
}
function loopLength(): number { return LOOP_MS; }
function step(ms: number): void {
if (fresh || (demo && ms < lastT)) { resetPoll(); fresh = false; }
lastT = ms;
if (demo) {
while (nextVote < schedule.length && ms >= VOTE_START_MS + nextVote * VOTE_GAP_MS) {
castVote(schedule[nextVote]);
nextVote += 1;
}
}
physicsTick();
}
// 그리지 않고 끝까지 — 스프링이 가라앉을 만큼 틱을 돌린 뒤 크기·자리를 목표에 맞춘다
function settlePoll(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) { physicsTick(); }
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
o.size = o.goal;
o.x = o.tx;
o.y = o.ty;
o.vx = 0;
o.vy = 0;
o.flash = 0;
}
}
// 동작 줄이기 — 일정표의 표를 모두 넣고 자리를 끝까지 계산한 최종 집계 한 장면
function finalScene(): void {
resetPoll();
fresh = false;
while (nextVote < schedule.length) { castVote(schedule[nextVote]); nextVote += 1; }
settlePoll();
}
// 사람이 손대면 — 시연 표는 멈추고, 1위가 바뀔 때 알림 줄을 화면 낭독기가 읽는다
function onWake(): void { live.setAttribute('aria-live', 'polite'); }
// 버튼 — 일정표의 다음 표 하나를 넣는다(끝까지 가면 처음부터 다시 돈다)
voteBtn.addEventListener('click', function (): void {
wake();
if (fresh) { resetPoll(); fresh = false; }
castVote(schedule[nextVote % schedule.length]);
nextVote += 1;
if (still) { settlePoll(); render(); }
}, { signal: off.signal });
function draw(): void {
clearStage();
let hi = -1;
for (let i = 0; i < options.length; i += 1) {
const o = options[i];
const r = rectOf(o, o.x, o.y, o.size);
if (hoverX >= r.x && hoverX <= r.x + r.w && hoverY >= r.y && hoverY <= r.y + r.h) { hi = i; }
// 스프링으로 옮겨 가는 중 — 지금 자리에서 목표까지 옅은 실
const far = Math.hypot(o.tx - o.x, o.ty - o.y);
if (far > 2) {
ctx.lineWidth = 1.2;
ctx.setLineDash([3, 4]);
ctx.strokeStyle = rgba(o.color, clamp(far / 30, 0, 0.6));
ctx.beginPath();
ctx.moveTo(o.x, o.y);
ctx.lineTo(o.tx, o.ty);
ctx.stroke();
ctx.setLineDash([]);
}
// 막 표를 받은 낱말 — 상자 둘레가 밝아지고 「+1」 이 떠오른다
if (o.flash > 0) {
const k = 1 - o.flash / FLASH_MS;
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(o.color, (1 - k) * 0.9);
roundRectPath(r.x - 2 - k * 4, r.y - 2 - k * 4, r.w + 4 + k * 8, r.h + 4 + k * 8, 8);
ctx.stroke();
ctx.font = fontOf(11);
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = rgba(CREAM, (1 - k) * 0.95);
ctx.fillText('+1', o.x, r.y - 2 - k * 12);
}
ctx.font = fontOf(o.size);
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(o.color, 1);
ctx.fillText(o.e.text, o.x, o.y + (o.asc - o.desc) * o.size / 2);
if (i === leader) {
ctx.fillStyle = rgba(LEMON, 0.95);
ctx.fillRect(o.x - o.wpp * o.size / 2, r.y + r.h - 1, o.wpp * o.size, 2);
}
}
if (options.length > 0) {
drawCaption(TALLY_LABEL.replace('{n}', String(totalVotes)).replace('{w}', options[leader].e.text), 8, 8, false);
}
if (hi >= 0) {
const o = options[hi];
drawTip(o.e.text + ' · ' + votesText(o.votes), o.x, o.y - (o.asc + o.desc) * o.size / 2);
}
}
function ranking(): Row[] {
const rows = options.map(function (o: Option): Row { return [o.e.text, o.votes, votesText(o.votes)]; });
rows.sort(function (a: Row, b: Row): number { return b[1] - a[1]; });
return rows;
}
// 글꼴 준비가 끝나 배치를 계산했는가
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 글꼴 준비 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (still && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (still && ready) { render(); }
}, { signal: off.signal });
// 숨긴 목록 — 순위나 횟수가 바뀐 때만 다시 쓴다. 캔버스 요약에는 1~3위를 붙인다
let listKey = '';
function syncList(): void {
const rows = ranking();
const key = rows.map(function (r: Row): string { return r[0] + ':' + r[1]; }).join('|');
if (key === listKey) { return; }
listKey = key;
while (list.firstChild) { list.removeChild(list.firstChild); }
for (let i = 0; i < rows.length; i += 1) {
const li = document.createElement('li');
li.textContent = rows[i][0] + ' — ' + rows[i][2];
list.appendChild(li);
}
const top = rows.slice(0, 3).map(function (r: Row): string { return r[0] + ' ' + r[2]; }).join(', ');
cv.setAttribute('aria-label', baseAria + ' ' + rankHeading + ' ' + top);
}
function render(): void {
draw();
syncList();
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
step(clock % loopLength());
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 배치를 끝까지 계산한 마지막 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
render();
}
function play(): void {
clock = 0;
step(0);
render();
startLoop();
}
// 설정이 페이지를 연 채 바뀌면 — 켜지면 마지막 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
if (!alive) { return; }
fitCanvas();
build();
ready = true;
if (still) { showStill(); } else { play(); }
}
// 글꼴 — 준비(document.fonts.ready)가 끝난 뒤에 글자 폭을 잰다. 준비 전에 재면 나중에 바뀐 글꼴과 비트 칸이 어긋난다
const fontSet = document.fonts;
// 늦게 도착한 글꼴 — 배치를 마친 뒤 글꼴이 새로 들어오면 글자 폭을 다시 재 배치를 다시 계산한다
fontSet.addEventListener('loadingdone', function (): void {
if (!ready || !alive) { return; }
build();
if (still) { finalScene(); }
render();
}, { signal: off.signal });
fontSet.ready.then(begin);
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("wc") + " is-demo"} ref={rootRef}>
<canvas className={cx("wc__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="투표가 들어올 때마다 단어가 커지고 겹친 이웃 단어만 비켜서는 실시간 투표판."></canvas>
<ol className={cx("wc__list")} data-role="list" aria-label="빈도 순 낱말 목록"></ol>
<button className={cx("wc__vote")} data-role="vote" type="button">{'투표 하나 넣기'}</button>
<p className={cx("wc__live")} data-role="live" aria-live="off"></p>
</div>
);
}09 A Live Word Board That Grows With Each Vote — Measured area 10.128% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · Word Cloud JavaScript: 9 Scenes, Spiral to 3D
The post this demo lives in
Word Cloud JavaScript: 9 Scenes, Spiral to 3DWord cloud JavaScript on canvas: if every new vote reshuffles the cloud, nobody can read it. 09 keeps each word in place See all demos in this post
If you like this effect - similar demos
Dashboard UI09 Half-circle gauge with a live needleCSS Chart Design: 9 SVG Graphs, No Library
Dashboard UI07 Big number board with rolling digitsStock Ticker UI: 9 Live Price Widgets, No API Key
Dashboard UI01 A Card That Follows the Cursor and Tilts With Its MotionCSS Kanban Board: 9 Demos With Springs and Lerp