06A Stadium Map with Inertia Whose Sections Split into Seats as It Zooms
The scale grows while the focus point stays pinned to the same spot on screen, panning eases to a stop under friction after release, and once the scale crosses a threshold each section block splits into rows and seats, merging back when it shrinks.
Best forSection map for ballpark tickets
ZOOM_EASEFRICTIONSPLIT_FROM

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="An oval stadium map zooms in while holding one point in place; past a scale threshold the section blocks split into rows and seats, and the map keeps gliding after release until friction stops it."></canvas>
<p class="cf__status" data-role="status" aria-live="off">Whole stadium</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
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;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 좌석표에 무슨 일이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
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;
}
// 자리 고르기 버튼 — 오른쪽 위. 방향키(왼쪽·오른쪽 = 옆 자리, 위·아래 = 앞뒤 줄)도 같은 일을 한다
.cf__pick {
position: absolute;
top: $sp-2;
right: $sp-2;
display: flex;
gap: $sp-1;
}
.cf__btn {
padding: 0 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
transition: transform $dur-quick $ease-spring;
}
.cf__btn:hover {
transform: translateY(-2px);
}
.cf__btn:active {
transform: translateY(1px);
}
.cf__btn:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status,
.cf__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.cf__btn { padding: 0 $sp-1; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause,
.cf__btn { transition: none !important; }
.cf__pause:hover,
.cf__pause:active,
.cf__btn:hover,
.cf__btn: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);
}
.cf {
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;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
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);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
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;
}
.cf__pick {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
}
.cf__btn {
padding: 0 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__btn:hover {
transform: translateY(-2px);
}
.cf__btn:active {
transform: translateY(1px);
}
.cf__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status,
.cf__btn {
font-size: 10px;
padding: 4px 8px;
}
.cf__btn {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause,
.cf__btn {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active,
.cf__btn:hover,
.cf__btn:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
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 smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 1/60초마다 ease 만큼 다가가는 비율을 dt 초로 환산 — 1 − (1 − ease)^(dt × 60)
function easeStep(ease, dt) { return 1 - Math.pow(1 - ease, dt * 60); }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (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;
};
}
// 지수 간격 — 평균 mean 인 도착 간격. u 는 0 이상 1 미만 난수
function expGap(u, mean) { return -Math.log(1 - u) * mean; }
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// ── 부채꼴 객석 (화면 좌표) ───────────────────────────────────
// 무대 중심(HALL_CX, HALL_CY)에서 줄 i 의 반지름 r = ROW_R0 + i × ROW_GAP. 각도 0 = 무대에서 똑바로 아래, 왼쪽이 음수
const HALL_CX = 240;
const HALL_CY = 18;
const HALL_ROWS = 10;
const ROW_R0 = 62;
const ROW_GAP = 18;
const FAN_HALF = 0.98;
// 통로 두 줄기의 각도 — 통로 폭 AISLE_W(px)는 줄마다 AISLE_W / r 라디안의 틈이 된다
const AISLE_ANGLES = [-0.34, 0.34];
const AISLE_W = 14;
// 좌석 하나가 호 위에서 차지하는 길이(px)
const SEAT_W = 16;
const ROW_LETTERS = 'ABCDEFGHJKLMNPQRST';
function seatLabel(row, k) { return ROW_LETTERS.charAt(row) + '-' + pad2(k + 1); }
// 줄마다 — 부채꼴 전체 각을 통로 틈으로 끊은 구간마다 호 길이(각 × 반지름)를 좌석 폭으로 나눈 몫이 그 구간 자리 수,
// 자리 중심은 구간 각을 자리 수로 똑같이 나눈 칸의 가운데
function buildHall(rows, r0, gap, seatW, aisleW) {
const seats = [];
const rowStart = [];
const rowCount = [];
const segs = [];
for (let i = 0; i < rows; i += 1) {
const r = r0 + i * gap;
const bounds = [-FAN_HALF];
for (const a of AISLE_ANGLES) {
const half = aisleW / 2 / r;
bounds.push(a - half, a + half);
}
bounds.push(FAN_HALF);
rowStart.push(seats.length);
let k = 0;
for (let s = 0; s + 1 < bounds.length; s += 2) {
const a0 = bounds[s];
const span = bounds[s + 1] - a0;
const n = Math.floor(span * r / seatW);
const seg = [];
for (let j = 0; j < n; j += 1) {
const ang = a0 + (j + 0.5) * span / n;
seg.push(seats.length);
seats.push({ row: i, k: k, ang: ang, r: r, x: HALL_CX + r * Math.sin(ang), y: HALL_CY + r * Math.cos(ang), label: seatLabel(i, k), seg: segs.length });
k += 1;
}
segs.push(seg);
}
rowCount.push(k);
}
return { seats: seats, rowStart: rowStart, rowCount: rowCount, segs: segs };
}
const HALL = buildHall(HALL_ROWS, ROW_R0, ROW_GAP, SEAT_W, AISLE_W);
// 옆 자리·앞뒤 줄로 옮기기 — 앞뒤 줄은 각도가 가장 가까운 자리
function hallNeighbor(idx, dk, dr) {
const s = HALL.seats[idx];
if (dr !== 0) {
const row = clamp(s.row + dr, 0, HALL_ROWS - 1);
let best = HALL.rowStart[row];
for (let j = 0; j < HALL.rowCount[row]; j += 1) {
const c = HALL.rowStart[row] + j;
if (Math.abs(HALL.seats[c].ang - s.ang) < Math.abs(HALL.seats[best].ang - s.ang)) {
best = c;
}
}
return best;
}
const k = clamp(s.k + dk, 0, HALL.rowCount[s.row] - 1);
return HALL.rowStart[s.row] + k;
}
function findSeat(label) {
for (let i = 0; i < HALL.seats.length; i += 1) {
if (HALL.seats[i].label === label) {
return i;
}
}
return 0;
}
// ── 시야 (단면, 미터) ────────────────────────────────────────
// 줄 i 는 무대 앞끝에서 rowDist(i) 떨어져 있고 바닥이 i × rake 만큼 높다. 앉은 눈높이는 그 바닥 + EYE_H
const FIRST_ROW_M = 2.4;
const ROW_DEPTH_M = 0.95;
const RAKE_STEP = 0.12;
const EYE_H = 1.15;
const STAGE_H = 0.95;
const STAGE_W_M = 8;
const STAGE_D_M = 5;
// 앞사람 눈 위로 시선이 이만큼(미터) 지나가면 머리에 가리지 않는다고 본다
const C_GOOD = 0.12;
const DIST_NEAR_M = 4;
const DIST_FAR_M = 12;
// 무대 폭이 눈에 차지하는 각 — 이 각을 넘으면 고개를 돌려야 무대 양끝이 보인다
const FOV_OK_DEG = 50;
const FOV_BAD_DEG = 100;
// 무대 앞끝을 내려다보거나 올려다보는 각
const PITCH_OK_DEG = 8;
const PITCH_BAD_DEG = 28;
// 정면에서 벗어난 각(라디안)
const SIDE_OK = 0.25;
const SIDE_BAD = 1.0;
// 점수 무게 — 틈 · 거리 · 무대 폭 · 높낮이 · 옆 각 (합 1)
const SCORE_WEIGHTS = [0.3, 0.1, 0.35, 0.1, 0.15];
function rowDist(i) { return FIRST_ROW_M + i * ROW_DEPTH_M; }
function eyeY(i, rake) { return i * rake + EYE_H; }
// 틈(미터) — 줄 i 의 눈에서 무대 앞끝(높이 stageH)까지 그은 직선이 앞줄 자리(거리 rowDist(i−1))를 지날 때의 높이 − 앞줄 눈높이.
// 직선 높이 = stageH + (내 눈 − stageH) × (앞줄 거리 / 내 거리). 맨 앞줄은 앞사람이 없으므로 C_GOOD 로 친다
function clearance(i, stageH, rake) {
if (i <= 0) {
return C_GOOD;
}
const e = eyeY(i, rake);
const lineAt = stageH + (e - stageH) * (rowDist(i - 1) / rowDist(i));
return lineAt - eyeY(i - 1, rake);
}
function viewOf(row, ang, stageH, rake) {
const d = rowDist(row);
const c = clearance(row, stageH, rake);
const fov = 2 * Math.atan(STAGE_W_M / 2 / d) * 180 / Math.PI;
const pitch = Math.atan2(eyeY(row, rake) - stageH, d) * 180 / Math.PI;
const parts = [
clamp(c / C_GOOD, 0, 1),
1 - smooth((d - DIST_NEAR_M) / (DIST_FAR_M - DIST_NEAR_M)),
1 - smooth((fov - FOV_OK_DEG) / (FOV_BAD_DEG - FOV_OK_DEG)),
1 - smooth((Math.abs(pitch) - PITCH_OK_DEG) / (PITCH_BAD_DEG - PITCH_OK_DEG)),
1 - smooth((Math.abs(ang) - SIDE_OK) / (SIDE_BAD - SIDE_OK))
];
let score = 0;
for (let k = 0; k < parts.length; k += 1) {
score += SCORE_WEIGHTS[k] * parts[k];
}
return { clear: c, fov: fov, pitch: pitch, parts: parts, score: score };
}
function hallScores(stageH, rake) {
return HALL.seats.map(function (s) { return viewOf(s.row, s.ang, stageH, rake).score; });
}
// ── 원근 투영 (미터) ─────────────────────────────────────────
// 무대 앞끝 가운데가 원점. x right, y 위, z 객석 쪽. 자리의 눈 = (ρ sin 각, 눈높이, ρ cos 각), ρ = rowDist(줄)
function v3sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function v3dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function v3cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function v3norm(a) { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function v3lerp(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const NEAR_M = 0.15;
// 무대 위 이 점을 바라본다 — 배우 가슴 높이쯤
const LOOK_AT = [0, STAGE_H + 1.1, -1.6];
function seatEye(row, ang, rake) {
const rho = rowDist(row);
return [rho * Math.sin(ang), eyeY(row, rake), rho * Math.cos(ang)];
}
function lookAt(eye, target, focal, cx, cy) {
const fwd = v3norm(v3sub(target, eye));
const right = v3norm(v3cross(fwd, [0, 1, 0]));
const up = v3cross(right, fwd);
return { eye: eye, right: right, up: up, fwd: fwd, focal: focal, cx: cx, cy: cy };
}
// 투영 — 카메라 기준 깊이 z 로 나눈다: x' = cx + f·x/z, y' = cy − f·y/z. 깊이가 NEAR_M 보다 가까우면 깊이를 음수로 돌려 버리게 한다
function project(cam, p) {
const rel = v3sub(p, cam.eye);
const z = v3dot(rel, cam.fwd);
if (z < NEAR_M) {
return [0, 0, -1];
}
return [cam.cx + cam.focal * v3dot(rel, cam.right) / z, cam.cy - cam.focal * v3dot(rel, cam.up) / z, z];
}
// 앞줄 머리 — 눈보다 HEAD_UP 위에 중심, 반지름 가로 HEAD_RX · 세로 HEAD_RY (미터)
const HEAD_UP = 0.06;
const HEAD_RX = 0.1;
const HEAD_RY = 0.13;
function headsAhead(cam, row, rake) {
const out = [];
for (const s of HALL.seats) {
if (s.row >= row) {
continue;
}
const e = seatEye(s.row, s.ang, rake);
const p = project(cam, [e[0], e[1] + HEAD_UP, e[2]]);
if (p[2] <= 0) {
continue;
}
out.push({ sx: p[0], sy: p[1], rx: cam.focal * HEAD_RX / p[2], ry: cam.focal * HEAD_RY / p[2], z: p[2] });
}
return out;
}
// 무대 가림 — 무대 앞끝 선과 배우 가슴 높이 선에서 STAGE_SAMPLES 점씩 찍어, 더 가까운 머리 타원 안에 든 점의 비율
const STAGE_SAMPLES = 41;
function stageOcclusion(cam, heads) {
let hidden = 0;
let total = 0;
const lines = [[STAGE_H, 0], [LOOK_AT[1], LOOK_AT[2]]];
for (const ln of lines) {
for (let k = 0; k < STAGE_SAMPLES; k += 1) {
const x = lerp(-STAGE_W_M / 2, STAGE_W_M / 2, k / (STAGE_SAMPLES - 1));
const p = project(cam, [x, ln[0], ln[1]]);
if (p[2] <= 0 || p[0] < 0 || p[0] > W || p[1] < 0 || p[1] > H) {
continue;
}
total += 1;
for (const h of heads) {
if (h.z >= p[2]) {
continue;
}
const dx = (p[0] - h.sx) / h.rx;
const dy = (p[1] - h.sy) / h.ry;
if (dx * dx + dy * dy <= 1) {
hidden += 1;
break;
}
}
}
}
return total ? hidden / total : 0;
}
// ── 영화관 격자 (04 · 05) ────────────────────────────────────
// 줄 CIN_ROWS × 열 CIN_COLS, 통로는 CIN_AISLES 열 앞. 자리 번호 = 줄 × CIN_COLS + 열
const CIN_ROWS = 8;
const CIN_COLS = 14;
const CIN_AISLES = [3, 11];
const CELL_W = 22;
const CELL_H = 18;
const CELL_GAP = 4;
const AISLE_PX = 16;
const GRID_TOP = 62;
// 가장 좋은 줄과 그 앞뒤로 점수가 0 이 되는 줄 수
const BEST_ROW = 5;
const ROW_SPREAD = 6;
function cinX(c) {
let aisles = 0;
for (const a of CIN_AISLES) {
if (c >= a) {
aisles += 1;
}
}
const total = CIN_COLS * (CELL_W + CELL_GAP) - CELL_GAP + CIN_AISLES.length * AISLE_PX;
return (W - total) / 2 + c * (CELL_W + CELL_GAP) + aisles * AISLE_PX;
}
function cinY(r) { return GRID_TOP + r * (CELL_H + CELL_GAP); }
// 격자 점수 — 줄은 BEST_ROW 에서 멀수록 제곱으로, 열은 가운데에서 멀수록 제곱으로 깎는다
function gridScore(r, c) {
const rowK = 1 - Math.pow((r - BEST_ROW) / ROW_SPREAD, 2);
const mid = (CIN_COLS - 1) / 2;
const colK = 1 - 0.7 * Math.pow((c - mid) / mid, 2);
return clamp(rowK, 0, 1) * colK;
}
function gridSegments() {
const out = [];
for (let r = 0; r < CIN_ROWS; r += 1) {
let seg = [];
for (let c = 0; c < CIN_COLS; c += 1) {
if (CIN_AISLES.indexOf(c) >= 0 && seg.length) {
out.push(seg);
seg = [];
}
seg.push(r * CIN_COLS + c);
}
out.push(seg);
}
return out;
}
function gridLabel(id) { return ROW_LETTERS.charAt(Math.floor(id / CIN_COLS)) + '-' + pad2(id % CIN_COLS + 1); }
function findBlocks(segs, free, score, size) {
const out = [];
segs.forEach(function (seg, si) {
for (let s = 0; s + size <= seg.length; s += 1) {
const ids = seg.slice(s, s + size);
if (!ids.every(function (id) { return free[id]; })) {
continue;
}
let sum = 0;
for (const id of ids) {
sum += score[id];
}
let orphan = -1;
const L = s - 1;
if (L >= 0 && free[seg[L]] && (L - 1 < 0 || !free[seg[L - 1]])) {
orphan = seg[L];
}
const R = s + size;
if (R < seg.length && free[seg[R]] && (R + 1 >= seg.length || !free[seg[R + 1]])) {
orphan = seg[R];
}
out.push({ seg: si, start: s, ids: ids, sum: sum, orphan: orphan });
}
});
return out;
}
// 가장 높은 후보 — allowOrphan 이 거짓이면 외톨이 빈칸을 남기는 후보는 건너뛴다. 없으면 −1
function bestBlock(blocks, allowOrphan) {
let best = -1;
blocks.forEach(function (b, i) {
if (!allowOrphan && b.orphan >= 0) {
return;
}
if (best < 0 || b.sum > blocks[best].sum + 1e-9) {
best = i;
}
});
return best;
}
// 좌석 상태 — 색만이 아니라 무늬로도 갈린다. 빈자리 = 연한 바탕 + 진한 테두리, 팔린 자리 = 회색 + 빗금, 잡아 둔 자리 = 버터 + 굵은 테두리, 고른 자리 = 민트 + 체크
const SEAT_FREE = 0;
const SEAT_SOLD = 1;
const SEAT_HELD = 2;
const SEAT_MINE = 3;
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 팔린 자리·글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [241, 234, 220];
const GRID = [226, 218, 204];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [190, 174, 236];
const MINT = [150, 212, 186];
const BUTTER = [244, 220, 140];
const CORAL = [242, 160, 140];
const SKY = [160, 200, 236];
const PINK = [242, 182, 206];
const SOLD = [176, 168, 186];
const MINT_DEEP = [36, 120, 92];
const CORAL_DEEP = [186, 70, 56];
const VIOLET_DEEP = [86, 56, 168];
// 무대 조명 — 따뜻한 미색
const STAGE_LIGHT = [252, 236, 204];
const STAGE_WOOD = [214, 182, 150];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 점수 색 — 0 은 차가운 하늘, 0.5 는 연한 미색, 1 은 따뜻한 살구
const SCORE_COLD = [150, 182, 228];
const SCORE_MID = [238, 228, 204];
const SCORE_WARM = [244, 160, 120];
function scoreColor(v) {
const c = clamp(v, 0, 1);
return c < 0.5 ? mix(SCORE_COLD, SCORE_MID, c * 2) : mix(SCORE_MID, SCORE_WARM, (c - 0.5) * 2);
}
// 점수 0~1 을 화면 글자 0~100 으로
function pct(v) { return Math.round(clamp(v, 0, 1) * 100); }
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) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function label(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 roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function hatchIn(w, h, spacing, c, a) {
ctx.beginPath();
for (let s = -h; s < w; s += spacing) {
ctx.moveTo(s, h);
ctx.lineTo(s + h, 0);
}
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function checkIn(w, h, c, a) {
ctx.beginPath();
ctx.moveTo(w * 0.24, h * 0.52);
ctx.lineTo(w * 0.43, h * 0.72);
ctx.lineTo(w * 0.78, h * 0.3);
ctx.lineWidth = Math.max(1.4, Math.min(w, h) * 0.14);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 자리 하나 — 가운데 (x, y), 폭 w · 높이 h, 회전 rot(라디안), 바탕색 fill. 등받이는 위쪽(무대 반대쪽)에 얇은 띠로
function drawSeat(x, y, w, h, rot, state, fill, a) {
if (a <= 0.01 || w < 0.5) {
return;
}
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.translate(-w / 2, -h / 2);
const base = state === SEAT_SOLD ? SOLD : fill;
roundRectPath(0, 0, w, h, Math.min(w, h) * 0.3);
ctx.fillStyle = rgba(base, a);
ctx.fill();
if (state === SEAT_SOLD) {
ctx.save();
ctx.clip();
hatchIn(w, h, Math.max(2.5, w / 3.2), INK, 0.55 * a);
ctx.restore();
}
roundRectPath(0, 0, w, h, Math.min(w, h) * 0.3);
ctx.lineWidth = state === SEAT_HELD ? 2 : (state === SEAT_MINE ? 1.6 : 1.1);
ctx.strokeStyle = rgba(state === SEAT_MINE ? MINT_DEEP : INK, (state === SEAT_SOLD ? 0.4 : 0.8) * a);
ctx.stroke();
if (state === SEAT_MINE) {
checkIn(w, h, MINT_DEEP, a);
}
// 등받이
if (h >= 8) {
ctx.fillStyle = rgba(INK, 0.16 * a);
ctx.fillRect(w * 0.18, h - Math.max(2, h * 0.16), w * 0.64, Math.max(1.5, h * 0.12));
}
ctx.restore();
}
// 남은 시간 고리 — 자리 둘레에 남은 몫(left 0~1)만큼 시계 방향 호
function holdRing(x, y, r, left, c, a) {
if (left <= 0 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, -Math.PI / 2, -Math.PI / 2 + TAU * clamp(left, 0, 1));
ctx.lineWidth = 2.4;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 무대 — 위쪽 가운데 둥근 띠와 따뜻한 조명 번짐
function drawStageArc(cx, cy, r, glow) {
const g = ctx.createRadialGradient(cx, cy, 4, cx, cy, r * 3.2);
g.addColorStop(0, rgba(STAGE_LIGHT, 0.95 * glow));
g.addColorStop(1, rgba(STAGE_LIGHT, 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, cy + r * 3.2);
ctx.beginPath();
ctx.ellipse(cx, cy - 6, r * 1.5, r * 0.62, 0, 0, Math.PI);
ctx.closePath();
ctx.fillStyle = rgba(STAGE_WOOD, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
}
function polyline(pts, c, a, width) {
if (pts.length < 4) {
return;
}
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) {
ctx.lineTo(pts[i], pts[i + 1]);
}
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 알약 이름표
function pill(s, x, y, bg, fg, a, px) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
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();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 06 — 관성 경기장 지도. 화면 = 오프셋 + 배율 × 지도 좌표. 확대는 기준점(ANCHOR)이 화면의 같은 자리에 남도록 오프셋을 함께 고치고,
// 놓은 뒤 이동은 1/60초마다 FRICTION 을 곱해 줄어든다. 배율이 SPLIT_FROM ~ SPLIT_TO 를 지나면 구역 사각형이 줄·자리로 갈라진다
const SECTIONS = 24;
const SECT_GAP = 0.02;
const R_IN = 58;
const R_OUT = 118;
// 가로로 늘린 비율 — 타원 경기장
const STRETCH = 1.55;
const SECT_ROWS = 30;
// 줄 위 자리 간격(지도 단위)
const SEAT_PITCH = 1.5;
const MAP_SEED = 606;
const SOLD_SHARE06 = 0.52;
const ZOOM_IN_AT_S = 0.2;
const ZOOM_MAX = 7;
const ZOOM_EASE = 0.06;
const ANCHOR = [330, 82];
const FLING_AT_S = 1.35;
const FLING_V = [-300, 150];
const FRICTION = 0.92;
const ZOOM_OUT_AT_S = 3.6;
const SPLIT_FROM = 2.2;
const SPLIT_TO = 4.0;
const HOME = [W / 2, H / 2];
const LOOP_S = 5.6;
const STATUS_MAP = 'Zoom ×{s} · seats drawn {a} / total {b}';
// 자리 지도 좌표는 한 줄짜리 배열 두 개에 — 1만 석을 객체 1만 개로 만들지 않는다
const seatX06 = [];
const seatY06 = [];
const seatSold06 = [];
const sections06 = [];
function ellipsePoint(rho, th) { return [rho * Math.cos(th) * STRETCH, rho * Math.sin(th)]; }
(function buildStadium() {
const rng = makeRng(MAP_SEED);
const span = TAU / SECTIONS;
for (let s = 0; s < SECTIONS; s += 1) {
const a0 = s * span + SECT_GAP;
const a1 = (s + 1) * span - SECT_GAP;
const first = seatX06.length;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let r = 0; r < SECT_ROWS; r += 1) {
const rho = R_IN + 2 + (r + 0.5) * (R_OUT - R_IN - 4) / SECT_ROWS;
const mid = (a0 + a1) / 2;
// 그 줄의 호 길이 — 타원이라 각도마다 늘어난 정도가 다르다
const len = (a1 - a0) * rho * Math.hypot(STRETCH * Math.sin(mid), Math.cos(mid));
const n = Math.floor(len / SEAT_PITCH);
for (let j = 0; j < n; j += 1) {
const p = ellipsePoint(rho, a0 + (j + 0.5) * (a1 - a0) / n);
seatX06.push(p[0]);
seatY06.push(p[1]);
seatSold06.push(rng() < SOLD_SHARE06);
minX = Math.min(minX, p[0]);
minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]);
maxY = Math.max(maxY, p[1]);
}
}
sections06.push({ a0: a0, a1: a1, first: first, count: seatX06.length - first, box: [minX, minY, maxX, maxY] });
}
})();
const TOTAL_SEATS = seatX06.length;
let scale06 = 1;
let ox06 = HOME[0];
let oy06 = HOME[1];
let vx06 = 0;
let vy06 = 0;
let flung06 = false;
let drawn06 = 0;
// 배율은 로그 눈금으로 다가간다 — ×1→×2 와 ×3.5→×7 이 같은 시간이 걸려 확대가 일정한 빠르기로 느껴진다
function logToward(s, target, k) { return Math.exp(lerp(Math.log(s), Math.log(target), k)); }
function split06() { return smooth((scale06 - SPLIT_FROM) / (SPLIT_TO - SPLIT_FROM)); }
// 기준점 고정 확대 — 새 오프셋 = 기준점 − (기준점 − 오프셋) × 새 배율 / 옛 배율
function zoomAbout(ax, ay, next) {
ox06 = ax - (ax - ox06) * next / scale06;
oy06 = ay - (ay - oy06) * next / scale06;
scale06 = next;
}
// 화면에 걸친 구역만 훑고, 그 안에서도 화면 밖 자리는 건너뛴다. 돌려주는 값 = 넘겨준 자리 수
function forVisibleSeats(s, ox, oy, cull, cb) {
let n = 0;
const pad = SEAT_PITCH * s;
for (const sec of sections06) {
if (cull) {
const x0 = ox + sec.box[0] * s - pad;
const y0 = oy + sec.box[1] * s - pad;
const x1 = ox + sec.box[2] * s + pad;
const y1 = oy + sec.box[3] * s + pad;
if (x1 < 0 || y1 < 0 || x0 > W || y0 > H) {
continue;
}
}
for (let i = sec.first; i < sec.first + sec.count; i += 1) {
const x = ox + seatX06[i] * s;
const y = oy + seatY06[i] * s;
if (cull && (x < -pad || y < -pad || x > W + pad || y > H + pad)) {
continue;
}
n += 1;
cb(i, x, y);
}
}
return n;
}
function reset() {
scale06 = 1;
ox06 = HOME[0];
oy06 = HOME[1];
vx06 = 0;
vy06 = 0;
flung06 = false;
}
function statusFor06() {
simStatus = STATUS_MAP.replace('{s}', scale06.toFixed(1)).replace('{a}', String(drawn06)).replace('{b}', String(TOTAL_SEATS));
}
function countDrawn06() {
drawn06 = split06() > 0.01 ? forVisibleSeats(scale06, ox06, oy06, true, function () { }) : 0;
}
function update(dt) {
if (sceneT >= ZOOM_IN_AT_S && sceneT < ZOOM_OUT_AT_S) {
zoomAbout(ANCHOR[0], ANCHOR[1], logToward(scale06, ZOOM_MAX, easeStep(ZOOM_EASE, dt)));
}
else if (sceneT >= ZOOM_OUT_AT_S) {
zoomAbout(W / 2, H / 2, logToward(scale06, 1, easeStep(ZOOM_EASE, dt)));
// 다 작아질 즈음 오프셋을 제자리로
const k = follow(6 * smooth((2 - scale06) / 1), dt);
ox06 = lerp(ox06, HOME[0], k);
oy06 = lerp(oy06, HOME[1], k);
}
if (sceneT >= FLING_AT_S && !flung06) {
vx06 = FLING_V[0];
vy06 = FLING_V[1];
flung06 = true;
}
ox06 += vx06 * dt;
oy06 += vy06 * dt;
const f = Math.pow(FRICTION, dt * 60);
vx06 *= f;
vy06 *= f;
if (Math.hypot(vx06, vy06) < 2) {
vx06 = 0;
vy06 = 0;
}
countDrawn06();
statusFor06();
}
function restPose() {
reset();
zoomAbout(ANCHOR[0], ANCHOR[1], ZOOM_MAX);
countDrawn06();
statusFor06();
}
const FIELD = [176, 220, 190];
const freeShare06 = sections06.map(function (sec) {
let f = 0;
for (let i = sec.first; i < sec.first + sec.count; i += 1) {
if (!seatSold06[i]) {
f += 1;
}
}
return f / sec.count;
});
// 구역 덩어리 — 안쪽·바깥 타원 호를 6점씩 이어 닫은 다각형
function sectionPath(sec) {
ctx.beginPath();
for (let k = 0; k <= 6; k += 1) {
const p = ellipsePoint(R_OUT, lerp(sec.a0, sec.a1, k / 6));
if (k === 0) {
ctx.moveTo(ox06 + p[0] * scale06, oy06 + p[1] * scale06);
}
else {
ctx.lineTo(ox06 + p[0] * scale06, oy06 + p[1] * scale06);
}
}
for (let k = 6; k >= 0; k -= 1) {
const p = ellipsePoint(R_IN, lerp(sec.a0, sec.a1, k / 6));
ctx.lineTo(ox06 + p[0] * scale06, oy06 + p[1] * scale06);
}
ctx.closePath();
}
function draw() {
clearStage(PAPER);
const k = split06();
// 경기장 바닥
ctx.beginPath();
ctx.ellipse(ox06, oy06, (R_IN - 4) * STRETCH * scale06, (R_IN - 4) * scale06, 0, 0, TAU);
ctx.fillStyle = rgba(FIELD, 1);
ctx.fill();
ctx.beginPath();
ctx.ellipse(ox06, oy06, (R_IN - 22) * STRETCH * scale06, (R_IN - 22) * scale06, 0, 0, TAU);
ctx.lineWidth = Math.max(1, 1.2 * scale06);
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.stroke();
// 낮은 배율 — 구역 사각형만 (자리 1만 개를 그리지 않는다)
if (k < 0.99) {
sections06.forEach(function (sec, si) {
sectionPath(sec);
ctx.fillStyle = rgba(mix(SOLD, LAVENDER, freeShare06[si]), 1 - k);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK, 0.4 * (1 - k));
ctx.stroke();
const mid = ellipsePoint((R_IN + R_OUT) / 2, (sec.a0 + sec.a1) / 2);
label(String(si + 101), ox06 + mid[0] * scale06, oy06 + mid[1] * scale06, INK, 1 - k, 9 + Math.min(scale06, 3) * 2, 800, 'center');
});
}
// 높은 배율 — 화면에 걸친 자리만, 빈자리·팔린 자리를 각각 한 경로로 모아 한 번에 칠한다
if (k > 0.01) {
const size = SEAT_PITCH * scale06 * 0.72;
const freePath = new Path2D();
const soldPath = new Path2D();
const slash = new Path2D();
drawn06 = forVisibleSeats(scale06, ox06, oy06, true, function (i, x, y) {
if (seatSold06[i]) {
soldPath.rect(x - size / 2, y - size / 2, size, size);
slash.moveTo(x - size / 2, y + size / 2);
slash.lineTo(x + size / 2, y - size / 2);
}
else {
freePath.rect(x - size / 2, y - size / 2, size, size);
}
});
ctx.globalAlpha = k;
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill(freePath);
ctx.lineWidth = Math.max(0.6, size * 0.12);
ctx.strokeStyle = rgba(INK, 0.75);
ctx.stroke(freePath);
ctx.fillStyle = rgba(SOLD, 1);
ctx.fill(soldPath);
ctx.strokeStyle = rgba(INK, 0.6);
ctx.stroke(slash);
ctx.globalAlpha = 1;
}
// 기준점 — 확대하는 동안 화면의 같은 자리에 남는 점
if (sceneT >= ZOOM_IN_AT_S && sceneT < FLING_AT_S) {
ctx.beginPath();
ctx.arc(ANCHOR[0], ANCHOR[1], 7, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.9);
ctx.stroke();
polyline([ANCHOR[0] - 11, ANCHOR[1], ANCHOR[0] + 11, ANCHOR[1]], VIOLET_DEEP, 0.9, 1.4);
polyline([ANCHOR[0], ANCHOR[1] - 11, ANCHOR[0], ANCHOR[1] + 11], VIOLET_DEEP, 0.9, 1.4);
}
pill('×' + scale06.toFixed(1), 34, 18, INK, CREAM, 1, 10);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
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);
}
draw();
pushStatus();
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();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { 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 {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// StadiumZoomSplit - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./StadiumZoomSplit.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function StadiumZoomSplit() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type V3 = [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 smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 1/60초마다 ease 만큼 다가가는 비율을 dt 초로 환산 — 1 − (1 − ease)^(dt × 60)
function easeStep(ease: number, dt: number): number { return 1 - Math.pow(1 - ease, dt * 60); }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (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;
};
}
// 지수 간격 — 평균 mean 인 도착 간격. u 는 0 이상 1 미만 난수
function expGap(u: number, mean: number): number { return -Math.log(1 - u) * mean; }
function pad2(n: number): string { return n < 10 ? '0' + n : String(n); }
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 부채꼴 객석 (화면 좌표) ───────────────────────────────────
// 무대 중심(HALL_CX, HALL_CY)에서 줄 i 의 반지름 r = ROW_R0 + i × ROW_GAP. 각도 0 = 무대에서 똑바로 아래, 왼쪽이 음수
const HALL_CX = 240;
const HALL_CY = 18;
const HALL_ROWS = 10;
const ROW_R0 = 62;
const ROW_GAP = 18;
const FAN_HALF = 0.98;
// 통로 두 줄기의 각도 — 통로 폭 AISLE_W(px)는 줄마다 AISLE_W / r 라디안의 틈이 된다
const AISLE_ANGLES = [-0.34, 0.34];
const AISLE_W = 14;
// 좌석 하나가 호 위에서 차지하는 길이(px)
const SEAT_W = 16;
const ROW_LETTERS = 'ABCDEFGHJKLMNPQRST';
type Seat = { row: number; k: number; ang: number; r: number; x: number; y: number; label: string; seg: number };
type Hall = { seats: Seat[]; rowStart: number[]; rowCount: number[]; segs: number[][] };
function seatLabel(row: number, k: number): string { return ROW_LETTERS.charAt(row) + '-' + pad2(k + 1); }
// 줄마다 — 부채꼴 전체 각을 통로 틈으로 끊은 구간마다 호 길이(각 × 반지름)를 좌석 폭으로 나눈 몫이 그 구간 자리 수,
// 자리 중심은 구간 각을 자리 수로 똑같이 나눈 칸의 가운데
function buildHall(rows: number, r0: number, gap: number, seatW: number, aisleW: number): Hall {
const seats: Seat[] = [];
const rowStart: number[] = [];
const rowCount: number[] = [];
const segs: number[][] = [];
for (let i = 0; i < rows; i += 1) {
const r = r0 + i * gap;
const bounds: number[] = [-FAN_HALF];
for (const a of AISLE_ANGLES) { const half = aisleW / 2 / r; bounds.push(a - half, a + half); }
bounds.push(FAN_HALF);
rowStart.push(seats.length);
let k = 0;
for (let s = 0; s + 1 < bounds.length; s += 2) {
const a0 = bounds[s];
const span = bounds[s + 1] - a0;
const n = Math.floor(span * r / seatW);
const seg: number[] = [];
for (let j = 0; j < n; j += 1) {
const ang = a0 + (j + 0.5) * span / n;
seg.push(seats.length);
seats.push({ row: i, k: k, ang: ang, r: r, x: HALL_CX + r * Math.sin(ang), y: HALL_CY + r * Math.cos(ang), label: seatLabel(i, k), seg: segs.length });
k += 1;
}
segs.push(seg);
}
rowCount.push(k);
}
return { seats: seats, rowStart: rowStart, rowCount: rowCount, segs: segs };
}
const HALL = buildHall(HALL_ROWS, ROW_R0, ROW_GAP, SEAT_W, AISLE_W);
// 옆 자리·앞뒤 줄로 옮기기 — 앞뒤 줄은 각도가 가장 가까운 자리
function hallNeighbor(idx: number, dk: number, dr: number): number {
const s = HALL.seats[idx];
if (dr !== 0) {
const row = clamp(s.row + dr, 0, HALL_ROWS - 1);
let best = HALL.rowStart[row];
for (let j = 0; j < HALL.rowCount[row]; j += 1) {
const c = HALL.rowStart[row] + j;
if (Math.abs(HALL.seats[c].ang - s.ang) < Math.abs(HALL.seats[best].ang - s.ang)) { best = c; }
}
return best;
}
const k = clamp(s.k + dk, 0, HALL.rowCount[s.row] - 1);
return HALL.rowStart[s.row] + k;
}
function findSeat(label: string): number {
for (let i = 0; i < HALL.seats.length; i += 1) { if (HALL.seats[i].label === label) { return i; } }
return 0;
}
// ── 시야 (단면, 미터) ────────────────────────────────────────
// 줄 i 는 무대 앞끝에서 rowDist(i) 떨어져 있고 바닥이 i × rake 만큼 높다. 앉은 눈높이는 그 바닥 + EYE_H
const FIRST_ROW_M = 2.4;
const ROW_DEPTH_M = 0.95;
const RAKE_STEP = 0.12;
const EYE_H = 1.15;
const STAGE_H = 0.95;
const STAGE_W_M = 8;
const STAGE_D_M = 5;
// 앞사람 눈 위로 시선이 이만큼(미터) 지나가면 머리에 가리지 않는다고 본다
const C_GOOD = 0.12;
const DIST_NEAR_M = 4;
const DIST_FAR_M = 12;
// 무대 폭이 눈에 차지하는 각 — 이 각을 넘으면 고개를 돌려야 무대 양끝이 보인다
const FOV_OK_DEG = 50;
const FOV_BAD_DEG = 100;
// 무대 앞끝을 내려다보거나 올려다보는 각
const PITCH_OK_DEG = 8;
const PITCH_BAD_DEG = 28;
// 정면에서 벗어난 각(라디안)
const SIDE_OK = 0.25;
const SIDE_BAD = 1.0;
// 점수 무게 — 틈 · 거리 · 무대 폭 · 높낮이 · 옆 각 (합 1)
const SCORE_WEIGHTS = [0.3, 0.1, 0.35, 0.1, 0.15];
function rowDist(i: number): number { return FIRST_ROW_M + i * ROW_DEPTH_M; }
function eyeY(i: number, rake: number): number { return i * rake + EYE_H; }
// 틈(미터) — 줄 i 의 눈에서 무대 앞끝(높이 stageH)까지 그은 직선이 앞줄 자리(거리 rowDist(i−1))를 지날 때의 높이 − 앞줄 눈높이.
// 직선 높이 = stageH + (내 눈 − stageH) × (앞줄 거리 / 내 거리). 맨 앞줄은 앞사람이 없으므로 C_GOOD 로 친다
function clearance(i: number, stageH: number, rake: number): number {
if (i <= 0) { return C_GOOD; }
const e = eyeY(i, rake);
const lineAt = stageH + (e - stageH) * (rowDist(i - 1) / rowDist(i));
return lineAt - eyeY(i - 1, rake);
}
type View = { clear: number; fov: number; pitch: number; parts: number[]; score: number };
function viewOf(row: number, ang: number, stageH: number, rake: number): View {
const d = rowDist(row);
const c = clearance(row, stageH, rake);
const fov = 2 * Math.atan(STAGE_W_M / 2 / d) * 180 / Math.PI;
const pitch = Math.atan2(eyeY(row, rake) - stageH, d) * 180 / Math.PI;
const parts = [
clamp(c / C_GOOD, 0, 1),
1 - smooth((d - DIST_NEAR_M) / (DIST_FAR_M - DIST_NEAR_M)),
1 - smooth((fov - FOV_OK_DEG) / (FOV_BAD_DEG - FOV_OK_DEG)),
1 - smooth((Math.abs(pitch) - PITCH_OK_DEG) / (PITCH_BAD_DEG - PITCH_OK_DEG)),
1 - smooth((Math.abs(ang) - SIDE_OK) / (SIDE_BAD - SIDE_OK))
];
let score = 0;
for (let k = 0; k < parts.length; k += 1) { score += SCORE_WEIGHTS[k] * parts[k]; }
return { clear: c, fov: fov, pitch: pitch, parts: parts, score: score };
}
function hallScores(stageH: number, rake: number): number[] {
return HALL.seats.map(function (s: Seat): number { return viewOf(s.row, s.ang, stageH, rake).score; });
}
// ── 원근 투영 (미터) ─────────────────────────────────────────
// 무대 앞끝 가운데가 원점. x 오른쪽, y 위, z 객석 쪽. 자리의 눈 = (ρ sin 각, 눈높이, ρ cos 각), ρ = rowDist(줄)
function v3sub(a: V3, b: V3): V3 { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function v3dot(a: V3, b: V3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function v3cross(a: V3, b: V3): V3 { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function v3norm(a: V3): V3 { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function v3lerp(a: V3, b: V3, k: number): V3 { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
type Cam = { eye: V3; right: V3; up: V3; fwd: V3; focal: number; cx: number; cy: number };
const NEAR_M = 0.15;
// 무대 위 이 점을 바라본다 — 배우 가슴 높이쯤
const LOOK_AT: V3 = [0, STAGE_H + 1.1, -1.6];
function seatEye(row: number, ang: number, rake: number): V3 {
const rho = rowDist(row);
return [rho * Math.sin(ang), eyeY(row, rake), rho * Math.cos(ang)];
}
function lookAt(eye: V3, target: V3, focal: number, cx: number, cy: number): Cam {
const fwd = v3norm(v3sub(target, eye));
const right = v3norm(v3cross(fwd, [0, 1, 0]));
const up = v3cross(right, fwd);
return { eye: eye, right: right, up: up, fwd: fwd, focal: focal, cx: cx, cy: cy };
}
// 투영 — 카메라 기준 깊이 z 로 나눈다: x' = cx + f·x/z, y' = cy − f·y/z. 깊이가 NEAR_M 보다 가까우면 깊이를 음수로 돌려 버리게 한다
function project(cam: Cam, p: V3): V3 {
const rel = v3sub(p, cam.eye);
const z = v3dot(rel, cam.fwd);
if (z < NEAR_M) { return [0, 0, -1]; }
return [cam.cx + cam.focal * v3dot(rel, cam.right) / z, cam.cy - cam.focal * v3dot(rel, cam.up) / z, z];
}
// 앞줄 머리 — 눈보다 HEAD_UP 위에 중심, 반지름 가로 HEAD_RX · 세로 HEAD_RY (미터)
const HEAD_UP = 0.06;
const HEAD_RX = 0.1;
const HEAD_RY = 0.13;
type Head = { sx: number; sy: number; rx: number; ry: number; z: number };
function headsAhead(cam: Cam, row: number, rake: number): Head[] {
const out: Head[] = [];
for (const s of HALL.seats) {
if (s.row >= row) { continue; }
const e = seatEye(s.row, s.ang, rake);
const p = project(cam, [e[0], e[1] + HEAD_UP, e[2]]);
if (p[2] <= 0) { continue; }
out.push({ sx: p[0], sy: p[1], rx: cam.focal * HEAD_RX / p[2], ry: cam.focal * HEAD_RY / p[2], z: p[2] });
}
return out;
}
// 무대 가림 — 무대 앞끝 선과 배우 가슴 높이 선에서 STAGE_SAMPLES 점씩 찍어, 더 가까운 머리 타원 안에 든 점의 비율
const STAGE_SAMPLES = 41;
function stageOcclusion(cam: Cam, heads: Head[]): number {
let hidden = 0;
let total = 0;
const lines: [number, number][] = [[STAGE_H, 0], [LOOK_AT[1], LOOK_AT[2]]];
for (const ln of lines) {
for (let k = 0; k < STAGE_SAMPLES; k += 1) {
const x = lerp(-STAGE_W_M / 2, STAGE_W_M / 2, k / (STAGE_SAMPLES - 1));
const p = project(cam, [x, ln[0], ln[1]]);
if (p[2] <= 0 || p[0] < 0 || p[0] > W || p[1] < 0 || p[1] > H) { continue; }
total += 1;
for (const h of heads) {
if (h.z >= p[2]) { continue; }
const dx = (p[0] - h.sx) / h.rx;
const dy = (p[1] - h.sy) / h.ry;
if (dx * dx + dy * dy <= 1) { hidden += 1; break; }
}
}
}
return total ? hidden / total : 0;
}
// ── 영화관 격자 (04 · 05) ────────────────────────────────────
// 줄 CIN_ROWS × 열 CIN_COLS, 통로는 CIN_AISLES 열 앞. 자리 번호 = 줄 × CIN_COLS + 열
const CIN_ROWS = 8;
const CIN_COLS = 14;
const CIN_AISLES = [3, 11];
const CELL_W = 22;
const CELL_H = 18;
const CELL_GAP = 4;
const AISLE_PX = 16;
const GRID_TOP = 62;
// 가장 좋은 줄과 그 앞뒤로 점수가 0 이 되는 줄 수
const BEST_ROW = 5;
const ROW_SPREAD = 6;
function cinX(c: number): number {
let aisles = 0;
for (const a of CIN_AISLES) { if (c >= a) { aisles += 1; } }
const total = CIN_COLS * (CELL_W + CELL_GAP) - CELL_GAP + CIN_AISLES.length * AISLE_PX;
return (W - total) / 2 + c * (CELL_W + CELL_GAP) + aisles * AISLE_PX;
}
function cinY(r: number): number { return GRID_TOP + r * (CELL_H + CELL_GAP); }
// 격자 점수 — 줄은 BEST_ROW 에서 멀수록 제곱으로, 열은 가운데에서 멀수록 제곱으로 깎는다
function gridScore(r: number, c: number): number {
const rowK = 1 - Math.pow((r - BEST_ROW) / ROW_SPREAD, 2);
const mid = (CIN_COLS - 1) / 2;
const colK = 1 - 0.7 * Math.pow((c - mid) / mid, 2);
return clamp(rowK, 0, 1) * colK;
}
function gridSegments(): number[][] {
const out: number[][] = [];
for (let r = 0; r < CIN_ROWS; r += 1) {
let seg: number[] = [];
for (let c = 0; c < CIN_COLS; c += 1) {
if (CIN_AISLES.indexOf(c) >= 0 && seg.length) { out.push(seg); seg = []; }
seg.push(r * CIN_COLS + c);
}
out.push(seg);
}
return out;
}
function gridLabel(id: number): string { return ROW_LETTERS.charAt(Math.floor(id / CIN_COLS)) + '-' + pad2(id % CIN_COLS + 1); }
// ── 일행 연석 창 ─────────────────────────────────────────────
// 구간(통로·벽 사이 연속 자리)마다 길이 size 인 창을 한 칸씩 밀며, 창 안이 모두 비었으면 후보.
// 외톨이 빈칸 — 창 바로 바깥 자리가 비었는데 그 너머가 팔렸거나 구간 끝이면 그 한 자리는 누구에게도 팔기 어렵다
type Block = { seg: number; start: number; ids: number[]; sum: number; orphan: number };
function findBlocks(segs: number[][], free: boolean[], score: number[], size: number): Block[] {
const out: Block[] = [];
segs.forEach(function (seg: number[], si: number): void {
for (let s = 0; s + size <= seg.length; s += 1) {
const ids = seg.slice(s, s + size);
if (!ids.every(function (id: number): boolean { return free[id]; })) { continue; }
let sum = 0;
for (const id of ids) { sum += score[id]; }
let orphan = -1;
const L = s - 1;
if (L >= 0 && free[seg[L]] && (L - 1 < 0 || !free[seg[L - 1]])) { orphan = seg[L]; }
const R = s + size;
if (R < seg.length && free[seg[R]] && (R + 1 >= seg.length || !free[seg[R + 1]])) { orphan = seg[R]; }
out.push({ seg: si, start: s, ids: ids, sum: sum, orphan: orphan });
}
});
return out;
}
// 가장 높은 후보 — allowOrphan 이 거짓이면 외톨이 빈칸을 남기는 후보는 건너뛴다. 없으면 −1
function bestBlock(blocks: Block[], allowOrphan: boolean): number {
let best = -1;
blocks.forEach(function (b: Block, i: number): void {
if (!allowOrphan && b.orphan >= 0) { return; }
if (best < 0 || b.sum > blocks[best].sum + 1e-9) { best = i; }
});
return best;
}
// 좌석 상태 — 색만이 아니라 무늬로도 갈린다. 빈자리 = 연한 바탕 + 진한 테두리, 팔린 자리 = 회색 + 빗금, 잡아 둔 자리 = 버터 + 굵은 테두리, 고른 자리 = 민트 + 체크
const SEAT_FREE = 0;
const SEAT_SOLD = 1;
const SEAT_HELD = 2;
const SEAT_MINE = 3;
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 팔린 자리·글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [241, 234, 220];
const GRID: RGB = [226, 218, 204];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [190, 174, 236];
const MINT: RGB = [150, 212, 186];
const BUTTER: RGB = [244, 220, 140];
const CORAL: RGB = [242, 160, 140];
const SKY: RGB = [160, 200, 236];
const PINK: RGB = [242, 182, 206];
const SOLD: RGB = [176, 168, 186];
const MINT_DEEP: RGB = [36, 120, 92];
const CORAL_DEEP: RGB = [186, 70, 56];
const VIOLET_DEEP: RGB = [86, 56, 168];
// 무대 조명 — 따뜻한 미색
const STAGE_LIGHT: RGB = [252, 236, 204];
const STAGE_WOOD: RGB = [214, 182, 150];
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)]; }
// 점수 색 — 0 은 차가운 하늘, 0.5 는 연한 미색, 1 은 따뜻한 살구
const SCORE_COLD: RGB = [150, 182, 228];
const SCORE_MID: RGB = [238, 228, 204];
const SCORE_WARM: RGB = [244, 160, 120];
function scoreColor(v: number): RGB {
const c = clamp(v, 0, 1);
return c < 0.5 ? mix(SCORE_COLD, SCORE_MID, c * 2) : mix(SCORE_MID, SCORE_WARM, (c - 0.5) * 2);
}
// 점수 0~1 을 화면 글자 0~100 으로
function pct(v: number): number { return Math.round(clamp(v, 0, 1) * 100); }
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) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
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(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function label(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 roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(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; }
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function hatchIn(w: number, h: number, spacing: number, c: RGB, a: number): void {
ctx.beginPath();
for (let s = -h; s < w; s += spacing) {
ctx.moveTo(s, h);
ctx.lineTo(s + h, 0);
}
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function checkIn(w: number, h: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(w * 0.24, h * 0.52);
ctx.lineTo(w * 0.43, h * 0.72);
ctx.lineTo(w * 0.78, h * 0.3);
ctx.lineWidth = Math.max(1.4, Math.min(w, h) * 0.14);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 자리 하나 — 가운데 (x, y), 폭 w · 높이 h, 회전 rot(라디안), 바탕색 fill. 등받이는 위쪽(무대 반대쪽)에 얇은 띠로
function drawSeat(x: number, y: number, w: number, h: number, rot: number, state: number, fill: RGB, a: number): void {
if (a <= 0.01 || w < 0.5) { return; }
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.translate(-w / 2, -h / 2);
const base = state === SEAT_SOLD ? SOLD : fill;
roundRectPath(0, 0, w, h, Math.min(w, h) * 0.3);
ctx.fillStyle = rgba(base, a);
ctx.fill();
if (state === SEAT_SOLD) {
ctx.save();
ctx.clip();
hatchIn(w, h, Math.max(2.5, w / 3.2), INK, 0.55 * a);
ctx.restore();
}
roundRectPath(0, 0, w, h, Math.min(w, h) * 0.3);
ctx.lineWidth = state === SEAT_HELD ? 2 : (state === SEAT_MINE ? 1.6 : 1.1);
ctx.strokeStyle = rgba(state === SEAT_MINE ? MINT_DEEP : INK, (state === SEAT_SOLD ? 0.4 : 0.8) * a);
ctx.stroke();
if (state === SEAT_MINE) { checkIn(w, h, MINT_DEEP, a); }
// 등받이
if (h >= 8) {
ctx.fillStyle = rgba(INK, 0.16 * a);
ctx.fillRect(w * 0.18, h - Math.max(2, h * 0.16), w * 0.64, Math.max(1.5, h * 0.12));
}
ctx.restore();
}
// 남은 시간 고리 — 자리 둘레에 남은 몫(left 0~1)만큼 시계 방향 호
function holdRing(x: number, y: number, r: number, left: number, c: RGB, a: number): void {
if (left <= 0 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, -Math.PI / 2, -Math.PI / 2 + TAU * clamp(left, 0, 1));
ctx.lineWidth = 2.4;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 무대 — 위쪽 가운데 둥근 띠와 따뜻한 조명 번짐
function drawStageArc(cx: number, cy: number, r: number, glow: number): void {
const g = ctx.createRadialGradient(cx, cy, 4, cx, cy, r * 3.2);
g.addColorStop(0, rgba(STAGE_LIGHT, 0.95 * glow));
g.addColorStop(1, rgba(STAGE_LIGHT, 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, cy + r * 3.2);
ctx.beginPath();
ctx.ellipse(cx, cy - 6, r * 1.5, r * 0.62, 0, 0, Math.PI);
ctx.closePath();
ctx.fillStyle = rgba(STAGE_WOOD, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
}
function polyline(pts: number[], c: RGB, a: number, width: number): void {
if (pts.length < 4) { return; }
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) { ctx.lineTo(pts[i], pts[i + 1]); }
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 알약 이름표
function pill(s: string, x: number, y: number, bg: RGB, fg: RGB, a: number, px: number): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
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();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 06 — 관성 경기장 지도. 화면 = 오프셋 + 배율 × 지도 좌표. 확대는 기준점(ANCHOR)이 화면의 같은 자리에 남도록 오프셋을 함께 고치고,
// 놓은 뒤 이동은 1/60초마다 FRICTION 을 곱해 줄어든다. 배율이 SPLIT_FROM ~ SPLIT_TO 를 지나면 구역 사각형이 줄·자리로 갈라진다
const SECTIONS = 24;
const SECT_GAP = 0.02;
const R_IN = 58;
const R_OUT = 118;
// 가로로 늘린 비율 — 타원 경기장
const STRETCH = 1.55;
const SECT_ROWS = 30;
// 줄 위 자리 간격(지도 단위)
const SEAT_PITCH = 1.5;
const MAP_SEED = 606;
const SOLD_SHARE06 = 0.52;
const ZOOM_IN_AT_S = 0.2;
const ZOOM_MAX = 7;
const ZOOM_EASE = 0.06;
const ANCHOR: [number, number] = [330, 82];
const FLING_AT_S = 1.35;
const FLING_V: [number, number] = [-300, 150];
const FRICTION = 0.92;
const ZOOM_OUT_AT_S = 3.6;
const SPLIT_FROM = 2.2;
const SPLIT_TO = 4.0;
const HOME: [number, number] = [W / 2, H / 2];
const LOOP_S = 5.6;
const STATUS_MAP = '배율 ×{s} · 그린 좌석 {a} / 전체 {b}';
type Section = { a0: number; a1: number; first: number; count: number; box: [number, number, number, number] };
// 자리 지도 좌표는 한 줄짜리 배열 두 개에 — 1만 석을 객체 1만 개로 만들지 않는다
const seatX06: number[] = [];
const seatY06: number[] = [];
const seatSold06: boolean[] = [];
const sections06: Section[] = [];
function ellipsePoint(rho: number, th: number): [number, number] { return [rho * Math.cos(th) * STRETCH, rho * Math.sin(th)]; }
(function buildStadium(): void {
const rng = makeRng(MAP_SEED);
const span = TAU / SECTIONS;
for (let s = 0; s < SECTIONS; s += 1) {
const a0 = s * span + SECT_GAP;
const a1 = (s + 1) * span - SECT_GAP;
const first = seatX06.length;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let r = 0; r < SECT_ROWS; r += 1) {
const rho = R_IN + 2 + (r + 0.5) * (R_OUT - R_IN - 4) / SECT_ROWS;
const mid = (a0 + a1) / 2;
// 그 줄의 호 길이 — 타원이라 각도마다 늘어난 정도가 다르다
const len = (a1 - a0) * rho * Math.hypot(STRETCH * Math.sin(mid), Math.cos(mid));
const n = Math.floor(len / SEAT_PITCH);
for (let j = 0; j < n; j += 1) {
const p = ellipsePoint(rho, a0 + (j + 0.5) * (a1 - a0) / n);
seatX06.push(p[0]);
seatY06.push(p[1]);
seatSold06.push(rng() < SOLD_SHARE06);
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
}
}
sections06.push({ a0: a0, a1: a1, first: first, count: seatX06.length - first, box: [minX, minY, maxX, maxY] });
}
})();
const TOTAL_SEATS = seatX06.length;
let scale06 = 1;
let ox06 = HOME[0];
let oy06 = HOME[1];
let vx06 = 0;
let vy06 = 0;
let flung06 = false;
let drawn06 = 0;
// 배율은 로그 눈금으로 다가간다 — ×1→×2 와 ×3.5→×7 이 같은 시간이 걸려 확대가 일정한 빠르기로 느껴진다
function logToward(s: number, target: number, k: number): number { return Math.exp(lerp(Math.log(s), Math.log(target), k)); }
function split06(): number { return smooth((scale06 - SPLIT_FROM) / (SPLIT_TO - SPLIT_FROM)); }
// 기준점 고정 확대 — 새 오프셋 = 기준점 − (기준점 − 오프셋) × 새 배율 / 옛 배율
function zoomAbout(ax: number, ay: number, next: number): void {
ox06 = ax - (ax - ox06) * next / scale06;
oy06 = ay - (ay - oy06) * next / scale06;
scale06 = next;
}
// 화면에 걸친 구역만 훑고, 그 안에서도 화면 밖 자리는 건너뛴다. 돌려주는 값 = 넘겨준 자리 수
function forVisibleSeats(s: number, ox: number, oy: number, cull: boolean, cb: (i: number, x: number, y: number) => void): number {
let n = 0;
const pad = SEAT_PITCH * s;
for (const sec of sections06) {
if (cull) {
const x0 = ox + sec.box[0] * s - pad;
const y0 = oy + sec.box[1] * s - pad;
const x1 = ox + sec.box[2] * s + pad;
const y1 = oy + sec.box[3] * s + pad;
if (x1 < 0 || y1 < 0 || x0 > W || y0 > H) { continue; }
}
for (let i = sec.first; i < sec.first + sec.count; i += 1) {
const x = ox + seatX06[i] * s;
const y = oy + seatY06[i] * s;
if (cull && (x < -pad || y < -pad || x > W + pad || y > H + pad)) { continue; }
n += 1;
cb(i, x, y);
}
}
return n;
}
function reset(): void {
scale06 = 1;
ox06 = HOME[0];
oy06 = HOME[1];
vx06 = 0;
vy06 = 0;
flung06 = false;
}
function statusFor06(): void {
simStatus = STATUS_MAP.replace('{s}', scale06.toFixed(1)).replace('{a}', String(drawn06)).replace('{b}', String(TOTAL_SEATS));
}
function countDrawn06(): void {
drawn06 = split06() > 0.01 ? forVisibleSeats(scale06, ox06, oy06, true, function (): void { /* 세기만 */ }) : 0;
}
function update(dt: number): void {
if (sceneT >= ZOOM_IN_AT_S && sceneT < ZOOM_OUT_AT_S) {
zoomAbout(ANCHOR[0], ANCHOR[1], logToward(scale06, ZOOM_MAX, easeStep(ZOOM_EASE, dt)));
} else if (sceneT >= ZOOM_OUT_AT_S) {
zoomAbout(W / 2, H / 2, logToward(scale06, 1, easeStep(ZOOM_EASE, dt)));
// 다 작아질 즈음 오프셋을 제자리로
const k = follow(6 * smooth((2 - scale06) / 1), dt);
ox06 = lerp(ox06, HOME[0], k);
oy06 = lerp(oy06, HOME[1], k);
}
if (sceneT >= FLING_AT_S && !flung06) { vx06 = FLING_V[0]; vy06 = FLING_V[1]; flung06 = true; }
ox06 += vx06 * dt;
oy06 += vy06 * dt;
const f = Math.pow(FRICTION, dt * 60);
vx06 *= f;
vy06 *= f;
if (Math.hypot(vx06, vy06) < 2) { vx06 = 0; vy06 = 0; }
countDrawn06();
statusFor06();
}
function restPose(): void {
reset();
zoomAbout(ANCHOR[0], ANCHOR[1], ZOOM_MAX);
countDrawn06();
statusFor06();
}
const FIELD: RGB = [176, 220, 190];
const freeShare06: number[] = sections06.map(function (sec: Section): number {
let f = 0;
for (let i = sec.first; i < sec.first + sec.count; i += 1) { if (!seatSold06[i]) { f += 1; } }
return f / sec.count;
});
// 구역 덩어리 — 안쪽·바깥 타원 호를 6점씩 이어 닫은 다각형
function sectionPath(sec: Section): void {
ctx.beginPath();
for (let k = 0; k <= 6; k += 1) {
const p = ellipsePoint(R_OUT, lerp(sec.a0, sec.a1, k / 6));
if (k === 0) { ctx.moveTo(ox06 + p[0] * scale06, oy06 + p[1] * scale06); } else { ctx.lineTo(ox06 + p[0] * scale06, oy06 + p[1] * scale06); }
}
for (let k = 6; k >= 0; k -= 1) {
const p = ellipsePoint(R_IN, lerp(sec.a0, sec.a1, k / 6));
ctx.lineTo(ox06 + p[0] * scale06, oy06 + p[1] * scale06);
}
ctx.closePath();
}
function draw(): void {
clearStage(PAPER);
const k = split06();
// 경기장 바닥
ctx.beginPath();
ctx.ellipse(ox06, oy06, (R_IN - 4) * STRETCH * scale06, (R_IN - 4) * scale06, 0, 0, TAU);
ctx.fillStyle = rgba(FIELD, 1);
ctx.fill();
ctx.beginPath();
ctx.ellipse(ox06, oy06, (R_IN - 22) * STRETCH * scale06, (R_IN - 22) * scale06, 0, 0, TAU);
ctx.lineWidth = Math.max(1, 1.2 * scale06);
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.stroke();
// 낮은 배율 — 구역 사각형만 (자리 1만 개를 그리지 않는다)
if (k < 0.99) {
sections06.forEach(function (sec: Section, si: number): void {
sectionPath(sec);
ctx.fillStyle = rgba(mix(SOLD, LAVENDER, freeShare06[si]), 1 - k);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK, 0.4 * (1 - k));
ctx.stroke();
const mid = ellipsePoint((R_IN + R_OUT) / 2, (sec.a0 + sec.a1) / 2);
label(String(si + 101), ox06 + mid[0] * scale06, oy06 + mid[1] * scale06, INK, 1 - k, 9 + Math.min(scale06, 3) * 2, 800, 'center');
});
}
// 높은 배율 — 화면에 걸친 자리만, 빈자리·팔린 자리를 각각 한 경로로 모아 한 번에 칠한다
if (k > 0.01) {
const size = SEAT_PITCH * scale06 * 0.72;
const freePath = new Path2D();
const soldPath = new Path2D();
const slash = new Path2D();
drawn06 = forVisibleSeats(scale06, ox06, oy06, true, function (i: number, x: number, y: number): void {
if (seatSold06[i]) {
soldPath.rect(x - size / 2, y - size / 2, size, size);
slash.moveTo(x - size / 2, y + size / 2);
slash.lineTo(x + size / 2, y - size / 2);
} else {
freePath.rect(x - size / 2, y - size / 2, size, size);
}
});
ctx.globalAlpha = k;
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill(freePath);
ctx.lineWidth = Math.max(0.6, size * 0.12);
ctx.strokeStyle = rgba(INK, 0.75);
ctx.stroke(freePath);
ctx.fillStyle = rgba(SOLD, 1);
ctx.fill(soldPath);
ctx.strokeStyle = rgba(INK, 0.6);
ctx.stroke(slash);
ctx.globalAlpha = 1;
}
// 기준점 — 확대하는 동안 화면의 같은 자리에 남는 점
if (sceneT >= ZOOM_IN_AT_S && sceneT < FLING_AT_S) {
ctx.beginPath();
ctx.arc(ANCHOR[0], ANCHOR[1], 7, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.9);
ctx.stroke();
polyline([ANCHOR[0] - 11, ANCHOR[1], ANCHOR[0] + 11, ANCHOR[1]], VIOLET_DEEP, 0.9, 1.4);
polyline([ANCHOR[0], ANCHOR[1] - 11, ANCHOR[0], ANCHOR[1] + 11], VIOLET_DEEP, 0.9, 1.4);
}
pill('×' + scale06.toFixed(1), 34, 18, INK, CREAM, 1, 10);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
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); }
draw();
pushStatus();
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();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { 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 { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="타원 경기장 지도가 한 점을 붙잡은 채 확대되고, 배율이 문턱을 넘자 구역 덩어리가 줄과 자리로 갈라지며 놓은 뒤에도 미끄러지다 멈춘다."></canvas>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'경기장 전체'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 A Stadium Map with Inertia Whose Sections Split into Seats as It Zooms — Measured area 71.413% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Seat Selection UI: 9 Scenes That Score Every View
The post this demo lives in
Seat Selection UI: 9 Scenes That Score Every ViewIn a seat selection ui, a seat near the stage can still have a poor view, so 02 scores the clearance over heads in front See all demos in this post
If you like this effect - similar demos
Pickers06 Keyboard Difficulty Selector Whose Focus Ring Trails With InertiaDifficulty Selector UI — 9 Computed Patterns
Pickers03 Drag inertia — tracking pointer velocity, then decaying it with an exponential glideCSS Range Slider: 9 Physics-Grade Custom Sliders
Pickers01 Picker Whose Histogram Bars Recompute Height With Every Drag9 Graph Animation CSS Pickers With Live Recompute