GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

좌석 선택 ui 9종 — 공연장 좌석 시야까지 계산하는 예매 화면

좌석 선택 ui 는 객석 지도에서 빈자리를 보여 주고 고르게 하는 예매 화면이다. 무대와 가까운 자리가 곧 잘 보이는 자리는 아니다. 그래서 02번은 앞줄 머리 위로 시선이 지나가는 틈과 거리·각도로 자리마다 점수를 매기고, 03번은 그 자리 눈높이에서 본 무대를 원근으로 그리며,

앞줄 가운데가 꼭 명당은 아니다 — 무대 높이·객석 단 높이·앞사람 머리가 정하는 시야

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

자리 고르기 화면을 빈자리와 팔린 자리를 두 색으로 칠한 격자로 끝내면, 그 격자가 말해 주지 않는 것이 시야다. 02 의 객석(줄 10개, 줄마다 단이 0.12 m 씩 오르고 무대 높이는 0.95 m)에서 줄마다 평균 점수를 내면 A 줄 57, B 줄 36, C 줄 41 이고, 가장 높은 줄은 무대에서 일곱째인 G 줄 62 다. 객석에서 가장 나쁜 줄은 맨 앞줄이 아니라 그 바로 뒤 둘째 줄이다.

둘째 줄이 맨 앞줄보다 나쁜 까닭

점수는 다섯 몫의 합이고, 그중 무거운 둘이 앞사람 머리 위로 남는 틈(무게 0.3)과 무대 폭이 눈에 차지하는 각(0.35)이다. 맨 앞줄은 앞사람이 없으니 틈을 만점인 12cm 로 치지만, 둘째 줄은 앞사람 눈 위로 시선이 2.93cm 만 지나가 틈 몫이 만점의 4분의 1 아래로 떨어진다. 폭 8 m 무대를 2.4 m 와 3.35 m 거리에서 보면 그 각이 118° 와 100° 인데, 이 몫은 50° 에서 100° 로 갈수록 0 으로 내려가므로 두 줄 모두 여기서 0 을 받는다. 맨 앞줄은 틈 몫으로 버티고 둘째 줄은 두 몫을 함께 잃는 셈이다. 뒤로 갈수록 무대 폭 각은 편해지지만 틈은 1.37cm(F)·1.21cm(G)·0.89cm(K)로 계속 좁아져, 두 흐름이 맞서는 G 줄 언저리에서 점수가 꼭대기에 오르는 것으로 보인다.

무대가 반 미터로 낮아지면

02 의 무대는 0.95 m 를 가운데 두고 위아래로 0.45 m 씩 2.4초 주기로 오르내린다. 가장 낮은 0.50 m 에서는 B 줄부터 K 줄까지 틈이 전부 음수(−9.84 ~ −3.01cm)로 뒤집혀, 143석 가운데 앞사람이 없는 A 줄 5석을 뺀 138석의 시선이 앞사람 눈 아래로 지나가고 평균은 53 으로 내려간다. 가장 높은 1.40 m 에서는 B 줄 틈이 15.69cm 로 벌어지고 평균이 70, 최고 자리가 F-06(82점)이 된다. 같은 E-07 도 무대 높이에 따라 60·64·81점으로 갈리니, 자리마다 점수를 보여 주려면 객석 배치와 무대 높이를 함께 넣어야 한다.

좌석 선택 ui 9종 — 부채꼴 객석·시야 점수 지도·자리에서 본 무대·일행 연석 찾기·실시간 예매 경쟁·관성 경기장 지도·입구에서 자리까지 길 안내·비행기 좌석 자동 배정·공연 예매 첫 화면

01~03 은 부채꼴 객석 하나를 세 겹으로 읽는다 — 자리를 놓고, 자리마다 시야를 매기고, 그 자리에 앉은 눈으로 무대를 본다. 04~08 은 예매 화면이 실제로 부딪히는 문제를 하나씩 맡는다(일행 연석·동시 예매·만 석 지도·입장 길·기내 배정). 09 는 01~05 의 계산을 6.6초짜리 한 흐름으로 이은 첫 화면이다. 아홉 장면 모두 외부 묶음 코드나 WebGL 없이 캔버스 2D 하나에 그리고, 난수는 시드를 고정해 몇 번을 열어도 같은 시연이 나온다.

480×300 판에서 잰 아홉 장면의 움직임

캔버스를 2초 동안 24장 캡처하고 이웃한 두 장 사이의 차이 23개로 잰 값이다. 무대 그림이 판 전체를 채우는 03 과 지도가 확대되는 06 이 60% 를 넘고, 좌석 점 몇십 개만 움직이는 04·08 은 10~13% 에 머문다.

장면 누적면적 움직인 프레임 강도 평균
01 부채꼴 객석 20.2% 23/23 45.6
02 시야 점수 지도 17.9% 23/23 88.1
03 자리에서 본 무대 63.3% 23/23 69.8
04 일행 연석 10.5% 23/23 61.8
05 실시간 예매 15.5% 22/23 81.7
06 경기장 지도 71.4% 23/23 44.7
07 입장 길 안내 31.3% 19/23 62.7
08 기내 자동 배정 12.5% 22/23 87.6
09 예매 첫 화면 34.5% 23/23 57.0

07 이 19/23 에 그친 것은 걷는 점이 자리에 닿은 뒤 다음 목표로 넘어가기 전까지 판이 멈춰 있는 구간 때문으로 보인다(가장 먼 A-22 도 2.046초에 도착하고 한 목표의 순환은 2.4초다). 05·08 의 22/23 도 사건과 사건 사이의 짧은 정지로 보인다.

좌석 예약 시스템 만들기에서 화면이 실제로 계산해야 하는 것은 무엇인가?

좌석 지도를 그리는 일보다 어려운 것은 자리를 누르기 전후에 화면이 대신 판단해 줘야 하는 것들이다. 아래 세 상황에서 그 판단이 무엇인지 나눠 본다.

공연 예매 — 그 자리에서 무대가 얼마나 보이는가

연극이나 뮤지컬 예매 화면에서 관객이 묻는 것은 「여기서 무대가 보이는가」 다. 02 의 점수 지도를 좌석표 위에 겹치고, 자리를 누르면 03 의 미리보기 창을 여는 조합이 이 물음에 답한다. 두 장면은 줄 거리·단 높이·눈높이 상수를 함께 쓰므로, 객석 단면 치수 몇 개만 바꾸면 다른 객석에도 옮겨 붙는다.

영화관 — 몇 명이 나란히 앉을 수 있는가

인원수를 먼저 고르는 화면이라면 계산할 것은 연속된 빈자리와 그 옆에 남는 한 칸이다. 04 처럼 외톨이 빈칸을 남기는 묶음을 걸러 두면 남은 자리가 다음 손님에게도 쓸모 있게 남는다. 05 는 그 화면에 다른 구매자가 동시에 들어와 있다는 사실을 다룬다.

경기장과 기내 — 너무 많거나 규칙이 붙은 자리

만 석이 넘는 경기장 지도에서는 무엇을 그리지 않을지가 계산이고(06), 입장한 뒤에는 입구에서 자리까지 길이 필요하다(07). 기내 좌석 지정은 붙어 앉기·창가나 통로 바람·비상구 줄 아이 금지를 한꺼번에 맞춰야 해서 08 처럼 놓아 보고 막히면 되돌아가는 순서가 있어야 한다. 사용자가 범위를 직접 긁는 달력 만들기 9종의 드래그 기간 선택이나, 점 하나를 고르는 지도 ui 디자인 9종의 핀 드롭과 견주면 차이가 보인다 — 좌석표는 고를 수 있는 칸의 모양과 칸끼리의 관계까지 계산한다.

01호 길이로 자리를 나누는 부채꼴 객석

줄 i 의 반지름은 62 + i × 18px 이고, 아래 반복은 통로 두 줄기 몫(폭 14px ÷ 반지름 라디안)을 각에서 먼저 끊어 낸 구간마다 구간 각 × 반지름을 SEAT_W 16px 로 나눠 내림해 자리 수를 정하므로 A 줄 5석에서 K 줄 24석까지 10줄에 143석이 앉는다. 자리는 0.2초 + 줄 × 110ms + 줄 안 순서 × 12ms 에 출발해 단단함 260·감쇠 14 스프링(감쇠비 0.434, 넘침 21.4%)으로 튀어 오르고, 뜰수록 그림자가 넓고 옅어지며, 마지막 K-24 가 1.466초에 출발해 143석이 2.254초에 모두 자리를 잡는다. 소극장 연극 예매 페이지처럼 객석이 무대를 감싸는 곳이라면 공연장 좌석 배치도 만들기의 첫 단계가 이 함수다.

호 길이로 자리를 나누는 부채꼴 객석
새 창에서 열기

01 · 호 길이로 자리를 나누는 부채꼴 객석 — 측정 누적면적 20.203% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEAT_WROW_STAGGER_MSPOP_STIFFNESS
        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);
        }

02앞줄 머리에 가리는 만큼 식는 시야 점수 지도

줄 i 의 눈높이는 i × RAKE_STEP(0.12 m) + 1.15 m, 무대까지 거리는 2.4 + i × 0.95 m 이고, 아래 clearance 는 그 눈에서 무대 앞끝까지 그은 직선이 앞줄 자리를 지날 때의 높이에서 앞줄 눈높이를 뺀 틈을 미터로 돌려준다. 이 틈(12cm 가 만점)과 거리·무대 폭 각·내려다보는 각·정면에서 벗어난 각을 0.3·0.1·0.35·0.1·0.15 의 무게로 더한 점수가 무대 쪽에서 초당 520px 로 퍼지며 칠해지고, 무대 높이가 오르내릴 때마다 지도 전체가 하늘색(0점)과 살구색(100점) 사이에서 다시 물든다. 공연장 시야 안내 페이지에 붙이면 「앞사람 머리 위로 시선이 몇 cm 지나가는가」 를 자리마다 숫자로 보여 줄 수 있다.

앞줄 머리에 가리는 만큼 식는 시야 점수 지도
새 창에서 열기

02 · 앞줄 머리에 가리는 만큼 식는 시야 점수 지도 — 측정 누적면적 17.908% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RAKE_STEPC_GOODSCORE_WEIGHTS
    // 틈(미터) — 줄 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);
    }

03그 자리 눈높이에서 원근으로 본 무대

고른 자리의 눈을 카메라로 놓고 (0, 2.05, −1.6) 을 바라보게 한 뒤 점마다 카메라 기준 깊이 z 로 나눠 화면 좌표를 얻고(초점 거리 360), 뒷막·무대·배우 셋·앞줄 머리와 어깨를 한 목록에 모아 아래처럼 먼 것부터 칠해 가까운 머리가 무대를 덮게 한다. 카메라는 A-03 → E-07 → K-13 → G-02 를 0.7초씩 미끄러져 옮기고 0.35초 머무는데, 무대 앞끝 폭이 화면 폭의 261%(A-03)에서 55%(K-13)로 줄고, 무대 앞끝 선과 배우 가슴 높이 선에 41점씩 찍은 표본 중 앞줄 머리에 가리는 몫은 0%·39%·49%·32% 로 바뀐다. 콘서트 예매의 좌석 미리보기 창이 사진 한 장 대신 이 계산을 쓰면 자리마다 다른 그림이 나온다.

그 자리 눈높이에서 원근으로 본 무대
새 창에서 열기

03 · 그 자리 눈높이에서 원근으로 본 무대 — 측정 누적면적 63.324% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOCALGLIDE_SSTAGE_SAMPLES
        // 무대 조명 번짐
        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();

04한 칸 빈자리를 남기지 않는 일행 연석 찾기

findBlocks(segs, free, score, size) 의 아래 본체는 구간마다 일행 수만큼 긴 창을 한 칸씩 밀며 창 안이 모두 빈 자리일 때만 점수 합을 내고, 창 바로 바깥 칸이 비었는데 그 너머가 팔렸거나 구간 끝이면 그 칸을 외톨이로 적는다. 4명 창의 후보 6개를 160ms 씩 훑는 동안 외톨이를 남기는 후보는 창이 산호색으로 바뀌고 그 칸에 빗금이 쳐지며, 점수 합 1위 F-06~F-09(3.917)와 2위 F-07~F-10(3.851)이 지워진 뒤 하이라이트가 E-05~E-08(3.744)에 멈춘다. 영화관 좌석 예약 시스템에서 인원수만 고르면 자리를 추천하는 버튼의 속이 이 계산이다.

한 칸 빈자리를 남기지 않는 일행 연석 찾기
새 창에서 열기

04 · 한 칸 빈자리를 남기지 않는 일행 연석 찾기 — 측정 누적면적 10.482% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GROUP_SIZECAND_MSSOLD_MAP
        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 });
            }
        });

05먼저 팔린 자리를 흔들어 알리고 가까운 대안을 권하는 실시간 예매

planBooking 이 시연 전체를 미리 짜 두는데, 다른 구매자 36명이 평균 70ms 의 지수 간격(이 시드에서는 첫 사람 0.35초, 마지막 사람 2.513초)으로 와서 빈자리 가운데 점수의 세제곱에 비례한 확률로 자리를 잡고 900ms 뒤 결제하며, 아래 반복이 그 사건을 시각대로 좌석 상태에 옮긴다. 나는 0.3초에 가장 높은 빈자리 E-07 을 잡아 4초짜리 남은 시간 고리가 줄기 시작하지만, 1.0초에 그 자리가 먼저 팔리면 734번 줄의 감쇠 사인으로 7px 부터 흔들리고 0.45초 뒤 대안 D-06 이 스프링으로 커지며 점선이 건너간다. 티켓 오픈 시각의 예매 대기 화면처럼 자리가 초 단위로 사라지는 곳에 맞는다.

먼저 팔린 자리를 흔들어 알리고 가까운 대안을 권하는 실시간 예매
새 창에서 열기

05 · 먼저 팔린 자리를 흔들어 알리고 가까운 대안을 권하는 실시간 예매 — 측정 누적면적 15.514% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ARRIVAL_MEAN_MSHOLD_MSSHAKE_DECAY_S
        while (evi05 < plan05.events.length && plan05.events[evi05].t <= sceneT) {
            const e = plan05.events[evi05];
            if (e.kind === EV_HOLD) {
                state05[e.id] = SEAT_HELD;
                heldAt05[e.id] = e.t;
            }
            else {
                state05[e.id] = SEAT_SOLD;
            }
            evi05 += 1;
        }
        const u = sceneT - CONFLICT_AT_S;
        myShake05 = u >= 0 && u < SHAKE_S ? SHAKE_PX * Math.exp(-u / SHAKE_DECAY_S) * Math.sin(TAU * SHAKE_HZ * u) : 0;
        altPop05.to = sceneT >= CONFLICT_AT_S + SHAKE_S ? 1 : 0;
        stepSpring(altPop05, 320, 14, dt);

06확대할수록 구역이 좌석으로 갈라지는 관성 경기장 지도

구역 24개(구역당 380~587석, 전체 11,720석)를 좌표 배열 두 개에 담고, 배율을 로그 눈금으로 1/60초마다 남은 거리의 6%씩 ×7 로 다가가게 하면서 첫 토막의 zoomAbout 으로 기준점 (330, 82) 를 화면의 같은 자리에 묶어 둔다. 배율이 ×2.2 에서 ×4 를 지나는 196ms 동안 구역 다각형은 옅어지고 자리 점은 짙어지며, 1.35초에 초당 (−300, 150)px 로 놓인 지도는 1/60초마다 속도에 0.92 를 곱해 2.375초에 멈춘다. 야구장 예매의 구역 선택 지도처럼 자리가 만 단위인 곳에서 쓰고, 둘째 토막의 forVisibleSeats 가 화면에 걸친 구역과 자리만 넘겨준다.

확대할수록 구역이 좌석으로 갈라지는 관성 경기장 지도
새 창에서 열기

06 · 확대할수록 구역이 좌석으로 갈라지는 관성 경기장 지도 — 측정 누적면적 71.413% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ZOOM_EASEFRICTIONSPLIT_FROM
    // 기준점 고정 확대 — 새 오프셋 = 기준점 − (기준점 − 오프셋) × 새 배율 / 옛 배율
    function zoomAbout(ax, ay, next) {
        ox06 = ax - (ax - ox06) * next / scale06;
        oy06 = ay - (ay - oy06) * next / scale06;
        scale06 = next;
    }
    function forVisibleSeats(s, ox, oy, cull, cb) {
        let n = 0;
        const pad = SEAT_PITCH * s;
        for (const sec of sections06) {
            if (cull) {
                const x0 = ox + sec.box[0] * s - pad;
                const y0 = oy + sec.box[1] * s - pad;
                const x1 = ox + sec.box[2] * s + pad;
                const y1 = oy + sec.box[3] * s + pad;
                if (x1 < 0 || y1 < 0 || x0 > W || y0 > H) {
                    continue;
                }
            }
            for (let i = sec.first; i < sec.first + sec.count; i += 1) {
                const x = ox + seatX06[i] * s;
                const y = oy + seatY06[i] * s;
                if (cull && (x < -pad || y < -pad || x > W + pad || y > H + pad)) {
                    continue;
                }
                n += 1;
                cb(i, x, y);
            }
        }
        return n;
    }

07입구에서 내 자리까지 통로를 따라 걷는 길 안내

통로와 줄 사이를 12px 칸 38 × 18 개로 바꾸면 걸을 수 있는 칸이 370개이고, breadthFirst 는 아래 반복 전에 입구 두 칸을 거리 0 으로 큐에 함께 넣은 뒤 꺼낸 칸의 네 이웃에 거리 + 1 을 적되 자리 칸에는 무대 쪽 바로 앞 칸에서만 들어가게 해 너비 우선으로 가장 짧은 길을 찾는다. B-05 까지는 왼쪽 입구에서 23칸·꺾임 4번이고, 꺾인 점 앞뒤 9px 를 2차 곡선으로 다듬은 길 265px 위를 점이 초당 230px, 곧 0.05초마다 11.5px 씩 같은 빠르기로 걸어가며 지나온 길은 굵은 민트 자취로 남는다. 공연장 모바일 티켓의 입장 안내에 넣으면 좌석 번호만 적힌 표보다 어느 입구로 들어갈지까지 알려 준다.

입구에서 내 자리까지 통로를 따라 걷는 길 안내
새 창에서 열기

07 · 입구에서 내 자리까지 통로를 따라 걷는 길 안내 — 측정 누적면적 31.31% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_CELLS_SCORNER_RWALK_PX_S
        while (head < queue.length) {
            const id = queue[head];
            head += 1;
            if (id === goal) {
                continue;
            }
            const r = Math.floor(id / G_COLS);
            const c = id % G_COLS;
            for (const d of DIRS) {
                const rr = r + d[0];
                const cc = c + d[1];
                const nid = rr * G_COLS + cc;
                if (rr < 0 || cc < 0 || rr >= G_ROWS || cc >= G_COLS || dist[nid] >= 0) {
                    continue;
                }
                // 자리 줄로는 그 자리의 바로 앞 칸(무대 쪽)에서만 들어간다
                if (nid === goal ? d[0] !== 1 : !walkable(rr, cc)) {
                    continue;
                }
                dist[nid] = dist[id] + 1;
                from[nid] = id;
                queue.push(nid);
            }
        }

08창가·통로 바람과 비상구 조건을 맞추는 비행기 좌석 자동 배정

placeParties 는 가족 1·둘·가족 2·부모와 아이·혼자 다섯 일행을 차례로, 선호 칸(창가 A·F, 통로 C·D)을 품은 후보부터 그다음은 앞줄부터 놓아 보고, 다음 일행이 실패하면 아래처럼 칸을 거두고 다음 후보로 넘어가며, 아이가 있는 일행이면 비상구 줄(4번)의 후보를 건너뛴다. 이 객실에서는 둘이 6E·6F 를 먼저 가져가 가족 2 가 막히자 한 단계 되돌아가 둘을 7A·7B 로 옮기고, 부모와 아이는 4B·4C 와 4D·4E 를 비상구 줄이라 건너뛰어 8B·8C 에 앉아 놓아 봄 6번·되돌림 1번·건너뜀 2번 만에 2.12초에 확정한다. 항공권 예약의 좌석 지정 단계에서 일행 좌석을 한꺼번에 채울 때 그 순서를 눈으로 보여 준다.

창가·통로 바람과 비상구 조건을 맞추는 비행기 좌석 자동 배정
새 창에서 열기

08 · 창가·통로 바람과 비상구 조건을 맞추는 비행기 좌석 자동 배정 — 측정 누적면적 12.541% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EXIT_ROWSTEP_MSCABIN_MAP
            for (const ids of candidates(p, taken)) {
                if (p.child && Math.floor(ids[0] / 6) + 1 === EXIT_ROW) {
                    steps.push({ kind: EV_RULE, party: g, seats: ids });
                    continue;
                }
                steps.push({ kind: EV_TRY, party: g, seats: ids });
                for (const id of ids) {
                    taken[id] = true;
                }
                if (place(g + 1)) {
                    return true;
                }
                for (const id of ids) {
                    taken[id] = false;
                }
                steps.push({ kind: EV_UNDO, party: g, seats: ids });
            }

09자리가 정해지면 표가 뒤집혀 나오는 공연 예매 첫 화면

01 의 배치가 줄 간격 30ms 로 0.1초부터 앉고 0.3초부터 02 의 점수 색이 퍼지며, 0.5~1.0초에 64석이 점수 세제곱 무게로 팔린 뒤 1.05초에 findBlocks 가 두 사람 후보 36개(외톨이 16개)에서 G-10·G-11 을 고른다. 1.15초에 03 과 같은 투영의 미리보기 창이 열리고 1.35초에 표가 단단함 180·감쇠 14 스프링으로 32px 넘쳐 올라온 뒤 1.6초부터 0.8초 동안 한 바퀴 도는데, 아래처럼 폭을 회전각 코사인의 절댓값으로 누르고 옆으로 누울수록 면을 어둡게 해 뒤집히는 느낌을 낸다. 공연 기획사 홈페이지 첫 화면에 걸면 고르고 표를 받는 과정 전체를 6.6초 안에 미리 보여 주는 셈이다.

자리가 정해지면 표가 뒤집혀 나오는 공연 예매 첫 화면
새 창에서 열기

09 · 자리가 정해지면 표가 뒤집혀 나오는 공연 예매 첫 화면 — 측정 누적면적 34.492% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FILL_POWERPARTY_SIZEFLIP_S
        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();

자리는 극좌표로, 시야는 앞줄 높이와 견주어, 연석은 미끄러지는 창으로 — 좌석표 뒤의 수식

통로 폭을 먼저 빼고 나누는 까닭

01 은 자리 수를 세기 전에 통로 두 줄기 몫을 각에서 먼저 끊는다. 끊는 각이 통로 폭 14px 를 반지름으로 나눈 값이라 바깥 줄일수록 각은 작아지고, 위에서 보면 통로가 줄마다 같은 폭의 곧은 길로 남는다. 그다음 구간 셋을 따로 내림하므로 구간마다 자투리가 버려져, 호 길이 199px 인 D 줄과 235px 인 E 줄이 똑같이 12석이 된다(통로를 뺀 E 줄 호를 한 번에 16px 로 나누면 14석이다). 반대로 호 전체를 먼저 나누고 통로 자리를 지우는 방식이라면 통로 폭이 좌석 폭 단위로 묶여 줄마다 들쭉날쭉해질 것으로 보인다.

틈 한 줄을 손으로 풀어 보면

clearance 의 직선 높이는 무대 높이 + (내 눈 − 무대 높이) × (앞줄 거리 / 내 거리) 다. E 줄(i = 4)을 넣으면 눈 1.63 m, 거리 6.2 m, 앞줄 거리 5.25 m 라 직선은 0.95 + 0.68 × 0.8468 = 1.5258 m 높이로 앞줄을 지나고, 앞줄 눈 1.51 m 를 빼면 0.0158 m, 곧 1.58cm 가 남는다. 이 값을 만점 12cm 로 나눈 0.13 이 E 줄의 틈 몫이다. 03 은 앞줄 머리를 눈 위 0.06 m 에 세로 반지름 0.13 m 인 타원으로 세우므로, 눈 위 1.58cm 를 지나는 시선은 그 타원의 아래쪽을 그대로 뚫는다 — E-07 에서 무대 표본의 39% 가 가리는 까닭으로 보인다.

확대는 기준점을, 연석은 바깥 칸을 본다

06 의 새 오프셋 = 기준점 − (기준점 − 오프셋) × 새 배율 / 옛 배율 은 기준점 아래에 있던 지도 좌표가 배율이 바뀐 뒤에도 같은 화면 좌표에 오게 하는 식이고, 소스 함수를 그대로 불러 잰 어긋남은 0px 다. 배율을 로그 눈금에서 다가가게 하면 ×1→×2 와 ×2→×4 처럼 같은 비율의 변화를 같은 거리로 다룬다. 다만 남은 거리의 6%씩 다가가므로 ×7 에 가까울수록 느려져, 0.25초에 ×1.46, 0.5초에 ×3.77, 1.0초에 ×6.35 가 된다. 04 의 외톨이 판정은 창 안이 아니라 창 바로 바깥 한 칸과 그 너머 한 칸만 보므로, 후보 하나를 판정하는 데 칸 두세 개만 더 읽는다. 07 은 칸 길이 합 276px 를 꺾임 4곳에서 둥글려 265px 로 11px 줄이고, 모서리 곡선을 8토막씩 잘라 만든 누적 길이 표에서 이진 탐색으로 위치를 찾아 걸음 폭을 고르게 맞춘다.

좌석표가 깨지는 곳 — 만 석에서 버벅이는 다시 그리기·두 사람이 동시에 잡은 같은 자리·외톨이 빈칸이 남는 일행 배정

화면에 빈자리로 보여도 이미 팔린 자리

05 에서 E-07 은 0.3초에 내 것이 되어 보라 고리를 두르지만, 1.0초에 다른 창구의 결제가 먼저 끝나 팔린다. 1.0초까지 다른 구매자 15명이 자리를 잡아 간 뒤였다. 화면은 흔들림 x = 7 × e^(−t/0.12) × sin(2π·9·t) 로 알리는데, 폭이 0.1초에 3.04px, 0.2초에 1.32px, 0.4초에 0.25px 로 줄어 0.45초 안에 잦아든다. 그다음 칸 거리 + 3 × (1 − 점수) 가 가장 작은 D-06 을 다시 잡는다 — 한 줄 앞·한 칸 옆이라 거리 √2 ≈ 1.414칸, 점수 0.856 이라 1.414 + 3 × 0.144 ≈ 1.85 다. 실제 예매 화면에서 칠해 둔 상태는 마지막으로 받은 소식일 뿐이니, 잡기 요청이 거절될 때 이렇게 곧바로 대안을 내미는 길을 처음부터 만들어 둬야 한다.

점수가 가장 높은 묶음이 외톨이를 남길 때

04 의 F 줄은 가운데 다섯 칸(F-06~F-10)이 비어 있다. 4명 창은 그 안에 두 번 들어가고 점수 합도 1·2위지만, 어느 쪽이든 한 칸을 외톨이로 남겨 둘 다 지워지고, 후보 6개 가운데 H-06~H-09 까지 3개가 같은 이유로 빠진다. 04 는 bestBlock 을 두 번 불러 외톨이를 막은 결과와 허용한 결과를 함께 들고 있다가, 둘이 다르면 허용했을 때의 1위를 산호 점선으로 남긴다. 04 폴더의 index.html 에서 bestBlock 의 두 번째 인자를 바꿔 이 비교를 직접 해 보려면, zip 을 풀 때 암호 칸에 74ar6jn4 을 입력하면 된다. 1위를 그대로 고르면 F-10 이 혼자 남고, 그 자리는 혼자 오는 손님 말고는 채울 수 없다.

만 석을 매 프레임 다시 그릴 때

06 의 11,720석을 낮은 배율에서 하나씩 그리면 사각형이 11,720개다. 그 배율에서는 구역 다각형 24개만 그리면 되고, 높은 배율에서는 forVisibleSeats 가 화면 밖 구역을 경계 상자로 통째로 건너뛰어 ×3.77 에서 1,792석, ×7 에서 742~799석(전체의 6.3~6.8%)만 넘겨준다. 가장 무거운 때는 구역과 자리를 함께 그리는 갈라짐 구간으로, ×2.9 에서 2,567석이다. 그래서 06 은 빈자리·팔린 자리·빗금을 각각 Path2D 하나에 모아 세 번만 칠한다.

색만으로 구분하지 않는 좌석표 — 무늬·좌석 번호 알림 문장·움직임 줄이기의 접근성

색을 빼도 남는 자리 상태

01·04·05·07·08·09 가 부르는, 자리 한 칸을 그리는 공용 drawSeat 에서 빈자리는 연한 바탕에 1.1px 진한 테두리, 팔린 자리는 회색에 45° 빗금, 잡아 둔 자리는 버터색에 2px 테두리, 고른 자리는 민트에 체크다. 흑백으로 출력해도 테두리·빗금·체크로 네 상태가 갈린다. 04 의 탈락 후보는 산호 점선, 08 의 거둔 자리와 건너뛴 자리는 산호 빗금, drawSeat 를 쓰지 않는 06 도 팔린 자리에 빗금을 따로 쳐서 같은 원칙을 따른다.

키보드로 자리 고르기 — 01·02·03

01·02·03 에는 진짜 <button> 인 「이전 자리」「다음 자리」가 있고, 왼쪽·오른쪽 방향키는 옆 자리로, 위·아래는 앞뒤 줄에서 각도가 가장 가까운 자리로 옮긴다. 고른 자리 번호는 상태 줄이 문장으로 알린다. 03 은 일시정지나 움직임 줄이기 중에 자리를 고르면 카메라가 미끄러지지 않고 그 자리로 바로 옮겨 간다.

움직임 줄이기와 일시정지

아홉 장면 모두 prefers-reduced-motion 의 change 까지 듣고, 켜지면 순환을 멈춘 채 자리가 모두 정해진 마지막 한 장을 그린다. 01·02·03 은 그 상태에서도 자리를 고르면 곧바로 다시 그린다. 일시정지 버튼은 aria-pressed 로 눌림을 알리고, 상태 줄은 처음에 aria-live="off" 였다가 첫 입력 뒤에야 polite 로 바뀌어 자동 시연이 쉬지 않고 읽히는 일을 막는다. 09 의 포스터 이미지는 자리가 앉으며 점수 색이 퍼지는 순간의 한 장이고 표가 뒤집히는 모습은 1.6초부터 나온다. 움직임 줄이기에서 09 가 남기는 한 장은 restPose 가 시계를 FLIP_AT_S + FLIP_S + 0.5, 곧 뒤집기가 끝나고 0.5초 지난 2.9초에 맞춘 표 앞면이다.

단을 가파르게, 일행을 많게, 확대를 빠르게 — 숫자 하나로 달라지는 예매 화면

이름 바꾼 값 달라지는 것
단을 가파르게 02 RAKE_STEP 0.12 → 0.3 E 줄 틈 1.58 → 8.55cm, 평균 56 → 68, 최고 자리 G-07(68) → F-06(81), 맨 뒷줄이 내려다보는 각 6.67° → 14.83°
일행을 많게 04 GROUP_SIZE 4 → 6 후보 6 → 1, 외톨이 탈락 3 → 0, 추천 E-05~E-08 → H-05~H-10
확대를 빠르게 06 ZOOM_EASE 0.06 → 0.3 ×7 의 95% 도달 1.221 → 0.375초, 구역이 자리로 갈라지는 구간 196 → 33ms

단을 0.3 으로 올리면 틈 계산은 눈 2.35 m 에서 0.95 + 1.4 × 0.8468 = 2.1355 m, 앞줄 눈 2.05 m 를 빼 8.55cm 가 된다. 03 에서 E-07 의 머리 가림도 39% → 24%, K-13 은 49% → 34%, G-02 는 32% → 27% 로 준다. 그 대가가 맨 뒷줄의 내려다보는 각이라, 가파른 단에서는 뒷줄이 무대를 더 숙여 보게 된다. 일행을 6명으로 늘리면 여섯 칸이 연달아 빈 곳이 한 군데뿐이라 추천이 앞의 E 줄에서 뒤쪽 H 줄로 밀리고 점수 합은 5.08 이 된다. 확대를 0.3 으로 빠르게 하면 갈라짐 구간을 두 프레임 만에 지나가 구역이 툭 자리로 바뀌니, 갈라짐을 눈으로 보여 주려면 0.06 근처가 맞는다.

좌석 선택 화면을 직접 만들 때 자주 묻는 질문

공연장 좌석 시야 점수를 우리 객석 치수로 바꾸려면 어디를 고치나요?

index.html 안의 공용 상수 FIRST_ROW_M(첫 줄 거리 2.4)·ROW_DEPTH_M(줄 간격 0.95)·RAKE_STEP(단 높이 0.12)·EYE_H(앉은 눈높이 1.15)·STAGE_H(무대 높이 0.95)·STAGE_W_M(무대 폭 8)을 객석 단면에 맞추면 02 의 점수와 03 의 투영이 함께 바뀐다. 점수 다섯 몫의 무게는 SCORE_WEIGHTS 한 줄이다.

05 를 실제 예매 서버와 붙이려면 무엇을 바꾸나요?

05 는 planBooking 이 시드로 미리 만든 사건 목록을 시각 순서대로 좌석 상태에 옮긴다. 이 목록 대신 서버에서 오는 잡기·결제 소식을 같은 모양(시각·종류·자리)으로 넣으면 흔들림과 대안 고르기는 그대로 쓸 수 있다.

11,720석 지도가 휴대폰에서 버벅이지 않나요?

06 은 화면에 걸친 구역만 훑고 그 안에서도 화면 밖 자리를 건너뛰며, 같은 상태의 자리를 Path2D 하나로 모아 칠한다. 캔버스 화소는 기기 화소 비율을 2 까지만 곱하고, 그리기 좌표는 setTransform 으로 480×300 에 고정한다. 탭이 숨으면 순환을 멈추고 돌아오면 다시 시작한다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.