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

css traffic light 9가지 — 신호에 맞춰 차가 서고 초록 물결이 이어진다

css traffic light 를 색만 바꾸는 타이머로 만들면 차는 빨강에도 밀고 들어오고, 신호가 줄줄이 따로 놀고, 걷는 사람은 남은 초를 모른다 — 02번·03번·04번이 계산으로 푼다. 아래 아홉 장면은 캔버스 한 장에 직접 그리고, 파일째 받아 붙이면 바로 돈다.

교차로 아홉 장면을 화면에서 바로 본다

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

css traffic light 9가지 — 간격·시차·감지를 계산해야 교차로가 살아 있다

아홉 장면은 모두 480×300(W·H) 캔버스 한 장과 상태 글자 한 줄, 버튼 하나, 캡션으로 이루어진다. 입력과 시간을 맡는 main 과 차를 흘려보내는 Lane 클래스는 아홉 파일이 같은 글자이고, 장면마다 다른 것은 makeScene 하나다. 한 번 호출이 한 프레임이며 시간은 16밀리초(FIXED_DT) 단위로 끊어 쌓고, 차가 들어오는 시점과 색은 시드 446 의 mulberry32 로 정해서 다시 열어도 같은 차가 같은 자리에 선다.

색만 돌리는 타이머는 빨강·노랑·초록을 시간표대로 켤 뿐이라 차도 사람도 신호를 모른다. 그래서 이 편은 세 가지 계산을 얹었다. 앞차와의 간격과 정지선까지 거리에서 가속도를 구하는 식, 신호마다 시작 시각을 늦추는 식, 감지선을 지나는 차 사이 간격으로 초록 길이를 정하는 식이다.

차량 계산의 뼈대는 Lane.accelFor 하나다. 목표 간격을 s = g0 + max(0, v·headway + v·(v − vL) ÷ (2·√(accel·brake))) 로 잡고, 가속도를 accel·(1 − (v ÷ vmax)^δ − (s ÷ s)²) 로 돌려준다. headway·accel·brake 는 장면이 Lane 을 만들 때 넘기는 값이다. 앞차와의 간격에서 나온 값과 정지선까지 거리에서 나온 값 중 작은 쪽을 쓰며 하한은 −3·brake 이다(02 에서는 BRAKE_DECEL 0.07 이라 −0.21). δ 는 02·07·09 에서 4, 03 에서 40 이라 03 은 순항 속도 근처까지 가속을 유지해 한 무리가 흩어지지 않는다. 이 클래스를 실제로 만드는 장면은 02·03·07·09 네 개이고, 01·04·05·06·08 은 시간표·호·시야·반사·충돌 표로 푼다.

Math.random·Date·fetch·localStorage·navigator·AudioContext·getImageData 는 아홉 파일에 한 번도 나오지 않고 WebGL 도 쓰지 않는다. 그래프 선을 곡선으로 잇는 방법은 css 그래프 만들기 SVG로 9종 — 라이브러리 없이가 다루고, 이 편의 07 은 같은 생각을 캔버스에서 신호 색 위에 얹는다.

기법별 코드로 보는 신호등

각 장면 index.html 에서 이어진 줄만 잘라 실었고 한 조각은 25줄 안에서 끊었다. traffic light css code 를 찾아 온 사람이 그대로 붙여 시험할 수 있는 크기이며, 처음부터 끝까지 이어진 파일은 압축 파일에 들어 있다.

01녹색·황색·적색이 시간표대로 넘어가는 신호등

phaseAt 이 프레임 번호를 한 주기(초록 100·노랑 30·빨강 100)로 나눈 나머지를 보고 신호를 고르고, relax 가 렌즈 셋의 밝기를 목표 쪽으로 프레임마다 LIT_RISE 0.16 만큼만 끌어당긴다. 번짐 반지름은 밝기 1 에서 78 이고 알파는 밝기의 1.6 제곱(LENS_GAMMA)이라 켜지는 렌즈는 부풀고 꺼지는 렌즈는 잦아든다. 측정은 누적면적 45.4%, 움직인 프레임 23/23 이다.

녹색·황색·적색이 시간표대로 넘어가는 신호등
새 창에서 열기

01 · 녹색·황색·적색이 시간표대로 넘어가는 신호등 — 측정 누적면적 45.421% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PHASE_FRAMESYELLOW_FRAMESBLOOM_RADIUSLENS_GAMMA
    function phaseAt(f, g, y, r) {
        const total = g + y + r;
        const m = ((f % total) + total) % total;
        return m < g ? SIG_GREEN : (m < g + y ? SIG_YELLOW : SIG_RED);
    }
    function stateText(state) {
        return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
    }
        // 켜지는 쪽은 부풀고 꺼지는 쪽은 잦아든다 — 목표 밝기를 향해 한 프레임에 LIT_RISE 만큼
        function relax() {
            const t = litOf(state());
            for (let i = 0; i < 3; i += 1) {
                lit[i] += (t[i] - lit[i]) * (reduce ? 1 : LIT_RISE);
            }
        }

02빨간불에 앞차 뒤로 간격을 두고 서는 차들

accelFor 가 앞차와의 간격에서 나온 값과 정지선을 속도 0 인 앞차로 본 값 중 작은 쪽을 쓰고, 신호가 초록이 아니게 된 첫 프레임에 v² ÷ (2·BRAKE_DECEL) 이 정지선까지 거리보다 큰지 한 번만 보고 설지 지나갈지 정한다. 시뮬레이션으로 6000프레임을 돌려 센 64대 중 빨강에 정지선을 넘은 차는 0대다. 측정은 누적면적 10.2%, 움직인 프레임 23/23 이다.

빨간불에 앞차 뒤로 간격을 두고 서는 차들
새 창에서 열기

02 · 빨간불에 앞차 뒤로 간격을 두고 서는 차들 — 측정 누적면적 10.192% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SAFE_GAPBRAKE_DECELSTOP_LINE_XACCEL
                    else {
                        if (!c.decided) {
                            c.decided = true;
                            c.go = c.v * c.v / (2 * p.brake) > sg.x - c.x;
                        }
                        if (!c.go) {
                            acc = Math.min(acc, this.accelFor(c.v, sg.x - c.x - 2, 0, p.lineGap));
                            clampX = sg.x - 1.5;
                        }
                    }
                acc = Math.max(acc, -p.brake * 3);
                const prevX = c.x;
                c.v = Math.max(0, c.v + acc);
                c.x += c.v;
                if (c.x > clampX && prevX <= clampX) {
                    c.x = clampX;
                    c.v = 0;
                }
            // 누르면 신호가 지금 상태에서 바로 다음 단계로 넘어간다
            tap: function () {
                const m = (((f + skew) % cycle) + cycle) % cycle;
                const target = sig.state === SIG_RED ? 0 : (sig.state === SIG_GREEN ? GREEN_LEN : GREEN_LEN + YELLOW_LEN);
                skew += ((target - m) % cycle + cycle) % cycle;
                tickSignal();
            },

03블록마다 시차를 둔 네 신호와 이어지는 초록 물결

신호 k 의 상태를 phaseAt 에 f - k * OFFSET_PER_BLOCK 를 넘겨 구해 신호마다 시작을 90프레임씩 늦추고, 한 블록(100px)을 프레임당 1.15px 로 가는 데 87.0프레임이 걸려 한 무리 3대가 네 신호를 서지 않고 지난다. 블록마다 3.0프레임씩 늦는 셈이라 네 번째 신호에서는 누적 9프레임이 어긋나지만 초록이 150프레임이라 그 차이는 흡수된다. 측정은 누적면적 12.7%, 움직인 프레임 23/23 이다.

블록마다 시차를 둔 네 신호와 이어지는 초록 물결
새 창에서 열기

03 · 블록마다 시차를 둔 네 신호와 이어지는 초록 물결 — 측정 누적면적 12.669% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CYCLE_LENOFFSET_PER_BLOCKPLATOON_SPEEDBLOCK_LEN
        // 신호 k 는 앞 신호보다 OFFSET_PER_BLOCK 프레임 늦게 같은 주기를 시작한다
        function stateOf(k) {
            return phaseAt(f - k * OFFSET_PER_BLOCK, GREEN_LEN, YELLOW_LEN, CYCLE_LEN - GREEN_LEN - YELLOW_LEN);
        }
        function progress(k) {
            return ((((f - k * OFFSET_PER_BLOCK) % CYCLE_LEN) + CYCLE_LEN) % CYCLE_LEN) / CYCLE_LEN;
        }
                const m = f % CYCLE_LEN;
                if (m === PLATOON_AT) {
                    toSpawn = PLATOON_SIZE;
                }
                if (toSpawn > 0 && lane.maybeSpawn(f, 0, 0, PLATOON_SPEED)) {
                    toSpawn -= 1;
                }
                lane.step(sigs, none, -1);

04남은 초를 줄어드는 호로 보여 주는 보행 신호

남은 12초를 원 둘레 RING_CIRCUMFERENCE ÷ 12 = 30.4px 에서 SEG_GAP 5 를 뺀 25.4px 호 조각으로 나눠 한 칸씩 지우고, 남은 초가 FLASH_AT 5 이하가 되면 9프레임마다 호는 빨강과 노랑, 숫자는 빨강과 흰색으로 번갈아 바뀐다. 이미 건너는 사람은 HURRY_SPEED 1.5 로 빨라지고 서 있는 사람은 깜빡임이 시작되면 출발하지 않는다. 측정은 누적면적 13.6%, 움직인 프레임 14/23 이다.

남은 초를 줄어드는 호로 보여 주는 보행 신호
새 창에서 열기

04 · 남은 초를 줄어드는 호로 보여 주는 보행 신호 — 측정 누적면적 13.559% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WALK_SECONDSFLASH_ATRING_CIRCUMFERENCETICK_STEP
        function inWalk() { return (f % cycle) >= WAIT_FRAMES; }
        function secondsLeft() {
            if (!inWalk()) {
                return 0;
            }
            return (cycle - (f % cycle)) / SECOND_FRAMES;
        }
        function flashing() { return inWalk() && secondsLeft() <= FLASH_AT; }
                for (let i = 0; i < peds.length; i += 1) {
                    const p = peds[i];
                    if (!p.crossing) {
                        if (inWalk() && !flashing() && f >= p.startAt) {
                            p.crossing = true;
                        }
                    }
                    else {
                        p.x += p.dir * (flashing() || !inWalk() ? HURRY_SPEED : WALK_SPEED);
                        const done = p.dir > 0 ? p.x >= ROAD_X1 + 12 : p.x <= ROAD_X0 - 12;
                        if (done) {
                            p.crossing = false;
                            p.dir = -p.dir;
                            p.x = p.dir > 0 ? ROAD_X0 - 12 : ROAD_X1 + 12;
                            p.startAt = lastWalkStart + 99999;
                        }
                    }
                }

05보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등

눈 위치와 렌즈 중심의 차이를 EYE_DISTANCE 190 으로 나눈 비율에 VISOR_DEPTH 24 를 곱해 챙 입구 원을 렌즈 위에서 밀고, 렌즈를 9×9 격자(SAMPLE_GRID)로 훑어 입구 안에 든 칸의 비율을 신호등마다 퍼센트로 적는다. 포인터가 없을 때는 autoPath 가 눈을 5200밀리초 주기로 움직이는데, 가로는 사인 하나(진폭 214px)이고 세로는 1.7배 빠른 사인(진폭 96px)이라 눈이 서로 어긋난 두 사인이 겹친 곡선을 따라 움직인다. 측정은 누적면적 18.5%, 움직인 프레임 23/23 이다.

보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등
새 창에서 열기

05 · 보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등 — 측정 누적면적 18.474% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VISOR_DEPTHVIEW_TILT_MAXSHADOW_BLURLENS_OFFSET
        // 눈 위치에서 본 챙 입구 원의 이동량 — 눈이 오른쪽이면 입구가 왼쪽으로 밀려 렌즈 오른쪽이 가려진다
        function shiftOf(hx, ly) {
            const dx = (ex - hx) / EYE_DISTANCE;
            const dy = (ey - ly) / EYE_DISTANCE;
            const lim = VIEW_TILT_MAX / VISOR_DEPTH;
            return [-VISOR_DEPTH * clamp(dx, -lim, lim), -VISOR_DEPTH * clamp(dy, -lim, lim)];
        }
        // 렌즈 안을 격자로 훑어 입구 안에 든 점의 비율
        function visibleOf(hx, ly) {
            const s = shiftOf(hx, ly);
            let hit = 0;
            let all = 0;
            for (let i = 0; i < SAMPLE_GRID; i += 1) {
                for (let j = 0; j < SAMPLE_GRID; j += 1) {
                    const x = (i + 0.5) / SAMPLE_GRID * 2 - 1;
                    const y = (j + 0.5) / SAMPLE_GRID * 2 - 1;
                    if (x * x + y * y > 1) {
                        continue;
                    }
                    all += 1;
                    const ax = x * LENS_R - s[0];
                    const ay = y * LENS_R - s[1];
                    if (ax * ax + ay * ay <= rimRadius() * rimRadius()) {
                        hit += 1;
                    }
                }
            }
            return hit / all;
        }

06젖은 도로에 길게 번지는 신호등 빛 반사

원본 장면을 따로 그려 두고 도로 행마다 거꾸로 된 원본 행을 REFLECT_STRETCH 1.7 로 늘려 가져오며, 20px 조각마다 vnoise 값 잡음으로 가로로 흔들고 멀수록 알파를 줄인다. 한 프레임에 drawImage 가 90행×24조각 = 2160번 불린다. 측정은 누적면적 45.0%, 움직인 프레임 23/23 이다.

젖은 도로에 길게 번지는 신호등 빛 반사
새 창에서 열기

06 · 젖은 도로에 길게 번지는 신호등 빛 반사 — 측정 누적면적 45.003% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REFLECT_STRETCHRIPPLE_AMPFADE_RATENOISE_SCALE
                // 반사 — 도로 행마다 원본의 거꾸로 된 행을 가져오고, 잡음으로 가로로 흔들고, 멀수록 흐려진다
                const depth = H - HORIZON;
                const t = reduce ? 0 : f * 0.06;
                for (let dy = 0; dy < depth; dy += ROW_STEP) {
                    const sy = HORIZON - 1 - dy / REFLECT_STRETCH;
                    if (sy < 0) {
                        break;
                    }
                    const near = dy / depth;
                    const alpha = Math.max(0, 0.82 - near * FADE_RATE * 0.8);
                    if (alpha <= 0.02) {
                        continue;
                    }
                    ctx.globalAlpha = alpha;
                    for (let sx = 0; sx < W; sx += SEG_W) {
                        const n = vnoise(sx * 0.031 + t * 0.7, dy * NOISE_SCALE + t) * 2 - 1;
                        const dx = n * RIPPLE_AMP * (0.35 + near * 1.1);
                        ctx.drawImage(src, sx, Math.floor(sy), SEG_W, 1, sx + dx, HORIZON + dy, SEG_W, ROW_STEP + 0.6);
                    }
                }

07줄 길이가 신호 주기마다 오르내리는 실시간 그래프

6프레임마다(SAMPLE_EVERY) 정지선 앞에 선 차 수를 재서 56점(GRAPH_POINTS)으로 쌓고, 점 사이는 이웃 점의 중간점을 잇는 2차 곡선으로 이으며 LINE_SMOOTH 0.5 가 휘어짐을 정한다. 점마다 그 순간의 신호 색을 뒤에 깔아 줄이 어느 신호에서 늘고 줄었는지 읽힌다. 측정은 누적면적 29.7%, 움직인 프레임 23/23 이다.

줄 길이가 신호 주기마다 오르내리는 실시간 그래프
새 창에서 열기

07 · 줄 길이가 신호 주기마다 오르내리는 실시간 그래프 — 측정 누적면적 29.664% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SAMPLE_EVERYGRAPH_POINTSY_MAX_CARSLINE_SMOOTH
        function sample() {
            hist.push(lane.waiting(STOP_LINE_X));
            histState.push(sig.state);
            if (hist.length > GRAPH_POINTS + 1) {
                hist.shift();
                histState.shift();
            }
        }
        function px(i, shift) {
            return GRAPH_X1 - (hist.length - 1 - i + shift) * (GRAPH_X1 - GRAPH_X0) / GRAPH_POINTS;
        }
        function py(v) {
            return GRAPH_Y0 - clamp(v / Y_MAX_CARS, 0, 1) * (GRAPH_Y0 - GRAPH_Y1);
        }
                    for (let i = 1; i < n; i += 1) {
                        const bx = px(i, shift);
                        const by = py(hist[i]);
                        const ex = i < n - 1 ? (bx + px(i + 1, shift)) / 2 : bx;
                        const ey = i < n - 1 ? (by + py(hist[i + 1])) / 2 : by;
                        ctx.quadraticCurveTo(lerp((sx + ex) / 2, bx, LINE_SMOOTH), lerp((sy + ey) / 2, by, LINE_SMOOTH), ex, ey);
                        sx = ex;
                        sy = ey;
                    }

08사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표

방향 8개마다 부딪히는 방향을 비트 표(CONFLICT_MASK)로 만들고, 앞 방향부터 충돌 없는 묶음에 넣어 네 단계(PHASE_ORDER)를 얻는다. 단계마다 초록 72프레임 뒤에 모두 빨강인 틈 16프레임(ALL_RED_GAP)을 두므로 한 바퀴는 352프레임이다. 측정은 누적면적 20.4%, 움직인 프레임 20/23 이다.

사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표
새 창에서 열기

08 · 사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표 — 측정 누적면적 20.43% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PHASE_ORDERARROW_LENALL_RED_GAPCONFLICT_MASK
        function collides(a, b) {
            if (a.approach === b.approach) {
                return true;
            }
            if ((a.approach + 2) % 4 === b.approach) {
                return a.left !== b.left;
            }
            return true;
        }
        // 위상 묶기 — 먼저 나온 묶음에 충돌하는 방향이 없으면 거기에 넣는다
        const PHASE_ORDER = [];
        for (let i = 0; i < moves.length; i += 1) {
            let placed = false;
            for (let g = 0; g < PHASE_ORDER.length && !placed; g += 1) {
                let ok = true;
                for (let k = 0; k < PHASE_ORDER[g].length; k += 1) {
                    if ((CONFLICT_MASK[i] >> PHASE_ORDER[g][k]) & 1) {
                        ok = false;
                    }
                }
                if (ok) {
                    PHASE_ORDER[g].push(i);
                    placed = true;
                }
            }
            if (!placed) {
                PHASE_ORDER.push([i]);
            }
        }

09차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호

감지선을 차가 지날 때 gapTimer 가 60 미만이면 초록 한도(budget)를 지금 초록 길이에 EXTEND_STEP 50 을 더한 값으로 늘리되 MAX_GREEN 300 을 넘기지 않고, MIN_GREEN 90 이후 gapTimer 가 60 이상이 되면 한도를 지금 시각으로 줄여 초록을 거둔다. 늘릴 때마다 막대에 점이 하나 찍힌다. 측정은 누적면적 10.0%, 움직인 프레임 23/23 이다.

차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호
새 창에서 열기

09 · 차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호 — 측정 누적면적 10.003% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAP_THRESHOLDEXTEND_STEPMAX_GREENDETECTOR_X
        function onDetect(c, x) {
            detLit = 8;
            if (sig.state !== SIG_GREEN) {
                return;
            }
            if (gapTimer < GAP_THRESHOLD) {
                budget = Math.min(MAX_GREEN, Math.max(budget, greenElapsed + EXTEND_STEP));
                pips.push(greenElapsed);
            }
            gapTimer = 0;
        }
                if (sig.state === SIG_GREEN) {
                    greenElapsed += 1;
                    gapTimer += 1;
                    if (greenElapsed >= MIN_GREEN && gapTimer >= GAP_THRESHOLD && greenElapsed < budget) {
                        budget = greenElapsed;
                        ended = 'gap';
                    }
                    if (greenElapsed >= MAX_GREEN) {
                        budget = greenElapsed;
                        ended = 'max';
                    }
                    if (greenElapsed >= budget) {
                        sig.state = SIG_YELLOW;
                        phaseF = 0;
                        if (ended === '') {
                            ended = 'gap';
                        }
                    }
                }

값이 장면을 바꾸는 곳 — 정지 간격·신호 시차·초록 상한

값 하나로 장면의 성격이 바뀌는 곳은 셋이다. 02 의 정지 간격 SAFE_GAP, 03 의 신호 시차 OFFSET_PER_BLOCK, 09 의 초록 상한 MAX_GREEN 이고, 아래 숫자는 같은 소스를 노드에서 헤드리스로 6000~9000프레임 돌려 센 값이라 브라우저 화면에서 직접 잰 수치와는 구별한다. traffic light animation css 를 값 하나로 바꿔 보고 싶다면 이 셋부터 건드리면 된다.

정지 간격을 줄이면 줄이 길어진다

SAFE_GAP 24 에서 서 있는 차 사이 간격은 18.8~19.1px 이고 정지선 앞에 서는 차는 최대 3대다. 12 로 줄이면 간격이 10.1~10.3px, 서는 차는 최대 5대가 된다(6000프레임 시뮬레이션).

신호 시차가 87.0 에서 벗어나면 차가 선다

OFFSET_PER_BLOCK 90 에서는 62대 가운데 멈춘 차가 한 대도 없다(stopFrames 0). 40 으로 줄이면 38대가 멈추고 한꺼번에 2대까지 서며 멈춘 차의 프레임 합은 3915다.

초록 상한을 낮추면 잘리는 횟수가 늘어난다

9000프레임에서 초록은 25회 켜지고 길이는 최소 90·평균 204·최대 300이며, 상한에 닿아 잘린 것이 9회, 차가 끊겨 거둔 것이 16회다. MAX_GREEN 을 150 으로 줄이면 초록 31회·평균 143·상한 도달 26회·거둠 5회로 바뀐다.

아홉 장면을 렌더해서 잰 값은 아래 표다. 10% 아래로 내려간 장면은 없고 가장 작은 09 가 10.0%, 02 가 10.2% 이며 01 과 06 은 45% 안팎이다. 움직인 프레임은 04 가 14/23, 08 이 20/23 이고 나머지 일곱 장면은 23/23 이라 시연 동안 화면이 거의 쉬지 않는다.

항목 누적면적 평균 강도 움직인 프레임
01 시간표 신호등 45.4% 109.2 23/23
02 간격을 두고 서는 차 10.2% 48.2 23/23
03 초록 물결 12.7% 51.2 23/23
04 보행 카운트다운 호 13.6% 40.5 14/23
05 챙 그림자 시야각 18.5% 50.5 23/23
06 젖은 도로 반사 45.0% 33.4 23/23
07 줄 길이 그래프 29.7% 46.3 23/23
08 사거리 위상 화살표 20.4% 71.4 20/23
09 감응 초록 연장 10.0% 63.3 23/23

어디서 깨지는가 — 좁은 화면·차가 정지선을 넘을 때·색만으로 구분할 때

마주 보는 좌회전 곡선이 모서리에서 맞닿는다

08 의 collides 표는 마주 보는 좌회전 둘을 충돌로 보지 않지만, 그려진 곡선 두 개는 교차로 한가운데 오른쪽 아래와 왼쪽 위 모서리(약 ±10px)에서 거의 닿는다. 정답은 표이고 그림은 단순화다. 교차로 안을 달리는 점은 켜진 단계에서만 그려지는 장식이라 단계가 끝나면 그 자리에서 사라지며 차 물리가 아니다.

걷기 153프레임 뒤에 출발한 사람은 빨강에도 길에 남는다

04 는 걷기 시작 182프레임(남은 5초) 뒤에는 새로 출발시키지 않는다. 길 218px 을 0.8px/프레임으로 건너면 272.5프레임, 깜빡임 뒤 1.5px/프레임이면 145.3프레임이라, 걷기 153프레임 이후에 출발한 사람은 걷기(312프레임)가 끝난 뒤에도 길에 있다. 마지막 허용 출발인 181프레임이면 14프레임(0.2초) 더 걸려 빨강 표시 뒤에도 건넌다.

서 있는 차 간격이 SAFE_GAP 보다 5px 짧다

02 는 SAFE_GAP 24 를 목표로 잡지만 서 있는 차 사이 실측은 18.8~19.1px 다. 감속 하한 −0.21 과 정지선 −1.5px 에서 속도 0 으로 잘리는 처리를 후보로 보지만 원인을 나눠 확인하지는 않았다.

반사에서 빨강 렌즈가 사라지는 값

06 의 도로 180행은 REFLECT_STRETCH 1.7 에서 원본 106행(y 14 이후)을 덮어 가까운 신호등 빨강 렌즈(원본 y 24.5)가 반사에 들어온다. 2.1 로 키우면 같은 180행이 86행(y 34 이후)만 덮어 그 렌즈는 반사에서 빠지고 초록 렌즈만 맨 아래에 남는다. 흔들림은 20px 조각 단위라 조각 경계에서 어긋남이 보이고, 가까운 도로에서는 최대 ±10px 까지 흔들린다.

방향키가 페이지 스크롤을 막는다

캔버스에 포커스가 있으면 방향키는 장면이 쓰지 않아도 preventDefault 를 부르고, Enter·Space 도 같다. 05 외 장면에서는 방향키가 아무 일도 하지 않는데 스크롤만 막히므로, 이 코드를 긴 페이지에 붙이면 캔버스에서 키보드 스크롤이 멈추는지 먼저 본다. 02·09 에서는 Enter·Space 가 멈춤이 아니라 신호 전환과 차 투입이라 멈춤은 버튼으로만 된다.

신호를 색으로만 읽게 두지 않는다

렌즈는 위에서부터 빨강·노랑·초록 순서(LENS_RGBS)로 놓여 있고, 상태는 초록불 — 진행·노란불 — 정지 준비·빨간불 — 정지 같은 글자로도 aria-live 영역에 적힌다. 색을 못 보는 상황에서도 위치와 글자가 상태를 알려 주는 구조이고, 신호 색 세 값(GREEN_RGB·YELLOW_RGB·RED_RGB)은 기능색이라 배경 팔레트와 따로 둔다.

좁은 화면과 움직임 판정

캔버스는 aspect-ratio 480 / 300 으로 폭만 줄고 아홉 장면의 넘침·폰 폭 넘침 판정은 모두 아니오다. 320px 이하에서는 캡션·버튼·상태 글자가 10px 로 줄고 패딩이 $sp-2 로 준다. 움직인 프레임이 23 에 못 미치는 장면의 원인은 확인하지 않았다.

직접 고쳐 보며 경계를 확인하는 법

02 의 SAFE_GAP, 03 의 OFFSET_PER_BLOCK, 06 의 REFLECT_STRETCH, 09 의 MAX_GREEN 을 하나씩 바꿔 가며 위 경계가 나타나는 값을 찾을 수 있다. 어느 값에서 어떻게 틀어지는지 손으로 보고 싶다면 아래 파일을 비밀번호 5d7zsm93 으로 연 뒤 이 상수들을 바꿔 본다.

변형 세 가지와 접근성

이름 값 느낌
정지 간격 바꾸기 02 SAFE_GAP 24 → 12 간격을 12 로 줄이면 서 있는 차 사이가 19px 에서 10px 으로 좁아지고, 정지선 앞에 서는 차가 최대 3대에서 5대로 늘어 줄이 길어진다.
신호 시차 바꾸기 03 OFFSET_PER_BLOCK 90 → 40 시차를 40 으로 줄이면 신호가 초록 물결 박자에서 벗어나, 6000프레임 동안 만든 62대 중 멈추는 차가 0대에서 38대로 늘고 한꺼번에 2대까지 줄을 선다.
초록 상한 바꾸기 09 MAX_GREEN 300 → 150 상한을 150 으로 줄이면 초록이 끝까지 늘어나다 잘리는 횟수가 9번에서 26번으로 늘고, 평균 초록 길이는 204프레임에서 143프레임으로 줄어든다.

세 변형의 숫자는 모두 노드 시뮬레이션으로 센 값이다.

prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고, 시작 장면을 75스텝(1200밀리초) 돌려 한 장만 그린다. 버튼 글자는 「다음 장면」 으로 바뀌고 누를 때마다 56스텝(900밀리초)만큼 돌려 다시 그린다. 포인터 동작은 장면마다 달라서, 05 는 눈 위치 보간(lerp 0.16)을 건너뛰고 입력 값을 바로 쓰고, 02 는 눌러서 신호는 바로 바뀌지만 차는 「다음 장면」 을 누르기 전까지 멈추며, 01 은 relax 의 보간 계수가 1 이라 밝기가 곧바로 목표로 간다.

캔버스는 tabindex 0 이라 포커스를 받고 방향키는 가상 포인터를 KEY_STEP 12px 씩 옮기지만 반응하는 장면은 05 뿐이다. 상태 글자는 장면이 정한 문구가 바뀔 때만 aria-live="polite" 영역을 고치므로 같은 문구가 반복해서 읽히지 않는다.

traffic light html css 는 타이머로 색만 바꾸면 충분한가?

신호 하나가 안내 그림이면 충분하다. 01 의 phaseAt 한 줄과 렌즈 밝기 보간이 그 전부이고, 차나 사람이 신호를 보고 움직여야 하는 순간부터 시차·간격·감지 계산이 필요해진다. 색을 켜는 타이머는 신호가 몇 개든 서로의 시작 시각을 모르므로 03 처럼 어긋남을 숫자로 맞춰야 한다.

장면마다 붙는 자리는 다르다. 01 은 신호등 소개·안내 화면, 02 는 교통 흐름 설명 장면, 03 은 도시 교통 소개 히어로, 04 는 횡단보도 안내 위젯, 05 는 제품 상세 보기 장면, 06 은 야간 도시 배경 장면, 07 은 교통 대시보드 지표 카드, 08 은 교차로 구조 설명 도식, 09 는 스마트 교통 서비스 소개에 맞다. 06 은 한 프레임에 2160번 그리는 만큼 가장 무거운 장면이라 배경으로 깔 때는 먼저 이 장면의 프레임 비용을 본다.

광원에서 그림자 모양을 계산하는 쪽은 css 그림자 효과 9가지 — 광원 위치에서 그림자를 계산하는 장면에서 이어지고, 05 의 챙 그림자는 같은 생각을 렌즈 안에서 격자로 센 것이다.

css traffic light 전에 자주 묻는 질문

traffic light using css 로 차까지 움직이게 하려면 어디부터 보나?

02 의 Lane 과 accelFor 부터 읽는다. 처음 바꿀 값은 SAFE_GAP·FREE_SPEED·BRAKE_DECEL 세 개이고, 신호 하나 앞에서 차가 서고 출발하는 구조라 03·07·09 로 넘어가기 전에 읽기 좋다.

신호 색이나 주기를 내 페이지에 맞게 바꾸려면?

색은 GREEN_RGB·YELLOW_RGB·RED_RGB 상수 세 개를, 주기는 phaseAt 에 넘기는 초록·노랑·빨강 프레임 세 값을 바꾼다. 장면마다 상수 이름이 달라 01 은 PHASE_FRAMES 와 YELLOW_FRAMES, 02 는 GREEN_LEN·YELLOW_LEN·RED_LEN 이다.

라이브러리나 이미지가 필요한가?

필요 없다. 아홉 파일 어디에도 외부 주소 호출이 없고 장면을 전부 캔버스 2d 로 그려서, 파일 하나만 옮겨도 돈다.

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

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