03The Stage in Perspective from the Seat's Eye Level
The eye position of the current seat becomes the camera, the stage box and the heads in the rows ahead are projected in perspective and drawn from the farthest first, and as the camera glides to the next seat the stage size and the blocked part change together.
Best forSeat preview panel for concert tickets
FOCALGLIDE_SSTAGE_SAMPLES

<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="The stage and the heads in front drawn in perspective from the eye height of the picked seat. As the camera glides to the next seat, the stage size and the blocked share change."></canvas>
<div class="cf__pick" role="group" aria-label="Pick a seat (arrow keys work too)">
<button class="cf__btn" data-role="prev" type="button">Prev seat</button>
<button class="cf__btn" data-role="next" type="button">Next seat</button>
</div>
<p class="cf__status" data-role="status" aria-live="off">The stage from your seat</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');
}
}
// 자리 고르기 — 진짜 버튼 두 개(Prev seat·Next seat)와 방향키. left·right = 같은 줄 옆 자리, 위·아래 = 앞뒤 줄의 각도가 가장 가까운 자리.
// 고르면 시연 표시를 떼고(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 });
// 03 — The stage from your seat. 카메라 = 그 자리의 앉은 눈(seatEye), 무대 위 LOOK_AT 을 바라본다. 무대 상자·배우·앞줄 머리를 project 로 옮기고
// 깊이가 먼 것부터 그려 겹침을 맞춘다. 카메라는 TOUR 의 다음 자리로 GLIDE_S 동안 미끄러진다
const FOCAL = 360;
const TOUR = ['A-03', 'E-07', 'K-13', 'G-02'].map(findSeat);
const TOUR_START_S = 0.2;
const GLIDE_S = 0.7;
const HOLD_S = 0.35;
const PICK_START = TOUR[1];
const LOOP_S = TOUR_START_S + TOUR.length * (GLIDE_S + HOLD_S);
const STATUS_VIEW = '{s} eye {e}m · stage {w}% wide · heads block {o}%';
// 배우 셋 — 무대 위 x 위치(미터)와 키
const ACTORS = [-1.7, 0, 1.6];
const ACTOR_H = 1.7;
// 배우가 좌우로 천천히 오가는 폭(미터)과 빠르기 — 무대 위 움직임이 앞줄 머리 뒤로 숨었다 나온다
const ACTOR_SWAY_M = 0.6;
const ACTOR_SWAY_HZ = 0.45;
function actorX(k) { return ACTORS[k] + ACTOR_SWAY_M * Math.sin(TAU * ACTOR_SWAY_HZ * sceneT + k * 2.1); }
let eye03 = seatEye(HALL.seats[TOUR[0]].row, HALL.seats[TOUR[0]].ang, RAKE_STEP);
let from03 = TOUR[0];
let to03 = TOUR[0];
let glide03 = 1;
let manual03 = -1;
let manualFrom03 = eye03;
let manualT03 = 1;
function seatEyeOf(i) { const s = HALL.seats[i]; return seatEye(s.row, s.ang, RAKE_STEP); }
function camOf(eye) { return lookAt(eye, LOOK_AT, FOCAL, W / 2, H / 2); }
// 지금 카메라 거리 ρ 보다 0.3m 넘게 앞선 줄만 앞줄 머리로 친다 — 미끄러지는 사이에도 끊기지 않게
function rowsAheadOf(eye) {
const rho = Math.hypot(eye[0], eye[2]);
let n = 0;
for (let i = 0; i < HALL_ROWS; i += 1) {
if (rowDist(i) < rho - 0.3) {
n = i + 1;
}
}
return n;
}
// 무대 앞끝 폭이 화면 폭에서 차지하는 몫(%) — 양끝을 투영해 잰다(화면 밖이면 100 을 넘는다)
function stageWidthPct(cam) {
const a = project(cam, [-STAGE_W_M / 2, STAGE_H, 0]);
const b = project(cam, [STAGE_W_M / 2, STAGE_H, 0]);
return Math.round(Math.abs(b[0] - a[0]) / W * 100);
}
function tourAt(t) {
const u = t - TOUR_START_S;
if (u < 0) {
return [TOUR[0], TOUR[0], 1];
}
const seg = Math.floor(u / (GLIDE_S + HOLD_S));
const local = u - seg * (GLIDE_S + HOLD_S);
const a = TOUR[seg % TOUR.length];
const b = TOUR[(seg + 1) % TOUR.length];
return [a, b, smooth(local / GLIDE_S)];
}
function reset() {
if (manual03 < 0) {
glide03 = 1;
from03 = TOUR[0];
to03 = TOUR[0];
eye03 = seatEyeOf(TOUR[0]);
}
}
function statusFor03() {
const cam = camOf(eye03);
const occ = stageOcclusion(cam, headsAhead(cam, rowsAheadOf(eye03), RAKE_STEP));
const s = HALL.seats[glide03 < 0.5 ? from03 : to03];
simStatus = STATUS_VIEW.replace('{s}', s.label).replace('{e}', eye03[1].toFixed(2)).replace('{w}', String(stageWidthPct(cam))).replace('{o}', String(Math.round(occ * 100)));
}
function update(dt) {
if (manual03 >= 0) {
manualT03 = Math.min(1, manualT03 + dt / GLIDE_S);
eye03 = v3lerp(manualFrom03, seatEyeOf(manual03), smooth(manualT03));
from03 = manual03;
to03 = manual03;
glide03 = 1;
}
else {
const tr = tourAt(sceneT);
from03 = tr[0];
to03 = tr[1];
glide03 = tr[2];
eye03 = v3lerp(seatEyeOf(from03), seatEyeOf(to03), glide03);
}
statusFor03();
}
function restPose() {
const i = manual03 >= 0 ? manual03 : PICK_START;
eye03 = seatEyeOf(i);
from03 = i;
to03 = i;
glide03 = 1;
statusFor03();
}
function onPick(idx) {
manualFrom03 = eye03;
manual03 = idx;
// 멈춘 동안(Pause·동작 줄이기)에는 루프가 없으니 미끄러지지 않고 바로 그 자리로
manualT03 = still || paused ? 1 : 0;
if (still || paused) {
eye03 = seatEyeOf(idx);
from03 = idx;
to03 = idx;
glide03 = 1;
}
statusFor03();
}
const WALL = [206, 196, 232];
const CURTAIN = [176, 160, 220];
const WOOD_FRONT = [178, 144, 116];
const HEAD = [96, 88, 112];
const ACTOR = [246, 176, 150];
function quad(cam, pts, c, a, line) {
const pp = pts.map(function (p) { return project(cam, p); });
if (pp.some(function (p) { return p[2] <= 0; })) {
return null;
}
let z = 0;
for (const p of pp) {
z += p[2];
}
return {
z: z / pp.length,
paint: function () {
ctx.beginPath();
ctx.moveTo(pp[0][0], pp[0][1]);
for (let k = 1; k < pp.length; k += 1) {
ctx.lineTo(pp[k][0], pp[k][1]);
}
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
if (line) {
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK, 0.35);
ctx.stroke();
}
}
};
}
function sceneList03(cam) {
const out = [];
const hw = STAGE_W_M / 2;
const push = function (d) { if (d) {
out.push(d);
} };
// 뒷벽(막) · 무대 윗면 · 무대 앞면 — 앞면은 윗면보다 가까우므로 나중에 그려진다
push(quad(cam, [[-hw - 2, 0, -STAGE_D_M], [hw + 2, 0, -STAGE_D_M], [hw + 2, 6, -STAGE_D_M], [-hw - 2, 6, -STAGE_D_M]], CURTAIN, 1, false));
push(quad(cam, [[-hw, STAGE_H, 0], [hw, STAGE_H, 0], [hw, STAGE_H, -STAGE_D_M], [-hw, STAGE_H, -STAGE_D_M]], STAGE_WOOD, 1, true));
push(quad(cam, [[-hw, 0, 0], [hw, 0, 0], [hw, STAGE_H, 0], [-hw, STAGE_H, 0]], WOOD_FRONT, 1, true));
// 막 주름 — 뒷벽 위 세로 띠
for (let k = -6; k <= 6; k += 2) {
push(quad(cam, [[k * 0.8, STAGE_H, -STAGE_D_M + 0.02], [k * 0.8 + 0.5, STAGE_H, -STAGE_D_M + 0.02], [k * 0.8 + 0.5, 6, -STAGE_D_M + 0.02], [k * 0.8, 6, -STAGE_D_M + 0.02]], WALL, 0.55, false));
}
// 배우 — 무대 위에 선 타원 기둥
for (let ak = 0; ak < ACTORS.length; ak += 1) {
const ax = actorX(ak);
const foot = project(cam, [ax, STAGE_H, LOOK_AT[2]]);
const top = project(cam, [ax, STAGE_H + ACTOR_H, LOOK_AT[2]]);
if (foot[2] <= 0) {
continue;
}
out.push({
z: foot[2],
paint: function () {
const rw = cam.focal * 0.22 / foot[2];
ctx.beginPath();
ctx.ellipse(foot[0], (foot[1] + top[1]) / 2, rw, Math.abs(foot[1] - top[1]) / 2, 0, 0, TAU);
ctx.fillStyle = rgba(ACTOR, 1);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.stroke();
}
});
}
// 앞줄 머리와 어깨
for (const h of headsAhead(cam, rowsAheadOf(cam.eye), RAKE_STEP)) {
if (h.sx < -h.rx * 3 || h.sx > W + h.rx * 3 || h.sy - h.ry > H) {
continue;
}
out.push({
z: h.z,
paint: function () {
ctx.beginPath();
ctx.ellipse(h.sx, h.sy + h.ry * 2.6, h.rx * 2.4, h.ry * 1.4, 0, 0, TAU);
ctx.fillStyle = rgba(mix(HEAD, PAPER, 0.25), 1);
ctx.fill();
ctx.beginPath();
ctx.ellipse(h.sx, h.sy, h.rx, h.ry, 0, 0, TAU);
ctx.fillStyle = rgba(HEAD, 1);
ctx.fill();
}
});
}
return out;
}
function miniMap03() {
const k = 0.26;
const ox = 12 - (HALL_CX - 240) * k;
fillRound(8, 8, 128, 76, 8, CREAM, 0.92);
for (const s of HALL.seats) {
ctx.fillStyle = rgba(INK_SOFT, 0.6);
ctx.fillRect(ox + s.x * k - 1, 12 + s.y * k - 1, 2, 2);
}
const rho = Math.hypot(eye03[0], eye03[2]);
const ang = Math.atan2(eye03[0], eye03[2]);
const r = ROW_R0 + (rho - FIRST_ROW_M) / ROW_DEPTH_M * ROW_GAP;
ctx.beginPath();
ctx.arc(ox + (HALL_CX + r * Math.sin(ang)) * k, 12 + (HALL_CY + r * Math.cos(ang)) * k, 4, 0, TAU);
ctx.fillStyle = rgba(MINT, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(MINT_DEEP, 1);
ctx.stroke();
}
function draw() {
const cam = camOf(eye03);
// 벽과 바닥 — 지평선(눈높이)을 기준으로 위는 벽, 아래는 바닥
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(WALL, 1));
g.addColorStop(1, rgba(PAPER_DIM, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 무대 조명 번짐
const c = project(cam, [0, STAGE_H + 1.2, LOOK_AT[2]]);
if (c[2] > 0) {
const rg = ctx.createRadialGradient(c[0], c[1], 4, c[0], c[1], cam.focal * 4 / c[2]);
rg.addColorStop(0, rgba(STAGE_LIGHT, 0.95));
rg.addColorStop(1, rgba(STAGE_LIGHT, 0));
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
}
// 먼 것부터 그리기 — 깊이가 큰 것부터 칠하면 가까운 머리가 무대를 덮는다
const list = sceneList03(cam);
list.sort(function (a, b) { return b.z - a.z; });
for (const d of list) {
d.paint();
}
miniMap03();
const s = HALL.seats[glide03 < 0.5 ? from03 : to03];
pill(s.label, 72, 96, 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();
};
})();// SeatViewProjection - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SeatViewProjection.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SeatViewProjection() {
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 });
// 03 — 자리에서 본 무대. 카메라 = 그 자리의 앉은 눈(seatEye), 무대 위 LOOK_AT 을 바라본다. 무대 상자·배우·앞줄 머리를 project 로 옮기고
// 깊이가 먼 것부터 그려 겹침을 맞춘다. 카메라는 TOUR 의 다음 자리로 GLIDE_S 동안 미끄러진다
const FOCAL = 360;
const TOUR = ['A-03', 'E-07', 'K-13', 'G-02'].map(findSeat);
const TOUR_START_S = 0.2;
const GLIDE_S = 0.7;
const HOLD_S = 0.35;
const PICK_START = TOUR[1];
const LOOP_S = TOUR_START_S + TOUR.length * (GLIDE_S + HOLD_S);
const STATUS_VIEW = '{s} 눈높이 {e}m · 무대 폭 {w}% · 머리 가림 {o}%';
// 배우 셋 — 무대 위 x 위치(미터)와 키
const ACTORS = [-1.7, 0, 1.6];
const ACTOR_H = 1.7;
// 배우가 좌우로 천천히 오가는 폭(미터)과 빠르기 — 무대 위 움직임이 앞줄 머리 뒤로 숨었다 나온다
const ACTOR_SWAY_M = 0.6;
const ACTOR_SWAY_HZ = 0.45;
function actorX(k: number): number { return ACTORS[k] + ACTOR_SWAY_M * Math.sin(TAU * ACTOR_SWAY_HZ * sceneT + k * 2.1); }
let eye03: V3 = seatEye(HALL.seats[TOUR[0]].row, HALL.seats[TOUR[0]].ang, RAKE_STEP);
let from03 = TOUR[0];
let to03 = TOUR[0];
let glide03 = 1;
let manual03 = -1;
let manualFrom03: V3 = eye03;
let manualT03 = 1;
function seatEyeOf(i: number): V3 { const s = HALL.seats[i]; return seatEye(s.row, s.ang, RAKE_STEP); }
function camOf(eye: V3): Cam { return lookAt(eye, LOOK_AT, FOCAL, W / 2, H / 2); }
// 지금 카메라 거리 ρ 보다 0.3m 넘게 앞선 줄만 앞줄 머리로 친다 — 미끄러지는 사이에도 끊기지 않게
function rowsAheadOf(eye: V3): number {
const rho = Math.hypot(eye[0], eye[2]);
let n = 0;
for (let i = 0; i < HALL_ROWS; i += 1) { if (rowDist(i) < rho - 0.3) { n = i + 1; } }
return n;
}
// 무대 앞끝 폭이 화면 폭에서 차지하는 몫(%) — 양끝을 투영해 잰다(화면 밖이면 100 을 넘는다)
function stageWidthPct(cam: Cam): number {
const a = project(cam, [-STAGE_W_M / 2, STAGE_H, 0]);
const b = project(cam, [STAGE_W_M / 2, STAGE_H, 0]);
return Math.round(Math.abs(b[0] - a[0]) / W * 100);
}
function tourAt(t: number): [number, number, number] {
const u = t - TOUR_START_S;
if (u < 0) { return [TOUR[0], TOUR[0], 1]; }
const seg = Math.floor(u / (GLIDE_S + HOLD_S));
const local = u - seg * (GLIDE_S + HOLD_S);
const a = TOUR[seg % TOUR.length];
const b = TOUR[(seg + 1) % TOUR.length];
return [a, b, smooth(local / GLIDE_S)];
}
function reset(): void {
if (manual03 < 0) { glide03 = 1; from03 = TOUR[0]; to03 = TOUR[0]; eye03 = seatEyeOf(TOUR[0]); }
}
function statusFor03(): void {
const cam = camOf(eye03);
const occ = stageOcclusion(cam, headsAhead(cam, rowsAheadOf(eye03), RAKE_STEP));
const s = HALL.seats[glide03 < 0.5 ? from03 : to03];
simStatus = STATUS_VIEW.replace('{s}', s.label).replace('{e}', eye03[1].toFixed(2)).replace('{w}', String(stageWidthPct(cam))).replace('{o}', String(Math.round(occ * 100)));
}
function update(dt: number): void {
if (manual03 >= 0) {
manualT03 = Math.min(1, manualT03 + dt / GLIDE_S);
eye03 = v3lerp(manualFrom03, seatEyeOf(manual03), smooth(manualT03));
from03 = manual03;
to03 = manual03;
glide03 = 1;
} else {
const tr = tourAt(sceneT);
from03 = tr[0];
to03 = tr[1];
glide03 = tr[2];
eye03 = v3lerp(seatEyeOf(from03), seatEyeOf(to03), glide03);
}
statusFor03();
}
function restPose(): void {
const i = manual03 >= 0 ? manual03 : PICK_START;
eye03 = seatEyeOf(i);
from03 = i;
to03 = i;
glide03 = 1;
statusFor03();
}
function onPick(idx: number): void {
manualFrom03 = eye03;
manual03 = idx;
// 멈춘 동안(일시정지·동작 줄이기)에는 루프가 없으니 미끄러지지 않고 바로 그 자리로
manualT03 = still || paused ? 1 : 0;
if (still || paused) { eye03 = seatEyeOf(idx); from03 = idx; to03 = idx; glide03 = 1; }
statusFor03();
}
// 그릴 것 하나 — 깊이(카메라 앞 거리)와 그리는 함수. 먼 것부터 그리기 위해 깊이로 줄 세운다
type Drawable = { z: number; paint: () => void };
const WALL: RGB = [206, 196, 232];
const CURTAIN: RGB = [176, 160, 220];
const WOOD_FRONT: RGB = [178, 144, 116];
const HEAD: RGB = [96, 88, 112];
const ACTOR: RGB = [246, 176, 150];
function quad(cam: Cam, pts: V3[], c: RGB, a: number, line: boolean): Drawable | null {
const pp = pts.map(function (p: V3): V3 { return project(cam, p); });
if (pp.some(function (p: V3): boolean { return p[2] <= 0; })) { return null; }
let z = 0;
for (const p of pp) { z += p[2]; }
return {
z: z / pp.length,
paint: function (): void {
ctx.beginPath();
ctx.moveTo(pp[0][0], pp[0][1]);
for (let k = 1; k < pp.length; k += 1) { ctx.lineTo(pp[k][0], pp[k][1]); }
ctx.closePath();
ctx.fillStyle = rgba(c, a);
ctx.fill();
if (line) { ctx.lineWidth = 1; ctx.strokeStyle = rgba(INK, 0.35); ctx.stroke(); }
}
};
}
function sceneList03(cam: Cam): Drawable[] {
const out: Drawable[] = [];
const hw = STAGE_W_M / 2;
const push = function (d: Drawable | null): void { if (d) { out.push(d); } };
// 뒷벽(막) · 무대 윗면 · 무대 앞면 — 앞면은 윗면보다 가까우므로 나중에 그려진다
push(quad(cam, [[-hw - 2, 0, -STAGE_D_M], [hw + 2, 0, -STAGE_D_M], [hw + 2, 6, -STAGE_D_M], [-hw - 2, 6, -STAGE_D_M]], CURTAIN, 1, false));
push(quad(cam, [[-hw, STAGE_H, 0], [hw, STAGE_H, 0], [hw, STAGE_H, -STAGE_D_M], [-hw, STAGE_H, -STAGE_D_M]], STAGE_WOOD, 1, true));
push(quad(cam, [[-hw, 0, 0], [hw, 0, 0], [hw, STAGE_H, 0], [-hw, STAGE_H, 0]], WOOD_FRONT, 1, true));
// 막 주름 — 뒷벽 위 세로 띠
for (let k = -6; k <= 6; k += 2) {
push(quad(cam, [[k * 0.8, STAGE_H, -STAGE_D_M + 0.02], [k * 0.8 + 0.5, STAGE_H, -STAGE_D_M + 0.02], [k * 0.8 + 0.5, 6, -STAGE_D_M + 0.02], [k * 0.8, 6, -STAGE_D_M + 0.02]], WALL, 0.55, false));
}
// 배우 — 무대 위에 선 타원 기둥
for (let ak = 0; ak < ACTORS.length; ak += 1) {
const ax = actorX(ak);
const foot = project(cam, [ax, STAGE_H, LOOK_AT[2]]);
const top = project(cam, [ax, STAGE_H + ACTOR_H, LOOK_AT[2]]);
if (foot[2] <= 0) { continue; }
out.push({
z: foot[2],
paint: function (): void {
const rw = cam.focal * 0.22 / foot[2];
ctx.beginPath();
ctx.ellipse(foot[0], (foot[1] + top[1]) / 2, rw, Math.abs(foot[1] - top[1]) / 2, 0, 0, TAU);
ctx.fillStyle = rgba(ACTOR, 1);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.stroke();
}
});
}
// 앞줄 머리와 어깨
for (const h of headsAhead(cam, rowsAheadOf(cam.eye), RAKE_STEP)) {
if (h.sx < -h.rx * 3 || h.sx > W + h.rx * 3 || h.sy - h.ry > H) { continue; }
out.push({
z: h.z,
paint: function (): void {
ctx.beginPath();
ctx.ellipse(h.sx, h.sy + h.ry * 2.6, h.rx * 2.4, h.ry * 1.4, 0, 0, TAU);
ctx.fillStyle = rgba(mix(HEAD, PAPER, 0.25), 1);
ctx.fill();
ctx.beginPath();
ctx.ellipse(h.sx, h.sy, h.rx, h.ry, 0, 0, TAU);
ctx.fillStyle = rgba(HEAD, 1);
ctx.fill();
}
});
}
return out;
}
function miniMap03(): void {
const k = 0.26;
const ox = 12 - (HALL_CX - 240) * k;
fillRound(8, 8, 128, 76, 8, CREAM, 0.92);
for (const s of HALL.seats) {
ctx.fillStyle = rgba(INK_SOFT, 0.6);
ctx.fillRect(ox + s.x * k - 1, 12 + s.y * k - 1, 2, 2);
}
const rho = Math.hypot(eye03[0], eye03[2]);
const ang = Math.atan2(eye03[0], eye03[2]);
const r = ROW_R0 + (rho - FIRST_ROW_M) / ROW_DEPTH_M * ROW_GAP;
ctx.beginPath();
ctx.arc(ox + (HALL_CX + r * Math.sin(ang)) * k, 12 + (HALL_CY + r * Math.cos(ang)) * k, 4, 0, TAU);
ctx.fillStyle = rgba(MINT, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(MINT_DEEP, 1);
ctx.stroke();
}
function draw(): void {
const cam = camOf(eye03);
// 벽과 바닥 — 지평선(눈높이)을 기준으로 위는 벽, 아래는 바닥
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(WALL, 1));
g.addColorStop(1, rgba(PAPER_DIM, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 무대 조명 번짐
const c = project(cam, [0, STAGE_H + 1.2, LOOK_AT[2]]);
if (c[2] > 0) {
const rg = ctx.createRadialGradient(c[0], c[1], 4, c[0], c[1], cam.focal * 4 / c[2]);
rg.addColorStop(0, rgba(STAGE_LIGHT, 0.95));
rg.addColorStop(1, rgba(STAGE_LIGHT, 0));
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
}
// 먼 것부터 그리기 — 깊이가 큰 것부터 칠하면 가까운 머리가 무대를 덮는다
const list = sceneList03(cam);
list.sort(function (a: Drawable, b: Drawable): number { return b.z - a.z; });
for (const d of list) { d.paint(); }
miniMap03();
const s = HALL.seats[glide03 < 0.5 ? from03 : to03];
pill(s.label, 72, 96, 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>
);
}03 The Stage in Perspective from the Seat's Eye Level — Measured area 63.324% · 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
Pickers08 Color-temperature track — interpolating hue and saturation from the value ratioCSS Range Slider: 9 Physics-Grade Custom Sliders
Pickers02 Month-year unit value toggle — when the display-unit value switches from month to year, several years can be skipped at onceDate Picker UI: 9 Popover & Calendar Patterns
Pickers02 Two-icon segmented toggle9 Segmented Control UI Patterns — Copy-Paste