01호 길이로 자리를 나누는 부채꼴 객석
무대 중심에서 같은 간격으로 넓어지는 호마다 통로 폭만큼 틈을 뺀 호 길이를 좌석 폭으로 나눠 줄마다 자리 수를 정하고, 자리가 무대 쪽 줄부터 차례로 튀어 올라 앉는다.
쓰임소극장 연극 예매 페이지
SEAT_WROW_STAGGER_MSPOP_STIFFNESS

<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="무대에서 같은 간격으로 넓어지는 호마다 호 길이를 좌석 폭으로 나눈 만큼 자리가 놓이고, 무대 쪽 줄부터 차례로 튀어 올라 앉는 부채꼴 객석."></canvas>
<div class="cf__pick" role="group" aria-label="자리 고르기 — 방향키로도 옮길 수 있다">
<button class="cf__btn" data-role="prev" type="button">이전 자리</button>
<button class="cf__btn" data-role="next" type="button">다음 자리</button>
</div>
<p class="cf__status" data-role="status" aria-live="off">객석을 만드는 중</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</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 오른쪽, 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');
}
}
// 자리 고르기 — 진짜 버튼 두 개(이전 자리·다음 자리)와 방향키. 왼쪽·오른쪽 = 같은 줄 옆 자리, 위·아래 = 앞뒤 줄의 각도가 가장 가까운 자리.
// 고르면 시연 표시를 떼고(wake) 항목의 onPick 이 장면을 그 자리에 맞춘 뒤, 동작 줄이기 중이어도 곧바로 한 장 다시 그린다
const prevEl = root.querySelector('[data-role="prev"]');
const nextEl = root.querySelector('[data-role="next"]');
let pickIdx = -1;
function movePick(dk, dr) {
pickIdx = pickIdx < 0 ? PICK_START : hallNeighbor(pickIdx, dk, dr);
wake();
onPick(pickIdx);
draw();
pushStatus();
}
if (prevEl) {
prevEl.addEventListener('click', function () { movePick(-1, 0); }, { signal: off.signal });
}
if (nextEl) {
nextEl.addEventListener('click', function () { movePick(1, 0); }, { signal: off.signal });
}
const PICK_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
root.addEventListener('keydown', function (e) {
const m = PICK_KEYS[e.key];
if (!m) {
return;
}
e.preventDefault();
movePick(m[0], m[1]);
}, { signal: off.signal });
// 01 — 부채꼴 객석. buildHall 이 줄마다 호 길이 ÷ SEAT_W 로 자리 수를 정하고, 자리는 무대 쪽 줄부터 ROW_STAGGER_MS 씩 늦게 스프링으로 튀어 올라 앉는다
const SEAT_START_S = 0.2;
// 줄 하나 뒤로 갈 때마다 늦게 출발, 같은 줄 안에서는 왼쪽부터 SEAT_STAGGER_MS 씩
const ROW_STAGGER_MS = 110;
const SEAT_STAGGER_MS = 12;
// 자리 크기 스프링 — 0 에서 1 로, 감쇠가 작아 살짝 넘쳤다가 앉는다
const POP_STIFFNESS = 260;
const POP_DAMPING = 14;
// 튀어 오르는 높이(px) — 크기가 1 에 못 미친 만큼 위로 떠 있다
const POP_LIFT = 14;
const SOLD_AT_S = 1.75;
const SOLD_SEED = 1201;
const SOLD_SHARE = 0.3;
const CHOSEN_AT_S = 2.1;
const PICK_START = findSeat('E-07');
const FOLD_AT_S = 5.2;
const LOOP_S = 6.2;
const STATUS_BUILD = '줄 {r}개 · {n}석 — 줄마다 호 길이 ÷ 좌석 폭 {w}px';
const STATUS_PICK = '{s} 선택 — {r}번째 줄, 이 줄 {n}석';
const pop01 = HALL.seats.map(function () { return makeSpring(0); });
// 팔린 자리 — 시드 고정 난수로 SOLD_SHARE 만큼
const sold01 = (function () {
const rng = makeRng(SOLD_SEED);
return HALL.seats.map(function () { return rng() < SOLD_SHARE; });
})();
let chosen01 = -1;
let manual01 = -1;
function departAt01(i) {
const s = HALL.seats[i];
return SEAT_START_S + s.row * ROW_STAGGER_MS / 1000 + s.k * SEAT_STAGGER_MS / 1000;
}
function reset() {
pop01.forEach(function (p) { snapSpring(p, 0); });
chosen01 = manual01;
}
function update(dt) {
pop01.forEach(function (p, i) {
p.to = sceneT >= departAt01(i) && sceneT < FOLD_AT_S + (HALL_ROWS - 1 - HALL.seats[i].row) * 0.03 ? 1 : 0;
stepSpring(p, POP_STIFFNESS, POP_DAMPING, dt);
});
if (manual01 < 0) {
chosen01 = sceneT >= CHOSEN_AT_S && sceneT < FOLD_AT_S ? PICK_START : -1;
}
statusFor01();
}
function statusFor01() {
if (chosen01 >= 0) {
const s = HALL.seats[chosen01];
simStatus = STATUS_PICK.replace('{s}', s.label).replace('{r}', String(s.row + 1)).replace('{n}', String(HALL.rowCount[s.row]));
}
else {
simStatus = STATUS_BUILD.replace('{r}', String(HALL_ROWS)).replace('{n}', String(HALL.seats.length)).replace('{w}', String(SEAT_W));
}
}
function restPose() {
sceneT = CHOSEN_AT_S + 0.5;
pop01.forEach(function (p) { snapSpring(p, 1); });
if (manual01 < 0) {
chosen01 = PICK_START;
}
statusFor01();
}
// 이 자리의 상태 — 사람이 고른 자리 · 시연이 고른 자리는 체크, 팔린 자리는 SOLD_AT_S 뒤 빗금
function seatState01(i) {
if (i === chosen01) {
return SEAT_MINE;
}
return sold01[i] && sceneT >= SOLD_AT_S + HALL.seats[i].row * 0.02 ? SEAT_SOLD : SEAT_FREE;
}
function onPick(idx) {
manual01 = idx;
chosen01 = idx;
if (still) {
pop01.forEach(function (p) { snapSpring(p, 1); });
}
statusFor01();
}
// 줄 안내선 — 그 줄의 첫 자리가 출발하기 0.15초 전부터 부채꼴 전체 각을 왼쪽에서 오른쪽으로 그어 나간다
function rowGuide01(i) {
const t0 = SEAT_START_S + i * ROW_STAGGER_MS / 1000 - 0.15;
const k = still ? 1 : smooth((sceneT - t0) / 0.3) * (sceneT < FOLD_AT_S ? 1 : 1 - smooth((sceneT - FOLD_AT_S) / 0.4));
if (k <= 0.01) {
return;
}
const r = ROW_R0 + i * ROW_GAP;
// 캔버스 arc 의 각은 +x 축에서 시계 방향 — 아래 방향 기준 각 a 는 π/2 − a
ctx.beginPath();
ctx.arc(HALL_CX, HALL_CY, r, Math.PI / 2 + FAN_HALF, Math.PI / 2 + FAN_HALF - 2 * FAN_HALF * k, true);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(GRID, 0.9 * k);
ctx.stroke();
// 줄 머리글자와 자리 수
const s0 = HALL.seats[HALL.rowStart[i]];
label(ROW_LETTERS.charAt(i), HALL_CX + (r + 2) * Math.sin(-FAN_HALF - 0.06), HALL_CY + (r + 2) * Math.cos(-FAN_HALF - 0.06), INK_SOFT, k, 9, 800, 'center');
if (s0 && k > 0.6) {
label(String(HALL.rowCount[i]), HALL_CX + (r + 2) * Math.sin(FAN_HALF + 0.07), HALL_CY + (r + 2) * Math.cos(FAN_HALF + 0.07), INK_SOFT, smooth((k - 0.6) / 0.4), 9, 700, 'center');
}
}
function draw() {
clearStage(PAPER);
drawStageArc(HALL_CX, HALL_CY, 34, 1);
for (let i = 0; i < HALL_ROWS; i += 1) {
rowGuide01(i);
}
// 통로 — 두 줄기의 각도를 따라 옅은 띠
for (const a of AISLE_ANGLES) {
ctx.beginPath();
ctx.moveTo(HALL_CX + (ROW_R0 - 10) * Math.sin(a), HALL_CY + (ROW_R0 - 10) * Math.cos(a));
ctx.lineTo(HALL_CX + (ROW_R0 + HALL_ROWS * ROW_GAP) * Math.sin(a), HALL_CY + (ROW_R0 + HALL_ROWS * ROW_GAP) * Math.cos(a));
ctx.lineWidth = AISLE_W - 6;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(PAPER_DIM, 1);
ctx.stroke();
}
const sw = SEAT_W * 0.78;
const sh = ROW_GAP * 0.62;
HALL.seats.forEach(function (s, i) {
const p = pop01[i].x;
if (p <= 0.01) {
return;
}
const lift = (1 - clamp(p, 0, 1)) * POP_LIFT;
// 정교함 한 겹 — 그림자는 자리가 높이 뜰수록 넓고 옅게, 앉으면 좁고 진하게
ctx.beginPath();
ctx.ellipse(s.x, s.y + sh * 0.35, sw * (0.45 + lift * 0.03) * Math.min(p, 1.2), sh * 0.22 * Math.min(p, 1.2), -s.ang, 0, TAU);
ctx.fillStyle = rgba(INK, clamp(0.22 - lift * 0.012, 0.05, 0.22));
ctx.fill();
const st = seatState01(i);
drawSeat(s.x, s.y - lift, sw * p, sh * p, -s.ang, st, st === SEAT_MINE ? MINT : CREAM, clamp(p * 1.4, 0, 1));
});
if (chosen01 >= 0) {
const s = HALL.seats[chosen01];
ctx.beginPath();
ctx.arc(s.x, s.y, 12, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(MINT_DEEP, 0.9);
ctx.stroke();
pill(s.label, s.x, s.y - 20, 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();
};
})();// FanHallArcSeats - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FanHallArcSeats.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FanHallArcSeats() {
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'); }
}
// 자리 고르기 — 진짜 버튼 두 개(이전 자리·다음 자리)와 방향키. 왼쪽·오른쪽 = 같은 줄 옆 자리, 위·아래 = 앞뒤 줄의 각도가 가장 가까운 자리.
// 고르면 시연 표시를 떼고(wake) 항목의 onPick 이 장면을 그 자리에 맞춘 뒤, 동작 줄이기 중이어도 곧바로 한 장 다시 그린다
const prevEl = root.querySelector('[data-role="prev"]') as HTMLButtonElement | null;
const nextEl = root.querySelector('[data-role="next"]') as HTMLButtonElement | null;
let pickIdx = -1;
function movePick(dk: number, dr: number): void {
pickIdx = pickIdx < 0 ? PICK_START : hallNeighbor(pickIdx, dk, dr);
wake();
onPick(pickIdx);
draw();
pushStatus();
}
if (prevEl) { prevEl.addEventListener('click', function (): void { movePick(-1, 0); }, { signal: off.signal }); }
if (nextEl) { nextEl.addEventListener('click', function (): void { movePick(1, 0); }, { signal: off.signal }); }
const PICK_KEYS: { [k: string]: [number, number] } = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
root.addEventListener('keydown', function (e: KeyboardEvent): void {
const m = PICK_KEYS[e.key];
if (!m) { return; }
e.preventDefault();
movePick(m[0], m[1]);
}, { signal: off.signal });
// 01 — 부채꼴 객석. buildHall 이 줄마다 호 길이 ÷ SEAT_W 로 자리 수를 정하고, 자리는 무대 쪽 줄부터 ROW_STAGGER_MS 씩 늦게 스프링으로 튀어 올라 앉는다
const SEAT_START_S = 0.2;
// 줄 하나 뒤로 갈 때마다 늦게 출발, 같은 줄 안에서는 왼쪽부터 SEAT_STAGGER_MS 씩
const ROW_STAGGER_MS = 110;
const SEAT_STAGGER_MS = 12;
// 자리 크기 스프링 — 0 에서 1 로, 감쇠가 작아 살짝 넘쳤다가 앉는다
const POP_STIFFNESS = 260;
const POP_DAMPING = 14;
// 튀어 오르는 높이(px) — 크기가 1 에 못 미친 만큼 위로 떠 있다
const POP_LIFT = 14;
const SOLD_AT_S = 1.75;
const SOLD_SEED = 1201;
const SOLD_SHARE = 0.3;
const CHOSEN_AT_S = 2.1;
const PICK_START = findSeat('E-07');
const FOLD_AT_S = 5.2;
const LOOP_S = 6.2;
const STATUS_BUILD = '줄 {r}개 · {n}석 — 줄마다 호 길이 ÷ 좌석 폭 {w}px';
const STATUS_PICK = '{s} 선택 — {r}번째 줄, 이 줄 {n}석';
const pop01: Spring[] = HALL.seats.map(function (): Spring { return makeSpring(0); });
// 팔린 자리 — 시드 고정 난수로 SOLD_SHARE 만큼
const sold01: boolean[] = (function (): boolean[] {
const rng = makeRng(SOLD_SEED);
return HALL.seats.map(function (): boolean { return rng() < SOLD_SHARE; });
})();
let chosen01 = -1;
let manual01 = -1;
function departAt01(i: number): number {
const s = HALL.seats[i];
return SEAT_START_S + s.row * ROW_STAGGER_MS / 1000 + s.k * SEAT_STAGGER_MS / 1000;
}
function reset(): void {
pop01.forEach(function (p: Spring): void { snapSpring(p, 0); });
chosen01 = manual01;
}
function update(dt: number): void {
pop01.forEach(function (p: Spring, i: number): void {
p.to = sceneT >= departAt01(i) && sceneT < FOLD_AT_S + (HALL_ROWS - 1 - HALL.seats[i].row) * 0.03 ? 1 : 0;
stepSpring(p, POP_STIFFNESS, POP_DAMPING, dt);
});
if (manual01 < 0) { chosen01 = sceneT >= CHOSEN_AT_S && sceneT < FOLD_AT_S ? PICK_START : -1; }
statusFor01();
}
function statusFor01(): void {
if (chosen01 >= 0) {
const s = HALL.seats[chosen01];
simStatus = STATUS_PICK.replace('{s}', s.label).replace('{r}', String(s.row + 1)).replace('{n}', String(HALL.rowCount[s.row]));
} else {
simStatus = STATUS_BUILD.replace('{r}', String(HALL_ROWS)).replace('{n}', String(HALL.seats.length)).replace('{w}', String(SEAT_W));
}
}
function restPose(): void {
sceneT = CHOSEN_AT_S + 0.5;
pop01.forEach(function (p: Spring): void { snapSpring(p, 1); });
if (manual01 < 0) { chosen01 = PICK_START; }
statusFor01();
}
// 이 자리의 상태 — 사람이 고른 자리 · 시연이 고른 자리는 체크, 팔린 자리는 SOLD_AT_S 뒤 빗금
function seatState01(i: number): number {
if (i === chosen01) { return SEAT_MINE; }
return sold01[i] && sceneT >= SOLD_AT_S + HALL.seats[i].row * 0.02 ? SEAT_SOLD : SEAT_FREE;
}
function onPick(idx: number): void {
manual01 = idx;
chosen01 = idx;
if (still) { pop01.forEach(function (p: Spring): void { snapSpring(p, 1); }); }
statusFor01();
}
// 줄 안내선 — 그 줄의 첫 자리가 출발하기 0.15초 전부터 부채꼴 전체 각을 왼쪽에서 오른쪽으로 그어 나간다
function rowGuide01(i: number): void {
const t0 = SEAT_START_S + i * ROW_STAGGER_MS / 1000 - 0.15;
const k = still ? 1 : smooth((sceneT - t0) / 0.3) * (sceneT < FOLD_AT_S ? 1 : 1 - smooth((sceneT - FOLD_AT_S) / 0.4));
if (k <= 0.01) { return; }
const r = ROW_R0 + i * ROW_GAP;
// 캔버스 arc 의 각은 +x 축에서 시계 방향 — 아래 방향 기준 각 a 는 π/2 − a
ctx.beginPath();
ctx.arc(HALL_CX, HALL_CY, r, Math.PI / 2 + FAN_HALF, Math.PI / 2 + FAN_HALF - 2 * FAN_HALF * k, true);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(GRID, 0.9 * k);
ctx.stroke();
// 줄 머리글자와 자리 수
const s0 = HALL.seats[HALL.rowStart[i]];
label(ROW_LETTERS.charAt(i), HALL_CX + (r + 2) * Math.sin(-FAN_HALF - 0.06), HALL_CY + (r + 2) * Math.cos(-FAN_HALF - 0.06), INK_SOFT, k, 9, 800, 'center');
if (s0 && k > 0.6) {
label(String(HALL.rowCount[i]), HALL_CX + (r + 2) * Math.sin(FAN_HALF + 0.07), HALL_CY + (r + 2) * Math.cos(FAN_HALF + 0.07), INK_SOFT, smooth((k - 0.6) / 0.4), 9, 700, 'center');
}
}
function draw(): void {
clearStage(PAPER);
drawStageArc(HALL_CX, HALL_CY, 34, 1);
for (let i = 0; i < HALL_ROWS; i += 1) { rowGuide01(i); }
// 통로 — 두 줄기의 각도를 따라 옅은 띠
for (const a of AISLE_ANGLES) {
ctx.beginPath();
ctx.moveTo(HALL_CX + (ROW_R0 - 10) * Math.sin(a), HALL_CY + (ROW_R0 - 10) * Math.cos(a));
ctx.lineTo(HALL_CX + (ROW_R0 + HALL_ROWS * ROW_GAP) * Math.sin(a), HALL_CY + (ROW_R0 + HALL_ROWS * ROW_GAP) * Math.cos(a));
ctx.lineWidth = AISLE_W - 6;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(PAPER_DIM, 1);
ctx.stroke();
}
const sw = SEAT_W * 0.78;
const sh = ROW_GAP * 0.62;
HALL.seats.forEach(function (s: Seat, i: number): void {
const p = pop01[i].x;
if (p <= 0.01) { return; }
const lift = (1 - clamp(p, 0, 1)) * POP_LIFT;
// 정교함 한 겹 — 그림자는 자리가 높이 뜰수록 넓고 옅게, 앉으면 좁고 진하게
ctx.beginPath();
ctx.ellipse(s.x, s.y + sh * 0.35, sw * (0.45 + lift * 0.03) * Math.min(p, 1.2), sh * 0.22 * Math.min(p, 1.2), -s.ang, 0, TAU);
ctx.fillStyle = rgba(INK, clamp(0.22 - lift * 0.012, 0.05, 0.22));
ctx.fill();
const st = seatState01(i);
drawSeat(s.x, s.y - lift, sw * p, sh * p, -s.ang, st, st === SEAT_MINE ? MINT : CREAM, clamp(p * 1.4, 0, 1));
});
if (chosen01 >= 0) {
const s = HALL.seats[chosen01];
ctx.beginPath();
ctx.arc(s.x, s.y, 12, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(MINT_DEEP, 0.9);
ctx.stroke();
pill(s.label, s.x, s.y - 20, 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>
<div className={cx("cf__pick")} role="group" aria-label="자리 고르기 — 방향키로도 옮길 수 있다">
<button className={cx("cf__btn")} data-role="prev" type="button">{'이전 자리'}</button>
<button className={cx("cf__btn")} data-role="next" type="button">{'다음 자리'}</button>
</div>
<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>
);
}01 호 길이로 자리를 나누는 부채꼴 객석 — 측정 누적면적 20.203% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 좌석 선택 ui 9종 — 공연장 좌석 시야까지 계산하는 예매 화면
이 데모가 속한 글
좌석 선택 ui 9종 — 공연장 좌석 시야까지 계산하는 예매 화면좌석 선택 ui 는 객석 지도에서 빈자리를 보여 주고 고르게 하는 예매 화면이다. 무대와 가까운 자리가 곧 잘 보이는 자리는 아니다. 그래서 02번은 앞줄 머리 위로 시선이 지나가는 틈과 거리·각도로 자리마다 점수를 이 글의 데모 전부 보기


