09자리가 정해지면 표가 뒤집혀 나오는 공연 예매 첫 화면
부채꼴 객석에 시야 점수가 물들고 다른 구매자들이 자리를 채우는 사이 일행 연석이 정해지면, 무대 미리보기가 열리고 스프링으로 튀어나온 표가 한 바퀴 뒤집혀 자리 번호를 보여 준 뒤 처음으로 돌아간다.
쓰임공연 기획사 홈페이지 첫 화면
FILL_POWERPARTY_SIZEFLIP_S

<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>
<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');
}
}
// 09 — 공연 예매 첫 화면. 01 의 부채꼴 객석이 튀어 올라 앉고 → 02 의 viewOf 점수로 물들고 → 05 처럼 점수^FILL_POWER 무게로 다른 구매자가 자리를 채우고 →
// 04 의 findBlocks 로 외톨이 빈칸 없는 PARTY_SIZE 명 연석을 고르고 → 03 의 투영으로 그 자리에서 본 무대 미리보기를 연 뒤 → 표가 스프링으로 튀어나와 한 바퀴 뒤집힌다
const POP_START_S = 0.1;
const HERO_ROW_STAGGER_MS = 30;
const HERO_POP_STIFFNESS = 260;
const HERO_POP_DAMPING = 15;
const COLOR_START_S = 0.3;
const HERO_SWEEP_PX_S = 520;
const FILL_START_S = 0.5;
const FILL_END_S = 1.0;
const FILL_SHARE = 0.45;
const FILL_POWER = 3;
const HERO_SEED = 909;
const PARTY_SIZE = 2;
const BLOCK_AT_S = 1.05;
const PREVIEW_AT_S = 1.15;
const TICKET_AT_S = 1.35;
const FLIP_AT_S = 1.6;
const FLIP_S = 0.8;
const TICKET_STIFFNESS = 180;
const TICKET_DAMPING = 14;
const LOOP_S = 6.6;
const STATUS_HERO_FILL = '예매 중 — 남은 자리 {n}석';
const STATUS_HERO_PICK = '{a}·{b} 연석 확정 · 시야 점수 {p}';
const heroScore = hallScores(STAGE_H, RAKE_STEP);
// 다른 구매자가 고르는 순서 — 빈자리 가운데 점수^FILL_POWER 에 비례한 확률로 하나씩
const fillOrder09 = (function () {
const rng = makeRng(HERO_SEED);
const left = HALL.seats.map(function (_, i) { return i; });
const out = [];
const n = Math.round(HALL.seats.length * FILL_SHARE);
while (out.length < n) {
let sum = 0;
for (const i of left) {
sum += Math.pow(heroScore[i], FILL_POWER);
}
let u = rng() * sum;
let k = 0;
for (; k < left.length - 1; k += 1) {
u -= Math.pow(heroScore[left[k]], FILL_POWER);
if (u <= 0) {
break;
}
}
out.push(left[k]);
left.splice(k, 1);
}
return out;
})();
const soldAt09 = HALL.seats.map(function () { return Infinity; });
fillOrder09.forEach(function (id, k) { soldAt09[id] = lerp(FILL_START_S, FILL_END_S, k / fillOrder09.length); });
const heroFree = soldAt09.map(function (t) { return !isFinite(t); });
const heroBlocks = findBlocks(HALL.segs, heroFree, heroScore, PARTY_SIZE);
const heroBest = bestBlock(heroBlocks, false);
const heroSeats = heroBest >= 0 ? heroBlocks[heroBest].ids : [];
const heroPop = HALL.seats.map(function () { return makeSpring(0); });
const ticketY = makeSpring(1);
const previewS = makeSpring(0);
let flip09 = 0;
function heroDepart(i) { return POP_START_S + HALL.seats[i].row * HERO_ROW_STAGGER_MS / 1000; }
function heroColorK(i) { return smooth(((sceneT - COLOR_START_S) * HERO_SWEEP_PX_S - (HALL.seats[i].r - ROW_R0)) / 60); }
function reset() {
heroPop.forEach(function (p) { snapSpring(p, 0); });
snapSpring(ticketY, 1);
snapSpring(previewS, 0);
flip09 = 0;
}
function statusFor09() {
if (sceneT >= BLOCK_AT_S && heroSeats.length) {
let s = 0;
for (const id of heroSeats) {
s += heroScore[id];
}
simStatus = STATUS_HERO_PICK.replace('{a}', HALL.seats[heroSeats[0]].label).replace('{b}', HALL.seats[heroSeats[heroSeats.length - 1]].label).replace('{p}', String(pct(s / heroSeats.length)));
}
else {
let n = 0;
for (let i = 0; i < soldAt09.length; i += 1) {
if (soldAt09[i] > sceneT) {
n += 1;
}
}
simStatus = STATUS_HERO_FILL.replace('{n}', String(n));
}
}
function update(dt) {
heroPop.forEach(function (p, i) {
p.to = sceneT >= heroDepart(i) && sceneT < LOOP_S - 0.6 ? 1 : 0;
stepSpring(p, HERO_POP_STIFFNESS, HERO_POP_DAMPING, dt);
});
previewS.to = sceneT >= PREVIEW_AT_S && sceneT < LOOP_S - 0.8 ? 1 : 0;
stepSpring(previewS, 240, 18, dt);
ticketY.to = sceneT >= TICKET_AT_S && sceneT < LOOP_S - 0.8 ? 0 : 1;
stepSpring(ticketY, TICKET_STIFFNESS, TICKET_DAMPING, dt);
// 한 바퀴 — 0 → 2π, 앞뒤를 smooth 로 눌러 천천히 시작해 천천히 멈춘다
flip09 = TAU * smooth((sceneT - FLIP_AT_S) / FLIP_S);
statusFor09();
}
function restPose() {
sceneT = FLIP_AT_S + FLIP_S + 0.5;
heroPop.forEach(function (p) { snapSpring(p, 1); });
snapSpring(previewS, 1);
snapSpring(ticketY, 0);
flip09 = TAU;
statusFor09();
}
const PV_X = 10;
const PV_Y = 150;
const PV_W = 156;
const PV_H = 98;
const PV_HEAD = [96, 88, 112];
const PV_WALL = [206, 196, 232];
const TICKET_W = 176;
const TICKET_H = 84;
const TICKET_CX = 360;
const TICKET_CY = 170;
const TICKET_TITLE = '공연 예매 확인';
const TICKET_BACK = '좌석 지정';
// 미리보기 — 고른 연석 첫 자리의 눈에서 03 과 같은 투영으로 무대·배우·앞줄 머리를 그리고 먼 것부터 칠한다
function preview09(k) {
if (k <= 0.02 || !heroSeats.length) {
return;
}
const s = HALL.seats[heroSeats[0]];
const cx = PV_X + PV_W / 2;
const cy = PV_Y + PV_H / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(k, k);
ctx.translate(-cx, -cy);
ctx.fillStyle = rgba(INK, 0.18);
roundRectPath(PV_X + 3, PV_Y + 5, PV_W, PV_H, 10);
ctx.fill();
roundRectPath(PV_X, PV_Y, PV_W, PV_H, 10);
ctx.save();
ctx.clip();
const g = ctx.createLinearGradient(0, PV_Y, 0, PV_Y + PV_H);
g.addColorStop(0, rgba(PV_WALL, 1));
g.addColorStop(1, rgba(PAPER_DIM, 1));
ctx.fillStyle = g;
ctx.fillRect(PV_X, PV_Y, PV_W, PV_H);
const cam = lookAt(seatEye(s.row, s.ang, RAKE_STEP), LOOK_AT, 120, cx, cy);
const hw = STAGE_W_M / 2;
const shapes = [];
const addQuad = function (ps, c) {
const pp = ps.map(function (p) { return project(cam, p); });
if (pp.some(function (p) { return p[2] <= 0; })) {
return;
}
shapes.push({ z: (pp[0][2] + pp[1][2] + pp[2][2] + pp[3][2]) / 4, pts: [pp[0][0], pp[0][1], pp[1][0], pp[1][1], pp[2][0], pp[2][1], pp[3][0], pp[3][1]], c: c, ellipse: false });
};
addQuad([[-hw - 2, 0, -STAGE_D_M], [hw + 2, 0, -STAGE_D_M], [hw + 2, 6, -STAGE_D_M], [-hw - 2, 6, -STAGE_D_M]], [176, 160, 220]);
addQuad([[-hw, STAGE_H, 0], [hw, STAGE_H, 0], [hw, STAGE_H, -STAGE_D_M], [-hw, STAGE_H, -STAGE_D_M]], STAGE_WOOD);
addQuad([[-hw, 0, 0], [hw, 0, 0], [hw, STAGE_H, 0], [-hw, STAGE_H, 0]], [178, 144, 116]);
for (const ax of [-1.7, 0, 1.6]) {
const f = project(cam, [ax, STAGE_H, LOOK_AT[2]]);
const t = project(cam, [ax, STAGE_H + 1.7, LOOK_AT[2]]);
if (f[2] > 0) {
shapes.push({ z: f[2], pts: [f[0], (f[1] + t[1]) / 2, cam.focal * 0.22 / f[2], Math.abs(f[1] - t[1]) / 2], c: [246, 176, 150], ellipse: true });
}
}
for (const h of headsAhead(cam, s.row, RAKE_STEP)) {
shapes.push({ z: h.z, pts: [h.sx, h.sy, h.rx, h.ry], c: PV_HEAD, ellipse: true });
}
shapes.sort(function (a, b) { return b.z - a.z; });
for (const sh of shapes) {
ctx.beginPath();
if (sh.ellipse) {
ctx.ellipse(sh.pts[0], sh.pts[1], Math.max(0.5, sh.pts[2]), Math.max(0.5, sh.pts[3]), 0, 0, TAU);
}
else {
ctx.moveTo(sh.pts[0], sh.pts[1]);
for (let i = 2; i < 8; i += 2) {
ctx.lineTo(sh.pts[i], sh.pts[i + 1]);
}
ctx.closePath();
}
ctx.fillStyle = rgba(sh.c, 1);
ctx.fill();
}
ctx.restore();
roundRectPath(PV_X, PV_Y, PV_W, PV_H, 10);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.8);
ctx.stroke();
pill(s.label, PV_X + 30, PV_Y + 14, INK, CREAM, 1, 9);
ctx.restore();
}
// 표 — 폭을 |cos 회전각| 로 눌러 뒤집히는 모습을 만든다. cos 가 음수인 동안은 뒷면
function ticket09() {
const y = TICKET_CY + ticketY.x * 230;
if (y > H + TICKET_H) {
return;
}
const c = Math.cos(flip09);
const w = TICKET_W * Math.max(0.02, Math.abs(c));
ctx.save();
ctx.translate(TICKET_CX, y);
ctx.fillStyle = rgba(INK, 0.2);
ctx.beginPath();
ctx.ellipse(0, TICKET_H / 2 + 10, w * 0.5, 6, 0, 0, TAU);
ctx.fill();
// 정교함 한 겹 — 옆으로 누운 동안(|cos| 작을 때) 면이 어두워져 빛을 비스듬히 받는 것처럼
const face = c >= 0 ? CREAM : LAVENDER;
fillRound(-w / 2, -TICKET_H / 2, w, TICKET_H, 10, mix(face, SOLD, 0.45 * (1 - Math.abs(c))), 1);
roundRectPath(-w / 2, -TICKET_H / 2, w, TICKET_H, 10);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.85);
ctx.stroke();
if (Math.abs(c) > 0.3) {
ctx.scale(Math.abs(c), 1);
if (c >= 0) {
label(TICKET_TITLE, -TICKET_W / 2 + 12, -TICKET_H / 2 + 16, INK_SOFT, 1, 10, 700, 'left');
const lbl = heroSeats.map(function (id) { return HALL.seats[id].label; }).join(' · ');
label(lbl, -TICKET_W / 2 + 12, 4, INK, 1, 17, 800, 'left');
for (let k = 0; k < 18; k += 1) {
ctx.fillStyle = rgba(INK, k % 3 === 0 ? 0.9 : 0.5);
ctx.fillRect(-TICKET_W / 2 + 12 + k * 6, TICKET_H / 2 - 20, k % 2 ? 2 : 3, 12);
}
ctx.beginPath();
ctx.arc(TICKET_W / 2 - 20, 0, 12, 0, TAU);
ctx.fillStyle = rgba(MINT, 1);
ctx.fill();
ctx.translate(TICKET_W / 2 - 32, -12);
checkIn(24, 24, MINT_DEEP, 1);
}
else {
for (let k = -6; k < 8; k += 1) {
ctx.beginPath();
ctx.moveTo(k * 12, -TICKET_H / 2 + 6);
ctx.lineTo(k * 12 + 30, TICKET_H / 2 - 6);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.25);
ctx.stroke();
}
label(TICKET_BACK, 0, 0, VIOLET_DEEP, 1, 14, 800, 'center');
}
}
ctx.restore();
}
function draw() {
clearStage(PAPER);
drawStageArc(HALL_CX, HALL_CY, 34, 1);
const sw = SEAT_W * 0.78;
const sh = ROW_GAP * 0.62;
const chosen = sceneT >= BLOCK_AT_S;
HALL.seats.forEach(function (s, i) {
const p = heroPop[i].x;
if (p <= 0.01) {
return;
}
const mine = chosen && heroSeats.indexOf(i) >= 0;
const st = mine ? SEAT_MINE : (soldAt09[i] <= sceneT ? SEAT_SOLD : SEAT_FREE);
const fill = mine ? MINT : mix(CREAM, scoreColor(heroScore[i]), heroColorK(i));
const lift = (1 - clamp(p, 0, 1)) * 12;
drawSeat(s.x, s.y - lift, sw * p * (mine ? 1.15 : 1), sh * p * (mine ? 1.15 : 1), -s.ang, st, fill, clamp(p * 1.4, 0, 1));
});
// 연석이 정해진 순간 — 두 자리를 감싸는 고리가 퍼진다
if (chosen && heroSeats.length) {
const a = HALL.seats[heroSeats[0]];
const b = HALL.seats[heroSeats[heroSeats.length - 1]];
const age = sceneT - BLOCK_AT_S;
const r = 14 + age * 60;
const al = clamp(1 - age / 0.7, 0, 1);
if (al > 0) {
ctx.beginPath();
ctx.ellipse((a.x + b.x) / 2, (a.y + b.y) / 2, r + Math.abs(b.x - a.x) / 2, r * 0.8, 0, 0, TAU);
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(MINT_DEEP, al);
ctx.stroke();
}
}
preview09(clamp(previewS.x, 0, 1.2));
ticket09();
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 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();
};
})();// ConcertBookingHero - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ConcertBookingHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ConcertBookingHero() {
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'); }
}
// 09 — 공연 예매 첫 화면. 01 의 부채꼴 객석이 튀어 올라 앉고 → 02 의 viewOf 점수로 물들고 → 05 처럼 점수^FILL_POWER 무게로 다른 구매자가 자리를 채우고 →
// 04 의 findBlocks 로 외톨이 빈칸 없는 PARTY_SIZE 명 연석을 고르고 → 03 의 투영으로 그 자리에서 본 무대 미리보기를 연 뒤 → 표가 스프링으로 튀어나와 한 바퀴 뒤집힌다
const POP_START_S = 0.1;
const HERO_ROW_STAGGER_MS = 30;
const HERO_POP_STIFFNESS = 260;
const HERO_POP_DAMPING = 15;
const COLOR_START_S = 0.3;
const HERO_SWEEP_PX_S = 520;
const FILL_START_S = 0.5;
const FILL_END_S = 1.0;
const FILL_SHARE = 0.45;
const FILL_POWER = 3;
const HERO_SEED = 909;
const PARTY_SIZE = 2;
const BLOCK_AT_S = 1.05;
const PREVIEW_AT_S = 1.15;
const TICKET_AT_S = 1.35;
const FLIP_AT_S = 1.6;
const FLIP_S = 0.8;
const TICKET_STIFFNESS = 180;
const TICKET_DAMPING = 14;
const LOOP_S = 6.6;
const STATUS_HERO_FILL = '예매 중 — 남은 자리 {n}석';
const STATUS_HERO_PICK = '{a}·{b} 연석 확정 · 시야 점수 {p}';
const heroScore: number[] = hallScores(STAGE_H, RAKE_STEP);
// 다른 구매자가 고르는 순서 — 빈자리 가운데 점수^FILL_POWER 에 비례한 확률로 하나씩
const fillOrder09: number[] = (function (): number[] {
const rng = makeRng(HERO_SEED);
const left = HALL.seats.map(function (_: Seat, i: number): number { return i; });
const out: number[] = [];
const n = Math.round(HALL.seats.length * FILL_SHARE);
while (out.length < n) {
let sum = 0;
for (const i of left) { sum += Math.pow(heroScore[i], FILL_POWER); }
let u = rng() * sum;
let k = 0;
for (; k < left.length - 1; k += 1) { u -= Math.pow(heroScore[left[k]], FILL_POWER); if (u <= 0) { break; } }
out.push(left[k]);
left.splice(k, 1);
}
return out;
})();
const soldAt09: number[] = HALL.seats.map(function (): number { return Infinity; });
fillOrder09.forEach(function (id: number, k: number): void { soldAt09[id] = lerp(FILL_START_S, FILL_END_S, k / fillOrder09.length); });
const heroFree: boolean[] = soldAt09.map(function (t: number): boolean { return !isFinite(t); });
const heroBlocks = findBlocks(HALL.segs, heroFree, heroScore, PARTY_SIZE);
const heroBest = bestBlock(heroBlocks, false);
const heroSeats: number[] = heroBest >= 0 ? heroBlocks[heroBest].ids : [];
const heroPop: Spring[] = HALL.seats.map(function (): Spring { return makeSpring(0); });
const ticketY: Spring = makeSpring(1);
const previewS: Spring = makeSpring(0);
let flip09 = 0;
function heroDepart(i: number): number { return POP_START_S + HALL.seats[i].row * HERO_ROW_STAGGER_MS / 1000; }
function heroColorK(i: number): number { return smooth(((sceneT - COLOR_START_S) * HERO_SWEEP_PX_S - (HALL.seats[i].r - ROW_R0)) / 60); }
function reset(): void {
heroPop.forEach(function (p: Spring): void { snapSpring(p, 0); });
snapSpring(ticketY, 1);
snapSpring(previewS, 0);
flip09 = 0;
}
function statusFor09(): void {
if (sceneT >= BLOCK_AT_S && heroSeats.length) {
let s = 0;
for (const id of heroSeats) { s += heroScore[id]; }
simStatus = STATUS_HERO_PICK.replace('{a}', HALL.seats[heroSeats[0]].label).replace('{b}', HALL.seats[heroSeats[heroSeats.length - 1]].label).replace('{p}', String(pct(s / heroSeats.length)));
} else {
let n = 0;
for (let i = 0; i < soldAt09.length; i += 1) { if (soldAt09[i] > sceneT) { n += 1; } }
simStatus = STATUS_HERO_FILL.replace('{n}', String(n));
}
}
function update(dt: number): void {
heroPop.forEach(function (p: Spring, i: number): void {
p.to = sceneT >= heroDepart(i) && sceneT < LOOP_S - 0.6 ? 1 : 0;
stepSpring(p, HERO_POP_STIFFNESS, HERO_POP_DAMPING, dt);
});
previewS.to = sceneT >= PREVIEW_AT_S && sceneT < LOOP_S - 0.8 ? 1 : 0;
stepSpring(previewS, 240, 18, dt);
ticketY.to = sceneT >= TICKET_AT_S && sceneT < LOOP_S - 0.8 ? 0 : 1;
stepSpring(ticketY, TICKET_STIFFNESS, TICKET_DAMPING, dt);
// 한 바퀴 — 0 → 2π, 앞뒤를 smooth 로 눌러 천천히 시작해 천천히 멈춘다
flip09 = TAU * smooth((sceneT - FLIP_AT_S) / FLIP_S);
statusFor09();
}
function restPose(): void {
sceneT = FLIP_AT_S + FLIP_S + 0.5;
heroPop.forEach(function (p: Spring): void { snapSpring(p, 1); });
snapSpring(previewS, 1);
snapSpring(ticketY, 0);
flip09 = TAU;
statusFor09();
}
const PV_X = 10;
const PV_Y = 150;
const PV_W = 156;
const PV_H = 98;
const PV_HEAD: RGB = [96, 88, 112];
const PV_WALL: RGB = [206, 196, 232];
const TICKET_W = 176;
const TICKET_H = 84;
const TICKET_CX = 360;
const TICKET_CY = 170;
const TICKET_TITLE = '공연 예매 확인';
const TICKET_BACK = '좌석 지정';
type Shape = { z: number; pts: number[]; c: RGB; ellipse: boolean };
// 미리보기 — 고른 연석 첫 자리의 눈에서 03 과 같은 투영으로 무대·배우·앞줄 머리를 그리고 먼 것부터 칠한다
function preview09(k: number): void {
if (k <= 0.02 || !heroSeats.length) { return; }
const s = HALL.seats[heroSeats[0]];
const cx = PV_X + PV_W / 2;
const cy = PV_Y + PV_H / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(k, k);
ctx.translate(-cx, -cy);
ctx.fillStyle = rgba(INK, 0.18);
roundRectPath(PV_X + 3, PV_Y + 5, PV_W, PV_H, 10);
ctx.fill();
roundRectPath(PV_X, PV_Y, PV_W, PV_H, 10);
ctx.save();
ctx.clip();
const g = ctx.createLinearGradient(0, PV_Y, 0, PV_Y + PV_H);
g.addColorStop(0, rgba(PV_WALL, 1));
g.addColorStop(1, rgba(PAPER_DIM, 1));
ctx.fillStyle = g;
ctx.fillRect(PV_X, PV_Y, PV_W, PV_H);
const cam = lookAt(seatEye(s.row, s.ang, RAKE_STEP), LOOK_AT, 120, cx, cy);
const hw = STAGE_W_M / 2;
const shapes: Shape[] = [];
const addQuad = function (ps: V3[], c: RGB): void {
const pp = ps.map(function (p: V3): V3 { return project(cam, p); });
if (pp.some(function (p: V3): boolean { return p[2] <= 0; })) { return; }
shapes.push({ z: (pp[0][2] + pp[1][2] + pp[2][2] + pp[3][2]) / 4, pts: [pp[0][0], pp[0][1], pp[1][0], pp[1][1], pp[2][0], pp[2][1], pp[3][0], pp[3][1]], c: c, ellipse: false });
};
addQuad([[-hw - 2, 0, -STAGE_D_M], [hw + 2, 0, -STAGE_D_M], [hw + 2, 6, -STAGE_D_M], [-hw - 2, 6, -STAGE_D_M]], [176, 160, 220]);
addQuad([[-hw, STAGE_H, 0], [hw, STAGE_H, 0], [hw, STAGE_H, -STAGE_D_M], [-hw, STAGE_H, -STAGE_D_M]], STAGE_WOOD);
addQuad([[-hw, 0, 0], [hw, 0, 0], [hw, STAGE_H, 0], [-hw, STAGE_H, 0]], [178, 144, 116]);
for (const ax of [-1.7, 0, 1.6]) {
const f = project(cam, [ax, STAGE_H, LOOK_AT[2]]);
const t = project(cam, [ax, STAGE_H + 1.7, LOOK_AT[2]]);
if (f[2] > 0) { shapes.push({ z: f[2], pts: [f[0], (f[1] + t[1]) / 2, cam.focal * 0.22 / f[2], Math.abs(f[1] - t[1]) / 2], c: [246, 176, 150], ellipse: true }); }
}
for (const h of headsAhead(cam, s.row, RAKE_STEP)) { shapes.push({ z: h.z, pts: [h.sx, h.sy, h.rx, h.ry], c: PV_HEAD, ellipse: true }); }
shapes.sort(function (a: Shape, b: Shape): number { return b.z - a.z; });
for (const sh of shapes) {
ctx.beginPath();
if (sh.ellipse) { ctx.ellipse(sh.pts[0], sh.pts[1], Math.max(0.5, sh.pts[2]), Math.max(0.5, sh.pts[3]), 0, 0, TAU); }
else { ctx.moveTo(sh.pts[0], sh.pts[1]); for (let i = 2; i < 8; i += 2) { ctx.lineTo(sh.pts[i], sh.pts[i + 1]); } ctx.closePath(); }
ctx.fillStyle = rgba(sh.c, 1);
ctx.fill();
}
ctx.restore();
roundRectPath(PV_X, PV_Y, PV_W, PV_H, 10);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.8);
ctx.stroke();
pill(s.label, PV_X + 30, PV_Y + 14, INK, CREAM, 1, 9);
ctx.restore();
}
// 표 — 폭을 |cos 회전각| 로 눌러 뒤집히는 모습을 만든다. cos 가 음수인 동안은 뒷면
function ticket09(): void {
const y = TICKET_CY + ticketY.x * 230;
if (y > H + TICKET_H) { return; }
const c = Math.cos(flip09);
const w = TICKET_W * Math.max(0.02, Math.abs(c));
ctx.save();
ctx.translate(TICKET_CX, y);
ctx.fillStyle = rgba(INK, 0.2);
ctx.beginPath();
ctx.ellipse(0, TICKET_H / 2 + 10, w * 0.5, 6, 0, 0, TAU);
ctx.fill();
// 정교함 한 겹 — 옆으로 누운 동안(|cos| 작을 때) 면이 어두워져 빛을 비스듬히 받는 것처럼
const face = c >= 0 ? CREAM : LAVENDER;
fillRound(-w / 2, -TICKET_H / 2, w, TICKET_H, 10, mix(face, SOLD, 0.45 * (1 - Math.abs(c))), 1);
roundRectPath(-w / 2, -TICKET_H / 2, w, TICKET_H, 10);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.85);
ctx.stroke();
if (Math.abs(c) > 0.3) {
ctx.scale(Math.abs(c), 1);
if (c >= 0) {
label(TICKET_TITLE, -TICKET_W / 2 + 12, -TICKET_H / 2 + 16, INK_SOFT, 1, 10, 700, 'left');
const lbl = heroSeats.map(function (id: number): string { return HALL.seats[id].label; }).join(' · ');
label(lbl, -TICKET_W / 2 + 12, 4, INK, 1, 17, 800, 'left');
for (let k = 0; k < 18; k += 1) {
ctx.fillStyle = rgba(INK, k % 3 === 0 ? 0.9 : 0.5);
ctx.fillRect(-TICKET_W / 2 + 12 + k * 6, TICKET_H / 2 - 20, k % 2 ? 2 : 3, 12);
}
ctx.beginPath();
ctx.arc(TICKET_W / 2 - 20, 0, 12, 0, TAU);
ctx.fillStyle = rgba(MINT, 1);
ctx.fill();
ctx.translate(TICKET_W / 2 - 32, -12);
checkIn(24, 24, MINT_DEEP, 1);
} else {
for (let k = -6; k < 8; k += 1) {
ctx.beginPath();
ctx.moveTo(k * 12, -TICKET_H / 2 + 6);
ctx.lineTo(k * 12 + 30, TICKET_H / 2 - 6);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.25);
ctx.stroke();
}
label(TICKET_BACK, 0, 0, VIOLET_DEEP, 1, 14, 800, 'center');
}
}
ctx.restore();
}
function draw(): void {
clearStage(PAPER);
drawStageArc(HALL_CX, HALL_CY, 34, 1);
const sw = SEAT_W * 0.78;
const sh = ROW_GAP * 0.62;
const chosen = sceneT >= BLOCK_AT_S;
HALL.seats.forEach(function (s: Seat, i: number): void {
const p = heroPop[i].x;
if (p <= 0.01) { return; }
const mine = chosen && heroSeats.indexOf(i) >= 0;
const st = mine ? SEAT_MINE : (soldAt09[i] <= sceneT ? SEAT_SOLD : SEAT_FREE);
const fill = mine ? MINT : mix(CREAM, scoreColor(heroScore[i]), heroColorK(i));
const lift = (1 - clamp(p, 0, 1)) * 12;
drawSeat(s.x, s.y - lift, sw * p * (mine ? 1.15 : 1), sh * p * (mine ? 1.15 : 1), -s.ang, st, fill, clamp(p * 1.4, 0, 1));
});
// 연석이 정해진 순간 — 두 자리를 감싸는 고리가 퍼진다
if (chosen && heroSeats.length) {
const a = HALL.seats[heroSeats[0]];
const b = HALL.seats[heroSeats[heroSeats.length - 1]];
const age = sceneT - BLOCK_AT_S;
const r = 14 + age * 60;
const al = clamp(1 - age / 0.7, 0, 1);
if (al > 0) {
ctx.beginPath();
ctx.ellipse((a.x + b.x) / 2, (a.y + b.y) / 2, r + Math.abs(b.x - a.x) / 2, r * 0.8, 0, 0, TAU);
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(MINT_DEEP, al);
ctx.stroke();
}
}
preview09(clamp(previewS.x, 0, 1.2));
ticket09();
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="부채꼴 객석이 생기고 시야 점수로 물든 뒤 자리가 채워지고 연석이 정해지면, 무대 미리보기가 열리고 표가 튀어나와 한 바퀴 뒤집힌다."></canvas>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'공연 예매'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 자리가 정해지면 표가 뒤집혀 나오는 공연 예매 첫 화면 — 측정 누적면적 34.492% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 좌석 선택 ui 9종 — 공연장 좌석 시야까지 계산하는 예매 화면
이 데모가 속한 글
좌석 선택 ui 9종 — 공연장 좌석 시야까지 계산하는 예매 화면좌석 선택 ui 는 객석 지도에서 빈자리를 보여 주고 고르게 하는 예매 화면이다. 무대와 가까운 자리가 곧 잘 보이는 자리는 아니다. 그래서 02번은 앞줄 머리 위로 시선이 지나가는 틈과 거리·각도로 자리마다 점수를 이 글의 데모 전부 보기


