css traffic light 9가지 — 신호에 맞춰 차가 서고 초록 물결이 이어진다
css traffic light 를 색만 바꾸는 타이머로 만들면 차는 빨강에도 밀고 들어오고, 신호가 줄줄이 따로 놀고, 걷는 사람은 남은 초를 모른다 — 02번·03번·04번이 계산으로 푼다. 아래 아홉 장면은 캔버스 한 장에 직접 그리고, 파일째 받아 붙이면 바로 돈다.
교차로 아홉 장면을 화면에서 바로 본다
01 tl-signal-phase-timeline-bloom
02 tl-car-follow-stop-line-gap
03 tl-green-wave-offset-avenue
04 tl-pedestrian-countdown-ring
05 tl-visor-shadow-view-angle
06 tl-wet-road-reflection-streak
07 tl-queue-length-live-graph
08 tl-four-way-phase-ring-arrows
09 tl-gap-out-green-extension
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 녹색·황색·적색이 시간표대로 넘어가는 신호등
- 02 빨간불에 앞차 뒤로 간격을 두고 서는 차들
- 03 블록마다 시차를 둔 네 신호와 이어지는 초록 물결
- 04 남은 초를 줄어드는 호로 보여 주는 보행 신호
- 05 보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등
- 06 젖은 도로에 길게 번지는 신호등 빛 반사
- 07 줄 길이가 신호 주기마다 오르내리는 실시간 그래프
- 08 사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표
- 09 차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="녹색·황색·적색이 시간표대로 넘어가는 신호등"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">녹색·황색·적색이 시간표대로 넘어가는 신호등</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const PHASE_FRAMES = 100;
const YELLOW_FRAMES = 30;
const BLOOM_RADIUS = 78;
const LENS_GAMMA = 1.6;
const LIT_RISE = 0.16;
const START_FRAME = 70;
const HEAD_X = 240;
const HEAD_Y = 112;
const LENS_R = 21;
const BAR_X = 60;
const BAR_W = 360;
const BAR_Y = 238;
function makeScene(ctx, reduce) {
const cycle = PHASE_FRAMES * 2 + YELLOW_FRAMES;
let f = START_FRAME;
const lit = [0, 0, 1];
if (reduce) {
lit[0] = 0;
}
function state() { return phaseAt(f, PHASE_FRAMES, YELLOW_FRAMES, PHASE_FRAMES); }
// 켜지는 쪽은 부풀고 꺼지는 쪽은 잦아든다 — 목표 밝기를 향해 한 프레임에 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);
}
}
function segX(frame) { return BAR_X + BAR_W * frame / cycle; }
const scene = {
step: function () {
f += 1;
relax();
},
label: function () { return stateText(state()); },
draw: function () {
ctx.fillStyle = rgba(SKY_RGB, 1);
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 3; i += 1) {
glow(ctx, HEAD_X, HEAD_Y + (i - 1) * LENS_R * 2.5, 260, LENS_RGBS[i], 0.26 * lit[i]);
}
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 214, W, 86);
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, 214, W, 14);
// 기둥
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(HEAD_X - 4, HEAD_Y + LENS_R * 3.6, 8, 214 - (HEAD_Y + LENS_R * 3.6));
// 바닥에 떨어지는 빛 웅덩이 — 켜진 렌즈 색이 번진다
for (let i = 0; i < 3; i += 1) {
if (lit[i] > 0.02) {
ctx.save();
ctx.translate(HEAD_X, 221);
ctx.scale(2.6, 0.3);
glow(ctx, 0, 0, 60, LENS_RGBS[i], 0.8 * lit[i]);
ctx.restore();
}
}
// 렌즈 — 알파는 밝기^LENS_GAMMA, 번짐 반경은 켜진 정도에 따라 자란다
rrect(ctx, HEAD_X - LENS_R * 1.45, HEAD_Y - LENS_R * 2.5 - LENS_R * 1.45, LENS_R * 2.9, LENS_R * 5 + LENS_R * 2.9, LENS_R * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = HEAD_Y + (i - 1) * LENS_R * 2.5;
const a = Math.pow(lit[i], LENS_GAMMA);
ctx.beginPath();
ctx.arc(HEAD_X, ly, LENS_R, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * a);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, HEAD_X, ly, BLOOM_RADIUS * (0.35 + 0.65 * lit[i]), LENS_RGBS[i], 0.78 * a);
glow(ctx, HEAD_X - 6, ly - 6, LENS_R * 0.8, [255, 255, 255], 0.7 * a);
}
}
// 시간표 막대 — 초록 · 노랑 · 빨강 구간과 지금 위치
const g1 = segX(PHASE_FRAMES);
const y1 = segX(PHASE_FRAMES + YELLOW_FRAMES);
ctx.fillStyle = rgba(GREEN_RGB, 1);
rrect(ctx, BAR_X, BAR_Y, g1 - BAR_X, 12, 4);
ctx.fill();
ctx.fillStyle = rgba(YELLOW_RGB, 1);
ctx.fillRect(g1, BAR_Y, y1 - g1, 12);
ctx.fillStyle = rgba(RED_RGB, 1);
rrect(ctx, y1, BAR_Y, BAR_X + BAR_W - y1, 12, 4);
ctx.fill();
const m = ((f % cycle) + cycle) % cycle;
const cx = segX(m);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(cx - 2, BAR_Y - 8, 4, 28);
ctx.beginPath();
ctx.arc(cx, BAR_Y - 8, 5, 0, PI2);
ctx.fill();
}
};
for (let i = 0; i < 40; i += 1) {
relax();
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlSignalPhaseTimelineBloom.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const PHASE_FRAMES = 100;
const YELLOW_FRAMES = 30;
const BLOOM_RADIUS = 78;
const LENS_GAMMA = 1.6;
const LIT_RISE = 0.16;
const START_FRAME = 70;
const HEAD_X = 240;
const HEAD_Y = 112;
const LENS_R = 21;
const BAR_X = 60;
const BAR_W = 360;
const BAR_Y = 238;
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const cycle = PHASE_FRAMES * 2 + YELLOW_FRAMES;
let f = START_FRAME;
const lit = [0, 0, 1];
if (reduce) { lit[0] = 0; }
function state(): number { return phaseAt(f, PHASE_FRAMES, YELLOW_FRAMES, PHASE_FRAMES); }
// 켜지는 쪽은 부풀고 꺼지는 쪽은 잦아든다 — 목표 밝기를 향해 한 프레임에 LIT_RISE 만큼
function relax(): void {
const t = litOf(state());
for (let i = 0; i < 3; i += 1) {
lit[i] += (t[i] - lit[i]) * (reduce ? 1 : LIT_RISE);
}
}
function segX(frame: number): number { return BAR_X + BAR_W * frame / cycle; }
const scene: Scene = {
step: function (): void {
f += 1;
relax();
},
label: function (): string { return stateText(state()); },
draw: function (): void {
ctx.fillStyle = rgba(SKY_RGB, 1);
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 3; i += 1) {
glow(ctx, HEAD_X, HEAD_Y + (i - 1) * LENS_R * 2.5, 260, LENS_RGBS[i], 0.26 * lit[i]);
}
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 214, W, 86);
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, 214, W, 14);
// 기둥
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(HEAD_X - 4, HEAD_Y + LENS_R * 3.6, 8, 214 - (HEAD_Y + LENS_R * 3.6));
// 바닥에 떨어지는 빛 웅덩이 — 켜진 렌즈 색이 번진다
for (let i = 0; i < 3; i += 1) {
if (lit[i] > 0.02) {
ctx.save();
ctx.translate(HEAD_X, 221);
ctx.scale(2.6, 0.3);
glow(ctx, 0, 0, 60, LENS_RGBS[i], 0.8 * lit[i]);
ctx.restore();
}
}
// 렌즈 — 알파는 밝기^LENS_GAMMA, 번짐 반경은 켜진 정도에 따라 자란다
rrect(ctx, HEAD_X - LENS_R * 1.45, HEAD_Y - LENS_R * 2.5 - LENS_R * 1.45, LENS_R * 2.9, LENS_R * 5 + LENS_R * 2.9, LENS_R * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = HEAD_Y + (i - 1) * LENS_R * 2.5;
const a = Math.pow(lit[i], LENS_GAMMA);
ctx.beginPath();
ctx.arc(HEAD_X, ly, LENS_R, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * a);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, HEAD_X, ly, BLOOM_RADIUS * (0.35 + 0.65 * lit[i]), LENS_RGBS[i], 0.78 * a);
glow(ctx, HEAD_X - 6, ly - 6, LENS_R * 0.8, [255, 255, 255], 0.7 * a);
}
}
// 시간표 막대 — 초록 · 노랑 · 빨강 구간과 지금 위치
const g1 = segX(PHASE_FRAMES);
const y1 = segX(PHASE_FRAMES + YELLOW_FRAMES);
ctx.fillStyle = rgba(GREEN_RGB, 1);
rrect(ctx, BAR_X, BAR_Y, g1 - BAR_X, 12, 4);
ctx.fill();
ctx.fillStyle = rgba(YELLOW_RGB, 1);
ctx.fillRect(g1, BAR_Y, y1 - g1, 12);
ctx.fillStyle = rgba(RED_RGB, 1);
rrect(ctx, y1, BAR_Y, BAR_X + BAR_W - y1, 12, 4);
ctx.fill();
const m = ((f % cycle) + cycle) % cycle;
const cx = segX(m);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(cx - 2, BAR_Y - 8, 4, 28);
ctx.beginPath();
ctx.arc(cx, BAR_Y - 8, 5, 0, PI2);
ctx.fill();
}
};
for (let i = 0; i < 40; i += 1) { relax(); }
return scene;
}
export default function TlSignalPhaseTimelineBloom() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="녹색·황색·적색이 시간표대로 넘어가는 신호등" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>녹색·황색·적색이 시간표대로 넘어가는 신호등</p>
</div>
</main>
);
}01 · 녹색·황색·적색이 시간표대로 넘어가는 신호등 — 측정 누적면적 45.421% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="빨간불에 앞차 뒤로 간격을 두고 서는 차들"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">빨간불에 앞차 뒤로 간격을 두고 서는 차들</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const SAFE_GAP = 24;
const BRAKE_DECEL = 0.07;
const ACCEL = 0.04;
const STOP_LINE_X = 372;
const ROAD_Y = 150;
const ROAD_H = 80;
const CAR_LEN = 46;
const CAR_WID = 30;
const GREEN_LEN = 84;
const YELLOW_LEN = 26;
const RED_LEN = 100;
const FREE_SPEED = 2.2;
const HEADWAY = 10;
const SEED = 446;
function makeScene(ctx, reduce) {
const lane = new Lane(SEED, { accel: ACCEL, brake: BRAKE_DECEL, gap: SAFE_GAP, lineGap: 3, vmax: FREE_SPEED, headway: HEADWAY, delta: 4, len: CAR_LEN });
const sig = { x: STOP_LINE_X, state: SIG_GREEN };
const sigs = [sig];
const cycle = GREEN_LEN + YELLOW_LEN + RED_LEN;
let f = GREEN_LEN - 24;
let skew = 0;
function tickSignal() {
sig.state = phaseAt(f + skew, GREEN_LEN, YELLOW_LEN, RED_LEN);
}
function none(c, x) { return; }
const scene = {
step: function () {
f += 1;
tickSignal();
lane.maybeSpawn(f, 30, 56, FREE_SPEED * 0.9);
lane.step(sigs, none, -1);
},
// 누르면 신호가 지금 상태에서 바로 다음 단계로 넘어간다
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();
},
label: function () { return stateText(sig.state); },
draw: function () {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, STOP_LINE_X, 17);
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
// 서 있는 차 사이 간격 — 앞차 뒤꽁무니와 내 앞 범퍼 사이를 칠하고 폭을 적는다
ctx.strokeStyle = rgba(INK_RGB, 0.8);
ctx.lineWidth = 1.5;
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'center';
for (let i = 1; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
const L = lane.cars[i - 1];
if (c.v < 0.25 && L.v < 0.25) {
const a = c.x;
const b = L.x - L.len;
ctx.fillStyle = rgba(YELLOW_RGB, 0.55);
ctx.fillRect(a, ROAD_Y - CAR_WID / 2, b - a, CAR_WID);
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.beginPath();
ctx.moveTo(a, ROAD_Y + CAR_WID / 2 + 7);
ctx.lineTo(b, ROAD_Y + CAR_WID / 2 + 7);
ctx.moveTo(a, ROAD_Y + CAR_WID / 2 + 3);
ctx.lineTo(a, ROAD_Y + CAR_WID / 2 + 11);
ctx.moveTo(b, ROAD_Y + CAR_WID / 2 + 3);
ctx.lineTo(b, ROAD_Y + CAR_WID / 2 + 11);
ctx.stroke();
ctx.fillText(String(Math.round(b - a)), (a + b) / 2, ROAD_Y + CAR_WID / 2 + 22);
}
}
// 신호 — 정지선 위쪽
drawHead(ctx, STOP_LINE_X + 20, ROAD_Y - ROAD_H / 2 - 40, 8, litOf(sig.state));
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(STOP_LINE_X + 18, ROAD_Y - ROAD_H / 2 - 8, 4, 8);
}
};
if (reduce) {
tickSignal();
}
for (let i = 0; i < 330; i += 1) {
scene.step();
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlCarFollowStopLineGap.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const SAFE_GAP = 24;
const BRAKE_DECEL = 0.07;
const ACCEL = 0.04;
const STOP_LINE_X = 372;
const ROAD_Y = 150;
const ROAD_H = 80;
const CAR_LEN = 46;
const CAR_WID = 30;
const GREEN_LEN = 84;
const YELLOW_LEN = 26;
const RED_LEN = 100;
const FREE_SPEED = 2.2;
const HEADWAY = 10;
const SEED = 446;
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const lane = new Lane(SEED, { accel: ACCEL, brake: BRAKE_DECEL, gap: SAFE_GAP, lineGap: 3, vmax: FREE_SPEED, headway: HEADWAY, delta: 4, len: CAR_LEN });
const sig: Sig = { x: STOP_LINE_X, state: SIG_GREEN };
const sigs: Sig[] = [sig];
const cycle = GREEN_LEN + YELLOW_LEN + RED_LEN;
let f = GREEN_LEN - 24;
let skew = 0;
function tickSignal(): void {
sig.state = phaseAt(f + skew, GREEN_LEN, YELLOW_LEN, RED_LEN);
}
function none(c: Car, x: number): void { return; }
const scene: Scene = {
step: function (): void {
f += 1;
tickSignal();
lane.maybeSpawn(f, 30, 56, FREE_SPEED * 0.9);
lane.step(sigs, none, -1);
},
// 누르면 신호가 지금 상태에서 바로 다음 단계로 넘어간다
tap: function (): void {
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();
},
label: function (): string { return stateText(sig.state); },
draw: function (): void {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, STOP_LINE_X, 17);
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
// 서 있는 차 사이 간격 — 앞차 뒤꽁무니와 내 앞 범퍼 사이를 칠하고 폭을 적는다
ctx.strokeStyle = rgba(INK_RGB, 0.8);
ctx.lineWidth = 1.5;
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'center';
for (let i = 1; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
const L = lane.cars[i - 1];
if (c.v < 0.25 && L.v < 0.25) {
const a = c.x;
const b = L.x - L.len;
ctx.fillStyle = rgba(YELLOW_RGB, 0.55);
ctx.fillRect(a, ROAD_Y - CAR_WID / 2, b - a, CAR_WID);
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.beginPath();
ctx.moveTo(a, ROAD_Y + CAR_WID / 2 + 7);
ctx.lineTo(b, ROAD_Y + CAR_WID / 2 + 7);
ctx.moveTo(a, ROAD_Y + CAR_WID / 2 + 3);
ctx.lineTo(a, ROAD_Y + CAR_WID / 2 + 11);
ctx.moveTo(b, ROAD_Y + CAR_WID / 2 + 3);
ctx.lineTo(b, ROAD_Y + CAR_WID / 2 + 11);
ctx.stroke();
ctx.fillText(String(Math.round(b - a)), (a + b) / 2, ROAD_Y + CAR_WID / 2 + 22);
}
}
// 신호 — 정지선 위쪽
drawHead(ctx, STOP_LINE_X + 20, ROAD_Y - ROAD_H / 2 - 40, 8, litOf(sig.state));
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(STOP_LINE_X + 18, ROAD_Y - ROAD_H / 2 - 8, 4, 8);
}
};
if (reduce) { tickSignal(); }
for (let i = 0; i < 330; i += 1) { scene.step(); }
return scene;
}
export default function TlCarFollowStopLineGap() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="빨간불에 앞차 뒤로 간격을 두고 서는 차들" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>빨간불에 앞차 뒤로 간격을 두고 서는 차들</p>
</div>
</main>
);
}02 · 빨간불에 앞차 뒤로 간격을 두고 서는 차들 — 측정 누적면적 10.192% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="블록마다 시차를 둔 네 신호와 이어지는 초록 물결"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">블록마다 시차를 둔 네 신호와 이어지는 초록 물결</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const CYCLE_LEN = 300;
const OFFSET_PER_BLOCK = 90;
const PLATOON_SPEED = 1.15;
const BLOCK_LEN = 100;
const GREEN_LEN = 150;
const YELLOW_LEN = 24;
const BLOCK_COUNT = 4;
const FIRST_SIGNAL_X = 120;
const ROAD_Y = 126;
const ROAD_H = 46;
const CAR_LEN = 26;
const CAR_WID = 17;
const PLATOON_SIZE = 3;
const PLATOON_AT = 200;
const SEED = 446;
function makeScene(ctx, reduce) {
const lane = new Lane(SEED, { accel: 0.03, brake: 0.06, gap: 10, lineGap: 3, vmax: PLATOON_SPEED, headway: 8, delta: 40, len: CAR_LEN });
const sigs = [];
for (let k = 0; k < BLOCK_COUNT; k += 1) {
sigs.push({ x: FIRST_SIGNAL_X + k * BLOCK_LEN, state: SIG_RED });
}
let f = 0;
let toSpawn = 0;
function none(c, x) { return; }
// 신호 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 scene = {
step: function () {
f += 1;
for (let k = 0; k < BLOCK_COUNT; k += 1) {
sigs[k].state = stateOf(k);
}
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);
},
label: function () {
let stopped = 0;
for (let i = 0; i < lane.cars.length; i += 1) {
if (lane.cars[i].v < 0.2 && lane.cars[i].x > 0) {
stopped += 1;
}
}
return stopped > 0 ? '줄이 서는 중 — 시차가 어긋남' : '초록 물결 — 멈춤 없이 통과';
},
draw: function () {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, -40, 33);
for (let k = 0; k < BLOCK_COUNT; k += 1) {
const sx = sigs[k].x;
// 정지선과 횡단보도
ctx.fillStyle = rgba(MARK_RGB, 0.9);
ctx.fillRect(sx - 2, ROAD_Y - ROAD_H / 2, 3, ROAD_H);
for (let j = 0; j < 5; j += 1) {
ctx.fillRect(sx + 5, ROAD_Y - ROAD_H / 2 + 3 + j * (ROAD_H - 6) / 5, 10, (ROAD_H - 6) / 5 - 3);
}
drawHead(ctx, sx + 10, ROAD_Y - ROAD_H / 2 - 34, 7, litOf(sigs[k].state));
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(sx + 8, ROAD_Y - ROAD_H / 2 - 6, 4, 6);
// 초록 구간이 닿는 도로면 — 켜진 동안 다음 신호 쪽으로 옅게 칠한다
if (sigs[k].state === SIG_GREEN && k < BLOCK_COUNT - 1) {
ctx.fillStyle = rgba(GREEN_RGB, 0.16);
ctx.fillRect(sx + 16, ROAD_Y - ROAD_H / 2 + 2, BLOCK_LEN - 20, ROAD_H - 4);
}
}
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
// 위상 시계 — 신호마다 한 바퀴가 OFFSET_PER_BLOCK 만큼씩 늦다
for (let k = 0; k < BLOCK_COUNT; k += 1) {
const cx = sigs[k].x + 10;
const cy = 222;
ctx.beginPath();
ctx.arc(cx, cy, 18, 0, PI2);
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ROAD_RGB, 1);
ctx.stroke();
const a0 = -Math.PI / 2;
const gEnd = GREEN_LEN / CYCLE_LEN;
const yEnd = (GREEN_LEN + YELLOW_LEN) / CYCLE_LEN;
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(GREEN_RGB, 0.55);
ctx.beginPath();
ctx.arc(cx, cy, 18, a0, a0 + PI2 * gEnd);
ctx.stroke();
ctx.strokeStyle = rgba(YELLOW_RGB, 0.7);
ctx.beginPath();
ctx.arc(cx, cy, 18, a0 + PI2 * gEnd, a0 + PI2 * yEnd);
ctx.stroke();
ctx.strokeStyle = rgba(RED_RGB, 0.45);
ctx.beginPath();
ctx.arc(cx, cy, 18, a0 + PI2 * yEnd, a0 + PI2);
ctx.stroke();
const ang = a0 + PI2 * progress(k);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(ang) * 17, cy + Math.sin(ang) * 17);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(INK_RGB, 1);
ctx.stroke();
}
}
};
if (reduce) {
for (let k = 0; k < BLOCK_COUNT; k += 1) {
sigs[k].state = stateOf(k);
}
}
for (let i = 0; i < 260; i += 1) {
scene.step();
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlGreenWaveOffsetAvenue.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const CYCLE_LEN = 300;
const OFFSET_PER_BLOCK = 90;
const PLATOON_SPEED = 1.15;
const BLOCK_LEN = 100;
const GREEN_LEN = 150;
const YELLOW_LEN = 24;
const BLOCK_COUNT = 4;
const FIRST_SIGNAL_X = 120;
const ROAD_Y = 126;
const ROAD_H = 46;
const CAR_LEN = 26;
const CAR_WID = 17;
const PLATOON_SIZE = 3;
const PLATOON_AT = 200;
const SEED = 446;
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const lane = new Lane(SEED, { accel: 0.03, brake: 0.06, gap: 10, lineGap: 3, vmax: PLATOON_SPEED, headway: 8, delta: 40, len: CAR_LEN });
const sigs: Sig[] = [];
for (let k = 0; k < BLOCK_COUNT; k += 1) {
sigs.push({ x: FIRST_SIGNAL_X + k * BLOCK_LEN, state: SIG_RED });
}
let f = 0;
let toSpawn = 0;
function none(c: Car, x: number): void { return; }
// 신호 k 는 앞 신호보다 OFFSET_PER_BLOCK 프레임 늦게 같은 주기를 시작한다
function stateOf(k: number): number {
return phaseAt(f - k * OFFSET_PER_BLOCK, GREEN_LEN, YELLOW_LEN, CYCLE_LEN - GREEN_LEN - YELLOW_LEN);
}
function progress(k: number): number {
return ((((f - k * OFFSET_PER_BLOCK) % CYCLE_LEN) + CYCLE_LEN) % CYCLE_LEN) / CYCLE_LEN;
}
const scene: Scene = {
step: function (): void {
f += 1;
for (let k = 0; k < BLOCK_COUNT; k += 1) { sigs[k].state = stateOf(k); }
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);
},
label: function (): string {
let stopped = 0;
for (let i = 0; i < lane.cars.length; i += 1) {
if (lane.cars[i].v < 0.2 && lane.cars[i].x > 0) { stopped += 1; }
}
return stopped > 0 ? '줄이 서는 중 — 시차가 어긋남' : '초록 물결 — 멈춤 없이 통과';
},
draw: function (): void {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, -40, 33);
for (let k = 0; k < BLOCK_COUNT; k += 1) {
const sx = sigs[k].x;
// 정지선과 횡단보도
ctx.fillStyle = rgba(MARK_RGB, 0.9);
ctx.fillRect(sx - 2, ROAD_Y - ROAD_H / 2, 3, ROAD_H);
for (let j = 0; j < 5; j += 1) {
ctx.fillRect(sx + 5, ROAD_Y - ROAD_H / 2 + 3 + j * (ROAD_H - 6) / 5, 10, (ROAD_H - 6) / 5 - 3);
}
drawHead(ctx, sx + 10, ROAD_Y - ROAD_H / 2 - 34, 7, litOf(sigs[k].state));
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(sx + 8, ROAD_Y - ROAD_H / 2 - 6, 4, 6);
// 초록 구간이 닿는 도로면 — 켜진 동안 다음 신호 쪽으로 옅게 칠한다
if (sigs[k].state === SIG_GREEN && k < BLOCK_COUNT - 1) {
ctx.fillStyle = rgba(GREEN_RGB, 0.16);
ctx.fillRect(sx + 16, ROAD_Y - ROAD_H / 2 + 2, BLOCK_LEN - 20, ROAD_H - 4);
}
}
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
// 위상 시계 — 신호마다 한 바퀴가 OFFSET_PER_BLOCK 만큼씩 늦다
for (let k = 0; k < BLOCK_COUNT; k += 1) {
const cx = sigs[k].x + 10;
const cy = 222;
ctx.beginPath();
ctx.arc(cx, cy, 18, 0, PI2);
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ROAD_RGB, 1);
ctx.stroke();
const a0 = -Math.PI / 2;
const gEnd = GREEN_LEN / CYCLE_LEN;
const yEnd = (GREEN_LEN + YELLOW_LEN) / CYCLE_LEN;
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(GREEN_RGB, 0.55);
ctx.beginPath();
ctx.arc(cx, cy, 18, a0, a0 + PI2 * gEnd);
ctx.stroke();
ctx.strokeStyle = rgba(YELLOW_RGB, 0.7);
ctx.beginPath();
ctx.arc(cx, cy, 18, a0 + PI2 * gEnd, a0 + PI2 * yEnd);
ctx.stroke();
ctx.strokeStyle = rgba(RED_RGB, 0.45);
ctx.beginPath();
ctx.arc(cx, cy, 18, a0 + PI2 * yEnd, a0 + PI2);
ctx.stroke();
const ang = a0 + PI2 * progress(k);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(ang) * 17, cy + Math.sin(ang) * 17);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(INK_RGB, 1);
ctx.stroke();
}
}
};
if (reduce) { for (let k = 0; k < BLOCK_COUNT; k += 1) { sigs[k].state = stateOf(k); } }
for (let i = 0; i < 260; i += 1) { scene.step(); }
return scene;
}
export default function TlGreenWaveOffsetAvenue() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="블록마다 시차를 둔 네 신호와 이어지는 초록 물결" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>블록마다 시차를 둔 네 신호와 이어지는 초록 물결</p>
</div>
</main>
);
}03 · 블록마다 시차를 둔 네 신호와 이어지는 초록 물결 — 측정 누적면적 12.669% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 신호 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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="남은 초를 줄어드는 호로 보여 주는 보행 신호"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">남은 초를 줄어드는 호로 보여 주는 보행 신호</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const WALK_SECONDS = 12;
const FLASH_AT = 5;
const SECOND_FRAMES = 26;
const RING_R = 58;
const RING_CIRCUMFERENCE = PI2 * RING_R;
const TICK_STEP = PI2 / WALK_SECONDS;
const SEG_GAP = 5;
const WAIT_FRAMES = 230;
const WALK_SPEED = 0.8;
const HURRY_SPEED = 1.5;
const RING_X = 118;
const RING_Y = 142;
const ROAD_X0 = 258;
const ROAD_X1 = 452;
const WALK_Y0 = 96;
const WALK_Y1 = 204;
const PED_COUNT = 9;
const SEED = 446;
function makeScene(ctx, reduce) {
const walkFrames = WALK_SECONDS * SECOND_FRAMES;
const cycle = walkFrames + WAIT_FRAMES;
const rnd = mulberry32(SEED);
const peds = [];
for (let i = 0; i < PED_COUNT; i += 1) {
const dir = i % 2 === 0 ? 1 : -1;
peds.push({
x: dir > 0 ? ROAD_X0 - 12 : ROAD_X1 + 12,
y: WALK_Y0 + 12 + (i % 5) * 20 + (i >= 5 ? 6 : 0),
dir: dir,
crossing: false,
startAt: Math.floor(rnd() * 150),
rgb: CAR_RGBS[i % CAR_RGBS.length]
});
}
// 걷기가 시작되고 7 초 뒤부터 보게 시작 — 곧 깜빡임 구간이 온다
let f = WAIT_FRAMES + 5 * SECOND_FRAMES;
let lastWalkStart = WAIT_FRAMES;
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; }
const scene = {
step: function () {
f += 1;
const m = f % cycle;
if (m === WAIT_FRAMES) {
lastWalkStart = f;
for (let i = 0; i < peds.length; i += 1) {
if (!peds[i].crossing) {
peds[i].startAt = f + Math.floor(rnd() * 200);
}
}
}
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;
}
}
}
},
label: function () {
if (!inWalk()) {
return '기다리세요 — 보행 정지';
}
return flashing() ? '서두르세요 — 곧 끝남' : '건너세요 — 남은 시간 충분';
},
draw: function () {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
// 오른쪽 도로와 횡단보도
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(ROAD_X0, 0, ROAD_X1 - ROAD_X0, H);
ctx.fillStyle = rgba(!inWalk() ? RED_RGB : (flashing() ? (Math.floor(f / 9) % 2 === 0 ? RED_RGB : YELLOW_RGB) : GREEN_RGB), inWalk() ? 0.3 : 0.16);
ctx.fillRect(ROAD_X0, WALK_Y0 - 6, ROAD_X1 - ROAD_X0, WALK_Y1 - WALK_Y0 + 12);
ctx.fillStyle = rgba(MARK_RGB, 1);
for (let y = WALK_Y0; y < WALK_Y1; y += 14) {
ctx.fillRect(ROAD_X0 + 6, y, ROAD_X1 - ROAD_X0 - 12, 8);
}
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(ROAD_X0, 0, 3, H);
ctx.fillRect(ROAD_X1 - 3, 0, 3, H);
// 보행자
for (let i = 0; i < peds.length; i += 1) {
const p = peds[i];
const wob = p.crossing ? Math.sin(f * 0.3 + i) * 1.5 : 0;
ctx.beginPath();
ctx.arc(p.x, p.y + wob, 6.5, 0, PI2);
ctx.fillStyle = rgba(p.rgb, 1);
ctx.fill();
ctx.beginPath();
ctx.arc(p.x + p.dir * 2, p.y + wob, 3.2, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fill();
}
// 보행 신호 원판
const left = secondsLeft();
const walk = inWalk();
const blink = flashing() && (Math.floor(f / 9) % 2 === 0);
ctx.beginPath();
ctx.arc(RING_X, RING_Y, RING_R + 14, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
ctx.lineWidth = 9;
ctx.lineCap = 'butt';
ctx.setLineDash([RING_CIRCUMFERENCE / WALK_SECONDS - SEG_GAP, SEG_GAP]);
ctx.lineDashOffset = -SEG_GAP / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.beginPath();
ctx.arc(RING_X, RING_Y, RING_R, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
if (walk) {
ctx.strokeStyle = rgba(flashing() ? (blink ? RED_RGB : YELLOW_RGB) : GREEN_RGB, 1);
ctx.beginPath();
ctx.arc(RING_X, RING_Y, RING_R, -Math.PI / 2, -Math.PI / 2 + TICK_STEP * left);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.lineDashOffset = 0;
if (walk) {
glow(ctx, RING_X, RING_Y, 56, flashing() ? RED_RGB : GREEN_RGB, blink ? 0.35 : 0.18);
ctx.fillStyle = rgba(blink ? RED_RGB : [255, 255, 255], 1);
ctx.font = '700 44px ' + FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(Math.ceil(left)), RING_X, RING_Y + 2);
}
else {
glow(ctx, RING_X, RING_Y, 56, RED_RGB, 0.25);
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(RED_RGB, 1);
ctx.beginPath();
ctx.moveTo(RING_X - 18, RING_Y - 18);
ctx.lineTo(RING_X + 18, RING_Y + 18);
ctx.moveTo(RING_X + 18, RING_Y - 18);
ctx.lineTo(RING_X - 18, RING_Y + 18);
ctx.stroke();
ctx.lineCap = 'butt';
}
ctx.textBaseline = 'alphabetic';
}
};
if (reduce) {
f = WAIT_FRAMES + 5 * SECOND_FRAMES;
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlPedestrianCountdownRing.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const WALK_SECONDS = 12;
const FLASH_AT = 5;
const SECOND_FRAMES = 26;
const RING_R = 58;
const RING_CIRCUMFERENCE = PI2 * RING_R;
const TICK_STEP = PI2 / WALK_SECONDS;
const SEG_GAP = 5;
const WAIT_FRAMES = 230;
const WALK_SPEED = 0.8;
const HURRY_SPEED = 1.5;
const RING_X = 118;
const RING_Y = 142;
const ROAD_X0 = 258;
const ROAD_X1 = 452;
const WALK_Y0 = 96;
const WALK_Y1 = 204;
const PED_COUNT = 9;
const SEED = 446;
interface Ped { x: number; y: number; dir: number; crossing: boolean; startAt: number; rgb: number[]; }
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const walkFrames = WALK_SECONDS * SECOND_FRAMES;
const cycle = walkFrames + WAIT_FRAMES;
const rnd = mulberry32(SEED);
const peds: Ped[] = [];
for (let i = 0; i < PED_COUNT; i += 1) {
const dir = i % 2 === 0 ? 1 : -1;
peds.push({
x: dir > 0 ? ROAD_X0 - 12 : ROAD_X1 + 12,
y: WALK_Y0 + 12 + (i % 5) * 20 + (i >= 5 ? 6 : 0),
dir: dir,
crossing: false,
startAt: Math.floor(rnd() * 150),
rgb: CAR_RGBS[i % CAR_RGBS.length]
});
}
// 걷기가 시작되고 7 초 뒤부터 보게 시작 — 곧 깜빡임 구간이 온다
let f = WAIT_FRAMES + 5 * SECOND_FRAMES;
let lastWalkStart = WAIT_FRAMES;
function inWalk(): boolean { return (f % cycle) >= WAIT_FRAMES; }
function secondsLeft(): number {
if (!inWalk()) { return 0; }
return (cycle - (f % cycle)) / SECOND_FRAMES;
}
function flashing(): boolean { return inWalk() && secondsLeft() <= FLASH_AT; }
const scene: Scene = {
step: function (): void {
f += 1;
const m = f % cycle;
if (m === WAIT_FRAMES) {
lastWalkStart = f;
for (let i = 0; i < peds.length; i += 1) {
if (!peds[i].crossing) { peds[i].startAt = f + Math.floor(rnd() * 200); }
}
}
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;
}
}
}
},
label: function (): string {
if (!inWalk()) { return '기다리세요 — 보행 정지'; }
return flashing() ? '서두르세요 — 곧 끝남' : '건너세요 — 남은 시간 충분';
},
draw: function (): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
// 오른쪽 도로와 횡단보도
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(ROAD_X0, 0, ROAD_X1 - ROAD_X0, H);
ctx.fillStyle = rgba(!inWalk() ? RED_RGB : (flashing() ? (Math.floor(f / 9) % 2 === 0 ? RED_RGB : YELLOW_RGB) : GREEN_RGB), inWalk() ? 0.3 : 0.16);
ctx.fillRect(ROAD_X0, WALK_Y0 - 6, ROAD_X1 - ROAD_X0, WALK_Y1 - WALK_Y0 + 12);
ctx.fillStyle = rgba(MARK_RGB, 1);
for (let y = WALK_Y0; y < WALK_Y1; y += 14) {
ctx.fillRect(ROAD_X0 + 6, y, ROAD_X1 - ROAD_X0 - 12, 8);
}
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(ROAD_X0, 0, 3, H);
ctx.fillRect(ROAD_X1 - 3, 0, 3, H);
// 보행자
for (let i = 0; i < peds.length; i += 1) {
const p = peds[i];
const wob = p.crossing ? Math.sin(f * 0.3 + i) * 1.5 : 0;
ctx.beginPath();
ctx.arc(p.x, p.y + wob, 6.5, 0, PI2);
ctx.fillStyle = rgba(p.rgb, 1);
ctx.fill();
ctx.beginPath();
ctx.arc(p.x + p.dir * 2, p.y + wob, 3.2, 0, PI2);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fill();
}
// 보행 신호 원판
const left = secondsLeft();
const walk = inWalk();
const blink = flashing() && (Math.floor(f / 9) % 2 === 0);
ctx.beginPath();
ctx.arc(RING_X, RING_Y, RING_R + 14, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
ctx.lineWidth = 9;
ctx.lineCap = 'butt';
ctx.setLineDash([RING_CIRCUMFERENCE / WALK_SECONDS - SEG_GAP, SEG_GAP]);
ctx.lineDashOffset = -SEG_GAP / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.beginPath();
ctx.arc(RING_X, RING_Y, RING_R, -Math.PI / 2, -Math.PI / 2 + PI2);
ctx.stroke();
if (walk) {
ctx.strokeStyle = rgba(flashing() ? (blink ? RED_RGB : YELLOW_RGB) : GREEN_RGB, 1);
ctx.beginPath();
ctx.arc(RING_X, RING_Y, RING_R, -Math.PI / 2, -Math.PI / 2 + TICK_STEP * left);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.lineDashOffset = 0;
if (walk) {
glow(ctx, RING_X, RING_Y, 56, flashing() ? RED_RGB : GREEN_RGB, blink ? 0.35 : 0.18);
ctx.fillStyle = rgba(blink ? RED_RGB : [255, 255, 255], 1);
ctx.font = '700 44px ' + FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(Math.ceil(left)), RING_X, RING_Y + 2);
} else {
glow(ctx, RING_X, RING_Y, 56, RED_RGB, 0.25);
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(RED_RGB, 1);
ctx.beginPath();
ctx.moveTo(RING_X - 18, RING_Y - 18);
ctx.lineTo(RING_X + 18, RING_Y + 18);
ctx.moveTo(RING_X + 18, RING_Y - 18);
ctx.lineTo(RING_X - 18, RING_Y + 18);
ctx.stroke();
ctx.lineCap = 'butt';
}
ctx.textBaseline = 'alphabetic';
}
};
if (reduce) { f = WAIT_FRAMES + 5 * SECOND_FRAMES; }
return scene;
}
export default function TlPedestrianCountdownRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="남은 초를 줄어드는 호로 보여 주는 보행 신호" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>남은 초를 줄어드는 호로 보여 주는 보행 신호</p>
</div>
</main>
);
}04 · 남은 초를 줄어드는 호로 보여 주는 보행 신호 — 측정 누적면적 13.559% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const VISOR_DEPTH = 24;
const VIEW_TILT_MAX = 52;
const SHADOW_BLUR = 9;
const LENS_OFFSET = 5;
const EYE_DISTANCE = 190;
const HEAD_XS = [104, 240, 376];
const HEAD_CY = 120;
const LENS_R = 17;
const LENS_STEP = 44;
const SAMPLE_GRID = 9;
const PATH_PERIOD_MS = 5200;
function makeScene(ctx, reduce) {
let f = 0;
let userSet = false;
let ex = 240;
let ey = 200;
let tx = 240;
let ty = 200;
const vis = [1, 1, 1];
function autoPath() {
const a = PI2 * (f * FIXED_DT) / PATH_PERIOD_MS;
return [240 + 214 * Math.sin(a), 176 + 96 * Math.sin(1.7 * a + 1)];
}
// 눈 위치에서 본 챙 입구 원의 이동량 — 눈이 오른쪽이면 입구가 왼쪽으로 밀려 렌즈 오른쪽이 가려진다
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 rimRadius() { return LENS_R + 3; }
// 렌즈 안을 격자로 훑어 입구 안에 든 점의 비율
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;
}
function update() {
for (let h = 0; h < 3; h += 1) {
const target = visibleOf(HEAD_XS[h], HEAD_CY + (h - 1) * LENS_STEP);
vis[h] = reduce ? target : vis[h] + (target - vis[h]) * 0.3;
}
}
const scene = {
step: function () {
f += 1;
if (!userSet) {
const p = autoPath();
tx = p[0];
ty = p[1];
}
ex = reduce ? tx : lerp(ex, tx, 0.16);
ey = reduce ? ty : lerp(ey, ty, 0.16);
update();
},
point: function (x, y) {
userSet = true;
tx = x;
ty = y;
if (reduce) {
ex = x;
ey = y;
update();
}
},
label: function () {
const lo = Math.min(vis[0], vis[1], vis[2]);
if (lo > 0.85) {
return '신호가 모두 잘 보임';
}
return lo < 0.3 ? '챙에 가려 거의 안 보임' : '챙이 렌즈 일부를 가림';
},
draw: function () {
ctx.fillStyle = rgba(SKY_RGB, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 232, W, 68);
// 가로대와 기둥
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(40, 46, 400, 7);
ctx.fillRect(236, 46, 8, 186);
for (let h = 0; h < 3; h += 1) {
const hx = HEAD_XS[h];
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(hx - 3, 50, 6, 16);
rrect(ctx, hx - 28, HEAD_CY - LENS_STEP - 26, 56, LENS_STEP * 2 + 52, 14);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = HEAD_CY + (i - 1) * LENS_STEP;
const s = shiftOf(hx, ly);
const lit = i === h ? 1 : 0;
// 렌즈 바탕
ctx.beginPath();
ctx.arc(hx, ly, LENS_R, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2);
ctx.fill();
const ax = hx + s[0];
const ay = ly + s[1];
if (lit > 0) {
glow(ctx, hx, ly, 70, LENS_RGBS[i], 0.55 * vis[h]);
// 챙 입구 안쪽만 보이게 자르고 그 안에 렌즈의 빛을 그린다
ctx.save();
ctx.beginPath();
ctx.arc(ax, ay, rimRadius(), 0, PI2);
ctx.clip();
ctx.beginPath();
ctx.arc(hx, ly, LENS_R, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 1);
ctx.fill();
glow(ctx, hx - 5, ly - 5, 14, [255, 255, 255], 0.7);
ctx.restore();
}
// 입구 가장자리가 렌즈에 드리우는 번진 그림자
ctx.save();
ctx.beginPath();
ctx.arc(hx, ly, LENS_R, 0, PI2);
ctx.clip();
ctx.beginPath();
ctx.arc(ax, ay, rimRadius() + 3, 0, PI2);
ctx.shadowColor = 'rgba(30,24,60,0.7)';
ctx.shadowBlur = SHADOW_BLUR;
ctx.shadowOffsetY = LENS_OFFSET;
ctx.lineWidth = 6;
ctx.strokeStyle = 'rgba(84,76,122,1)';
ctx.stroke();
ctx.restore();
// 챙 안쪽 벽 — 입구 밖에 있는 렌즈 부분과 렌즈 둘레를 가린다
ctx.save();
ctx.beginPath();
ctx.rect(hx - 40, ly - 40, 80, 80);
ctx.moveTo(ax + rimRadius(), ay);
ctx.arc(ax, ay, rimRadius(), 0, PI2);
ctx.clip('evenodd');
ctx.beginPath();
ctx.arc(hx, ly, LENS_R + 7, 0, PI2);
ctx.fillStyle = 'rgba(84,76,122,1)';
ctx.fill();
ctx.restore();
}
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.font = '700 13px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText(String(Math.round(vis[h] * 100)) + '%', hx, HEAD_CY + LENS_STEP + 44);
}
// 눈 위치 표시
ctx.beginPath();
ctx.arc(ex, ey, 9, 0, PI2);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK_RGB, 0.9);
ctx.stroke();
ctx.beginPath();
ctx.arc(ex, ey, 3, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.fill();
}
};
const p0 = autoPath();
tx = p0[0];
ty = p0[1];
ex = tx;
ey = ty;
update();
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlVisorShadowViewAngle.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const VISOR_DEPTH = 24;
const VIEW_TILT_MAX = 52;
const SHADOW_BLUR = 9;
const LENS_OFFSET = 5;
const EYE_DISTANCE = 190;
const HEAD_XS = [104, 240, 376];
const HEAD_CY = 120;
const LENS_R = 17;
const LENS_STEP = 44;
const SAMPLE_GRID = 9;
const PATH_PERIOD_MS = 5200;
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
let f = 0;
let userSet = false;
let ex = 240;
let ey = 200;
let tx = 240;
let ty = 200;
const vis = [1, 1, 1];
function autoPath(): Pt {
const a = PI2 * (f * FIXED_DT) / PATH_PERIOD_MS;
return [240 + 214 * Math.sin(a), 176 + 96 * Math.sin(1.7 * a + 1)];
}
// 눈 위치에서 본 챙 입구 원의 이동량 — 눈이 오른쪽이면 입구가 왼쪽으로 밀려 렌즈 오른쪽이 가려진다
function shiftOf(hx: number, ly: number): Pt {
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 rimRadius(): number { return LENS_R + 3; }
// 렌즈 안을 격자로 훑어 입구 안에 든 점의 비율
function visibleOf(hx: number, ly: number): number {
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;
}
function update(): void {
for (let h = 0; h < 3; h += 1) {
const target = visibleOf(HEAD_XS[h], HEAD_CY + (h - 1) * LENS_STEP);
vis[h] = reduce ? target : vis[h] + (target - vis[h]) * 0.3;
}
}
const scene: Scene = {
step: function (): void {
f += 1;
if (!userSet) {
const p = autoPath();
tx = p[0];
ty = p[1];
}
ex = reduce ? tx : lerp(ex, tx, 0.16);
ey = reduce ? ty : lerp(ey, ty, 0.16);
update();
},
point: function (x: number, y: number): void {
userSet = true;
tx = x;
ty = y;
if (reduce) {
ex = x;
ey = y;
update();
}
},
label: function (): string {
const lo = Math.min(vis[0], vis[1], vis[2]);
if (lo > 0.85) { return '신호가 모두 잘 보임'; }
return lo < 0.3 ? '챙에 가려 거의 안 보임' : '챙이 렌즈 일부를 가림';
},
draw: function (): void {
ctx.fillStyle = rgba(SKY_RGB, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 232, W, 68);
// 가로대와 기둥
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(40, 46, 400, 7);
ctx.fillRect(236, 46, 8, 186);
for (let h = 0; h < 3; h += 1) {
const hx = HEAD_XS[h];
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(hx - 3, 50, 6, 16);
rrect(ctx, hx - 28, HEAD_CY - LENS_STEP - 26, 56, LENS_STEP * 2 + 52, 14);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = HEAD_CY + (i - 1) * LENS_STEP;
const s = shiftOf(hx, ly);
const lit = i === h ? 1 : 0;
// 렌즈 바탕
ctx.beginPath();
ctx.arc(hx, ly, LENS_R, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2);
ctx.fill();
const ax = hx + s[0];
const ay = ly + s[1];
if (lit > 0) {
glow(ctx, hx, ly, 70, LENS_RGBS[i], 0.55 * vis[h]);
// 챙 입구 안쪽만 보이게 자르고 그 안에 렌즈의 빛을 그린다
ctx.save();
ctx.beginPath();
ctx.arc(ax, ay, rimRadius(), 0, PI2);
ctx.clip();
ctx.beginPath();
ctx.arc(hx, ly, LENS_R, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 1);
ctx.fill();
glow(ctx, hx - 5, ly - 5, 14, [255, 255, 255], 0.7);
ctx.restore();
}
// 입구 가장자리가 렌즈에 드리우는 번진 그림자
ctx.save();
ctx.beginPath();
ctx.arc(hx, ly, LENS_R, 0, PI2);
ctx.clip();
ctx.beginPath();
ctx.arc(ax, ay, rimRadius() + 3, 0, PI2);
ctx.shadowColor = 'rgba(30,24,60,0.7)';
ctx.shadowBlur = SHADOW_BLUR;
ctx.shadowOffsetY = LENS_OFFSET;
ctx.lineWidth = 6;
ctx.strokeStyle = 'rgba(84,76,122,1)';
ctx.stroke();
ctx.restore();
// 챙 안쪽 벽 — 입구 밖에 있는 렌즈 부분과 렌즈 둘레를 가린다
ctx.save();
ctx.beginPath();
ctx.rect(hx - 40, ly - 40, 80, 80);
ctx.moveTo(ax + rimRadius(), ay);
ctx.arc(ax, ay, rimRadius(), 0, PI2);
ctx.clip('evenodd');
ctx.beginPath();
ctx.arc(hx, ly, LENS_R + 7, 0, PI2);
ctx.fillStyle = 'rgba(84,76,122,1)';
ctx.fill();
ctx.restore();
}
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.font = '700 13px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText(String(Math.round(vis[h] * 100)) + '%', hx, HEAD_CY + LENS_STEP + 44);
}
// 눈 위치 표시
ctx.beginPath();
ctx.arc(ex, ey, 9, 0, PI2);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK_RGB, 0.9);
ctx.stroke();
ctx.beginPath();
ctx.arc(ex, ey, 3, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.fill();
}
};
const p0 = autoPath();
tx = p0[0];
ty = p0[1];
ex = tx;
ey = ty;
update();
return scene;
}
export default function TlVisorShadowViewAngle() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등</p>
</div>
</main>
);
}05 · 보는 각도에 따라 챙 그림자가 렌즈를 덮는 신호등 — 측정 누적면적 18.474% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 눈 위치에서 본 챙 입구 원의 이동량 — 눈이 오른쪽이면 입구가 왼쪽으로 밀려 렌즈 오른쪽이 가려진다
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="젖은 도로에 길게 번지는 신호등 빛 반사"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">젖은 도로에 길게 번지는 신호등 빛 반사</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const REFLECT_STRETCH = 1.7;
const RIPPLE_AMP = 7;
const FADE_RATE = 0.9;
const NOISE_SCALE = 0.05;
const HORIZON = 120;
const SEG_W = 20;
const ROW_STEP = 2;
const GREEN_LEN = 90;
const YELLOW_LEN = 24;
const RED_LEN = 110;
const RIPPLE_COUNT = 7;
const SEED = 446;
function makeScene(ctx, reduce) {
const src = document.createElement('canvas');
src.width = W;
src.height = HORIZON;
const sc = src.getContext('2d');
if (!sc) {
return { step: function () { return; }, draw: function () { return; }, label: function () { return TXT_STOP; } };
}
const rnd = mulberry32(SEED);
const buildings = [];
let bx = -10;
while (bx < W) {
const bw = 30 + Math.floor(rnd() * 34);
const bh = 46 + Math.floor(rnd() * 56);
buildings.push([bx, bw, bh, Math.floor(rnd() * 1000)]);
bx += bw + 3;
}
const ripples = [];
for (let i = 0; i < RIPPLE_COUNT; i += 1) {
ripples.push({ x: 30 + rnd() * 420, y: HORIZON + 24 + rnd() * 110, period: 80 + Math.floor(rnd() * 60), off: Math.floor(rnd() * 120) });
}
let f = 40;
let carX = -60;
function stateA() { return phaseAt(f, GREEN_LEN, YELLOW_LEN, RED_LEN); }
function stateB() { return phaseAt(f + 110, GREEN_LEN, YELLOW_LEN, RED_LEN); }
function drawSource() {
const g = sc.createLinearGradient(0, 0, 0, HORIZON);
g.addColorStop(0, 'rgba(255,224,214,1)');
g.addColorStop(1, 'rgba(214,204,246,1)');
sc.fillStyle = g;
sc.fillRect(0, 0, W, HORIZON);
for (let i = 0; i < buildings.length; i += 1) {
const b = buildings[i];
sc.fillStyle = 'rgba(150,140,190,1)';
sc.fillRect(b[0], HORIZON - b[2] - 8, b[1], b[2] + 8);
for (let wy = HORIZON - b[2]; wy < HORIZON - 14; wy += 12) {
for (let wx = b[0] + 5; wx < b[0] + b[1] - 8; wx += 10) {
if (hash2(wx + b[3], wy) < 0.34) {
sc.fillStyle = 'rgba(255,236,176,1)';
sc.fillRect(wx, wy, 5, 6);
}
}
}
}
sc.fillStyle = 'rgba(120,112,164,1)';
sc.fillRect(0, HORIZON - 9, W, 9);
// 신호 두 대 — 한쪽이 초록이면 다른 쪽은 빨강
const heads = [[140, 8, stateA()], [352, 11, stateB()]];
for (let h = 0; h < heads.length; h += 1) {
const hx = heads[h][0];
const hr = heads[h][1];
const st = heads[h][2];
sc.fillStyle = rgba(INK_RGB, 1);
sc.fillRect(hx - 2, 66, 4, HORIZON - 66);
drawHead(sc, hx, 52, hr, litOf(st));
}
// 가로등
sc.fillStyle = rgba(INK_RGB, 1);
sc.fillRect(432, 70, 3, HORIZON - 70);
glow(sc, 434, 68, 46, [255, 226, 160], 0.9);
sc.beginPath();
sc.arc(434, 68, 5, 0, PI2);
sc.fillStyle = 'rgba(255,248,214,1)';
sc.fill();
// 지나가는 차 — 앞 등이 반사의 가장 밝은 줄이 된다
if (carX > -50 && carX < W + 20) {
rrect(sc, carX - 38, HORIZON - 24, 38, 14, 5);
sc.fillStyle = 'rgba(206,176,214,1)';
sc.fill();
sc.fillStyle = 'rgba(88,80,128,1)';
sc.fillRect(carX - 28, HORIZON - 28, 20, 6);
sc.beginPath();
sc.arc(carX - 28, HORIZON - 9, 4, 0, PI2);
sc.arc(carX - 9, HORIZON - 9, 4, 0, PI2);
sc.fill();
glow(sc, carX + 2, HORIZON - 16, 34, [255, 240, 190], 0.95);
sc.fillStyle = 'rgba(255,252,230,1)';
sc.fillRect(carX - 3, HORIZON - 19, 4, 5);
}
}
const scene = {
step: function () {
f += 1;
carX += 2.1;
if (carX > W + 40) {
carX = -60 - (f % 140);
}
},
label: function () { return stateText(stateA()); },
draw: function () {
drawSource();
ctx.drawImage(src, 0, 0);
// 젖은 도로 바탕
const road = ctx.createLinearGradient(0, HORIZON, 0, H);
road.addColorStop(0, 'rgba(92,84,128,1)');
road.addColorStop(1, 'rgba(56,50,88,1)');
ctx.fillStyle = road;
ctx.fillRect(0, HORIZON, W, H - HORIZON);
// 반사 — 도로 행마다 원본의 거꾸로 된 행을 가져오고, 잡음으로 가로로 흔들고, 멀수록 흐려진다
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);
}
}
ctx.globalAlpha = 1;
// 빗방울 파문
for (let i = 0; i < ripples.length; i += 1) {
const r = ripples[i];
const ph = (((f + r.off) % r.period) / r.period);
const rad = 4 + 30 * ph * (0.6 + (r.y - HORIZON) / 200);
ctx.beginPath();
ctx.ellipse(r.x, r.y, rad, rad * 0.28, 0, 0, PI2);
ctx.lineWidth = 1.6;
ctx.strokeStyle = 'rgba(236,232,255,' + (0.7 * (1 - ph)).toFixed(3) + ')';
ctx.stroke();
}
}
};
for (let i = 0; i < 60; i += 1) {
scene.step();
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlWetRoadReflectionStreak.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const REFLECT_STRETCH = 1.7;
const RIPPLE_AMP = 7;
const FADE_RATE = 0.9;
const NOISE_SCALE = 0.05;
const HORIZON = 120;
const SEG_W = 20;
const ROW_STEP = 2;
const GREEN_LEN = 90;
const YELLOW_LEN = 24;
const RED_LEN = 110;
const RIPPLE_COUNT = 7;
const SEED = 446;
interface Ripple { x: number; y: number; period: number; off: number; }
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const src = document.createElement('canvas');
src.width = W;
src.height = HORIZON;
const sc = src.getContext('2d');
if (!sc) { return { step: function (): void { return; }, draw: function (): void { return; }, label: function (): string { return TXT_STOP; } }; }
const rnd = mulberry32(SEED);
const buildings: number[][] = [];
let bx = -10;
while (bx < W) {
const bw = 30 + Math.floor(rnd() * 34);
const bh = 46 + Math.floor(rnd() * 56);
buildings.push([bx, bw, bh, Math.floor(rnd() * 1000)]);
bx += bw + 3;
}
const ripples: Ripple[] = [];
for (let i = 0; i < RIPPLE_COUNT; i += 1) {
ripples.push({ x: 30 + rnd() * 420, y: HORIZON + 24 + rnd() * 110, period: 80 + Math.floor(rnd() * 60), off: Math.floor(rnd() * 120) });
}
let f = 40;
let carX = -60;
function stateA(): number { return phaseAt(f, GREEN_LEN, YELLOW_LEN, RED_LEN); }
function stateB(): number { return phaseAt(f + 110, GREEN_LEN, YELLOW_LEN, RED_LEN); }
function drawSource(): void {
const g = sc!.createLinearGradient(0, 0, 0, HORIZON);
g.addColorStop(0, 'rgba(255,224,214,1)');
g.addColorStop(1, 'rgba(214,204,246,1)');
sc!.fillStyle = g;
sc!.fillRect(0, 0, W, HORIZON);
for (let i = 0; i < buildings.length; i += 1) {
const b = buildings[i];
sc!.fillStyle = 'rgba(150,140,190,1)';
sc!.fillRect(b[0], HORIZON - b[2] - 8, b[1], b[2] + 8);
for (let wy = HORIZON - b[2]; wy < HORIZON - 14; wy += 12) {
for (let wx = b[0] + 5; wx < b[0] + b[1] - 8; wx += 10) {
if (hash2(wx + b[3], wy) < 0.34) {
sc!.fillStyle = 'rgba(255,236,176,1)';
sc!.fillRect(wx, wy, 5, 6);
}
}
}
}
sc!.fillStyle = 'rgba(120,112,164,1)';
sc!.fillRect(0, HORIZON - 9, W, 9);
// 신호 두 대 — 한쪽이 초록이면 다른 쪽은 빨강
const heads = [[140, 8, stateA()], [352, 11, stateB()]];
for (let h = 0; h < heads.length; h += 1) {
const hx = heads[h][0];
const hr = heads[h][1];
const st = heads[h][2];
sc!.fillStyle = rgba(INK_RGB, 1);
sc!.fillRect(hx - 2, 66, 4, HORIZON - 66);
drawHead(sc!, hx, 52, hr, litOf(st));
}
// 가로등
sc!.fillStyle = rgba(INK_RGB, 1);
sc!.fillRect(432, 70, 3, HORIZON - 70);
glow(sc!, 434, 68, 46, [255, 226, 160], 0.9);
sc!.beginPath();
sc!.arc(434, 68, 5, 0, PI2);
sc!.fillStyle = 'rgba(255,248,214,1)';
sc!.fill();
// 지나가는 차 — 앞 등이 반사의 가장 밝은 줄이 된다
if (carX > -50 && carX < W + 20) {
rrect(sc!, carX - 38, HORIZON - 24, 38, 14, 5);
sc!.fillStyle = 'rgba(206,176,214,1)';
sc!.fill();
sc!.fillStyle = 'rgba(88,80,128,1)';
sc!.fillRect(carX - 28, HORIZON - 28, 20, 6);
sc!.beginPath();
sc!.arc(carX - 28, HORIZON - 9, 4, 0, PI2);
sc!.arc(carX - 9, HORIZON - 9, 4, 0, PI2);
sc!.fill();
glow(sc!, carX + 2, HORIZON - 16, 34, [255, 240, 190], 0.95);
sc!.fillStyle = 'rgba(255,252,230,1)';
sc!.fillRect(carX - 3, HORIZON - 19, 4, 5);
}
}
const scene: Scene = {
step: function (): void {
f += 1;
carX += 2.1;
if (carX > W + 40) { carX = -60 - (f % 140); }
},
label: function (): string { return stateText(stateA()); },
draw: function (): void {
drawSource();
ctx.drawImage(src, 0, 0);
// 젖은 도로 바탕
const road = ctx.createLinearGradient(0, HORIZON, 0, H);
road.addColorStop(0, 'rgba(92,84,128,1)');
road.addColorStop(1, 'rgba(56,50,88,1)');
ctx.fillStyle = road;
ctx.fillRect(0, HORIZON, W, H - HORIZON);
// 반사 — 도로 행마다 원본의 거꾸로 된 행을 가져오고, 잡음으로 가로로 흔들고, 멀수록 흐려진다
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);
}
}
ctx.globalAlpha = 1;
// 빗방울 파문
for (let i = 0; i < ripples.length; i += 1) {
const r = ripples[i];
const ph = (((f + r.off) % r.period) / r.period);
const rad = 4 + 30 * ph * (0.6 + (r.y - HORIZON) / 200);
ctx.beginPath();
ctx.ellipse(r.x, r.y, rad, rad * 0.28, 0, 0, PI2);
ctx.lineWidth = 1.6;
ctx.strokeStyle = 'rgba(236,232,255,' + (0.7 * (1 - ph)).toFixed(3) + ')';
ctx.stroke();
}
}
};
for (let i = 0; i < 60; i += 1) { scene.step(); }
return scene;
}
export default function TlWetRoadReflectionStreak() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="젖은 도로에 길게 번지는 신호등 빛 반사" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>젖은 도로에 길게 번지는 신호등 빛 반사</p>
</div>
</main>
);
}06 · 젖은 도로에 길게 번지는 신호등 빛 반사 — 측정 누적면적 45.003% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 반사 — 도로 행마다 원본의 거꾸로 된 행을 가져오고, 잡음으로 가로로 흔들고, 멀수록 흐려진다
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="줄 길이가 신호 주기마다 오르내리는 실시간 그래프"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">줄 길이가 신호 주기마다 오르내리는 실시간 그래프</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const SAMPLE_EVERY = 6;
const GRAPH_POINTS = 56;
const Y_MAX_CARS = 10;
const LINE_SMOOTH = 0.5;
const STOP_LINE_X = 300;
const ROAD_Y = 58;
const ROAD_H = 44;
const CAR_LEN = 24;
const CAR_WID = 16;
const GREEN_LEN = 110;
const YELLOW_LEN = 24;
const RED_LEN = 160;
const GRAPH_X0 = 44;
const GRAPH_X1 = 450;
const GRAPH_Y0 = 236;
const GRAPH_Y1 = 128;
const SEED = 446;
function makeScene(ctx, reduce) {
const lane = new Lane(SEED, { accel: 0.04, brake: 0.07, gap: 10, lineGap: 3, vmax: 2, headway: 10, delta: 4, len: CAR_LEN });
const sig = { x: STOP_LINE_X, state: SIG_GREEN };
const sigs = [sig];
const hist = [];
const histState = [];
let f = 0;
function none(c, x) { return; }
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);
}
const scene = {
step: function () {
f += 1;
sig.state = phaseAt(f, GREEN_LEN, YELLOW_LEN, RED_LEN);
lane.maybeSpawn(f, 22, 44, 1.8);
lane.step(sigs, none, -1);
if (f % SAMPLE_EVERY === 0) {
sample();
}
},
label: function () { return stateText(sig.state); },
draw: function () {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, STOP_LINE_X, 7);
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 108, W, 192);
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
drawHead(ctx, STOP_LINE_X + 18, ROAD_Y - ROAD_H / 2 - 22, 6, litOf(sig.state));
// 그래프 판
rrect(ctx, GRAPH_X0 - 26, GRAPH_Y1 - 14, GRAPH_X1 - GRAPH_X0 + 40, GRAPH_Y0 - GRAPH_Y1 + 26, 10);
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.rect(GRAPH_X0, GRAPH_Y1 - 6, GRAPH_X1 - GRAPH_X0, GRAPH_Y0 - GRAPH_Y1 + 8);
ctx.clip();
const shift = reduce ? 0 : (f % SAMPLE_EVERY) / SAMPLE_EVERY;
const n = hist.length;
// 신호 색 띠 — 그 순간의 신호가 뒤에 깔린다
for (let i = 0; i < n; i += 1) {
const x0 = px(i, shift);
const x1 = px(i + 1, shift);
ctx.fillStyle = rgba(SIGNAL_RGBS[histState[i]], 0.2);
ctx.fillRect(x0, GRAPH_Y1 - 6, Math.max(x1 - x0, 0) + (i < n - 1 ? 1 : 0), GRAPH_Y0 - GRAPH_Y1 + 8);
}
// 눈금선
ctx.strokeStyle = 'rgba(58,52,84,0.14)';
ctx.lineWidth = 1;
for (let v = 0; v <= Y_MAX_CARS; v += 2) {
ctx.beginPath();
ctx.moveTo(GRAPH_X0, py(v));
ctx.lineTo(GRAPH_X1, py(v));
ctx.stroke();
}
if (n > 2) {
// 선 — 이웃 점의 중간점을 이어 곡선으로. LINE_SMOOTH 가 0 이면 꺾은선, 1 이면 완전한 곡선
let sx = px(0, shift);
let sy = py(hist[0]);
ctx.beginPath();
ctx.moveTo(sx, sy);
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;
}
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK_RGB, 1);
ctx.stroke();
ctx.lineTo(px(n - 1, shift), GRAPH_Y0);
ctx.lineTo(px(0, shift), GRAPH_Y0);
ctx.closePath();
ctx.fillStyle = 'rgba(170,150,230,0.28)';
ctx.fill();
}
ctx.restore();
if (n > 0) {
ctx.beginPath();
ctx.arc(px(n - 1, shift), py(hist[n - 1]), 5, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
}
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'right';
for (let v = 0; v <= Y_MAX_CARS; v += 4) {
ctx.fillText(String(v), GRAPH_X0 - 6, py(v) + 3);
}
ctx.textAlign = 'center';
}
};
for (let i = 0; i < 420; i += 1) {
scene.step();
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlQueueLengthLiveGraph.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const SAMPLE_EVERY = 6;
const GRAPH_POINTS = 56;
const Y_MAX_CARS = 10;
const LINE_SMOOTH = 0.5;
const STOP_LINE_X = 300;
const ROAD_Y = 58;
const ROAD_H = 44;
const CAR_LEN = 24;
const CAR_WID = 16;
const GREEN_LEN = 110;
const YELLOW_LEN = 24;
const RED_LEN = 160;
const GRAPH_X0 = 44;
const GRAPH_X1 = 450;
const GRAPH_Y0 = 236;
const GRAPH_Y1 = 128;
const SEED = 446;
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const lane = new Lane(SEED, { accel: 0.04, brake: 0.07, gap: 10, lineGap: 3, vmax: 2, headway: 10, delta: 4, len: CAR_LEN });
const sig: Sig = { x: STOP_LINE_X, state: SIG_GREEN };
const sigs: Sig[] = [sig];
const hist: number[] = [];
const histState: number[] = [];
let f = 0;
function none(c: Car, x: number): void { return; }
function sample(): void {
hist.push(lane.waiting(STOP_LINE_X));
histState.push(sig.state);
if (hist.length > GRAPH_POINTS + 1) {
hist.shift();
histState.shift();
}
}
function px(i: number, shift: number): number {
return GRAPH_X1 - (hist.length - 1 - i + shift) * (GRAPH_X1 - GRAPH_X0) / GRAPH_POINTS;
}
function py(v: number): number {
return GRAPH_Y0 - clamp(v / Y_MAX_CARS, 0, 1) * (GRAPH_Y0 - GRAPH_Y1);
}
const scene: Scene = {
step: function (): void {
f += 1;
sig.state = phaseAt(f, GREEN_LEN, YELLOW_LEN, RED_LEN);
lane.maybeSpawn(f, 22, 44, 1.8);
lane.step(sigs, none, -1);
if (f % SAMPLE_EVERY === 0) { sample(); }
},
label: function (): string { return stateText(sig.state); },
draw: function (): void {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, STOP_LINE_X, 7);
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 108, W, 192);
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
drawHead(ctx, STOP_LINE_X + 18, ROAD_Y - ROAD_H / 2 - 22, 6, litOf(sig.state));
// 그래프 판
rrect(ctx, GRAPH_X0 - 26, GRAPH_Y1 - 14, GRAPH_X1 - GRAPH_X0 + 40, GRAPH_Y0 - GRAPH_Y1 + 26, 10);
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.rect(GRAPH_X0, GRAPH_Y1 - 6, GRAPH_X1 - GRAPH_X0, GRAPH_Y0 - GRAPH_Y1 + 8);
ctx.clip();
const shift = reduce ? 0 : (f % SAMPLE_EVERY) / SAMPLE_EVERY;
const n = hist.length;
// 신호 색 띠 — 그 순간의 신호가 뒤에 깔린다
for (let i = 0; i < n; i += 1) {
const x0 = px(i, shift);
const x1 = px(i + 1, shift);
ctx.fillStyle = rgba(SIGNAL_RGBS[histState[i]], 0.2);
ctx.fillRect(x0, GRAPH_Y1 - 6, Math.max(x1 - x0, 0) + (i < n - 1 ? 1 : 0), GRAPH_Y0 - GRAPH_Y1 + 8);
}
// 눈금선
ctx.strokeStyle = 'rgba(58,52,84,0.14)';
ctx.lineWidth = 1;
for (let v = 0; v <= Y_MAX_CARS; v += 2) {
ctx.beginPath();
ctx.moveTo(GRAPH_X0, py(v));
ctx.lineTo(GRAPH_X1, py(v));
ctx.stroke();
}
if (n > 2) {
// 선 — 이웃 점의 중간점을 이어 곡선으로. LINE_SMOOTH 가 0 이면 꺾은선, 1 이면 완전한 곡선
let sx = px(0, shift);
let sy = py(hist[0]);
ctx.beginPath();
ctx.moveTo(sx, sy);
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;
}
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK_RGB, 1);
ctx.stroke();
ctx.lineTo(px(n - 1, shift), GRAPH_Y0);
ctx.lineTo(px(0, shift), GRAPH_Y0);
ctx.closePath();
ctx.fillStyle = 'rgba(170,150,230,0.28)';
ctx.fill();
}
ctx.restore();
if (n > 0) {
ctx.beginPath();
ctx.arc(px(n - 1, shift), py(hist[n - 1]), 5, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
}
ctx.fillStyle = rgba(INK_RGB, 0.9);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'right';
for (let v = 0; v <= Y_MAX_CARS; v += 4) {
ctx.fillText(String(v), GRAPH_X0 - 6, py(v) + 3);
}
ctx.textAlign = 'center';
}
};
for (let i = 0; i < 420; i += 1) { scene.step(); }
return scene;
}
export default function TlQueueLengthLiveGraph() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="줄 길이가 신호 주기마다 오르내리는 실시간 그래프" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>줄 길이가 신호 주기마다 오르내리는 실시간 그래프</p>
</div>
</main>
);
}07 · 줄 길이가 신호 주기마다 오르내리는 실시간 그래프 — 측정 누적면적 29.664% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const ARROW_LEN = 13;
const ALL_RED_GAP = 16;
const PHASE_GREEN = 72;
const LANE_OFFSET = 11;
const PATH_REACH = 112;
const SAMPLES = 48;
const APPROACH_N = 14;
const TURN_N = 20;
const EXIT_N = 14;
const TURN_HALF = 22;
const CENTER_X = 240;
const CENTER_Y = 146;
const DOTS_PER_MOVE = 3;
const DOT_SPEED = 0.011;
function makeScene(ctx, reduce) {
const dirs = [[0, 1], [-1, 0], [0, -1], [1, 0]];
// 진행 방향 d 의 오른쪽 · 왼쪽 벡터 (화면 y 는 아래로 자란다)
function rightOf(d) { return [-d[1], d[0]]; }
function leftOf(d) { return [d[1], -d[0]]; }
function makeMove(approach, left) {
const d = dirs[approach];
const r = rightOf(d);
const p0 = [CENTER_X + r[0] * LANE_OFFSET - d[0] * PATH_REACH, CENTER_Y + r[1] * LANE_OFFSET - d[1] * PATH_REACH];
const pts = [];
if (!left) {
const p1 = [CENTER_X + r[0] * LANE_OFFSET + d[0] * PATH_REACH, CENTER_Y + r[1] * LANE_OFFSET + d[1] * PATH_REACH];
for (let i = 0; i <= SAMPLES; i += 1) {
const u = i / SAMPLES;
pts.push([lerp(p0[0], p1[0], u), lerp(p0[1], p1[1], u)]);
}
}
else {
// 좌회전 — 먼 곳에서 교차로 가장자리까지는 직선, 교차로 안에서만 2차 곡선으로 꺾는다
const l = leftOf(d);
const e = [CENTER_X + r[0] * LANE_OFFSET - d[0] * TURN_HALF, CENTER_Y + r[1] * LANE_OFFSET - d[1] * TURN_HALF];
const c = [CENTER_X - l[0] * LANE_OFFSET + d[0] * LANE_OFFSET, CENTER_Y - l[1] * LANE_OFFSET + d[1] * LANE_OFFSET];
const x = [CENTER_X + d[0] * LANE_OFFSET + l[0] * TURN_HALF, CENTER_Y + d[1] * LANE_OFFSET + l[1] * TURN_HALF];
const p2 = [CENTER_X + d[0] * LANE_OFFSET + l[0] * PATH_REACH, CENTER_Y + d[1] * LANE_OFFSET + l[1] * PATH_REACH];
for (let i = 0; i < APPROACH_N; i += 1) {
const u = i / APPROACH_N;
pts.push([lerp(p0[0], e[0], u), lerp(p0[1], e[1], u)]);
}
for (let i = 0; i < TURN_N; i += 1) {
const u = i / TURN_N;
const a = (1 - u) * (1 - u);
const b = 2 * u * (1 - u);
const q = u * u;
pts.push([a * e[0] + b * c[0] + q * x[0], a * e[1] + b * c[1] + q * x[1]]);
}
for (let i = 0; i <= EXIT_N; i += 1) {
const u = i / EXIT_N;
pts.push([lerp(x[0], p2[0], u), lerp(x[1], p2[1], u)]);
}
}
return { approach: approach, left: left, pts: pts };
}
const moves = [makeMove(0, false), makeMove(2, false), makeMove(0, true), makeMove(2, true), makeMove(1, false), makeMove(3, false), makeMove(1, true), makeMove(3, true)];
// 충돌표 — 같은 쪽에서 나오는 둘, 마주 보는 직진과 좌회전, 직각으로 만나는 모든 짝이 서로 부딪힌다.
// 마주 보는 직진끼리, 마주 보는 좌회전끼리만 부딪히지 않는다
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 CONFLICT_MASK = [];
for (let i = 0; i < moves.length; i += 1) {
let mask = 0;
for (let j = 0; j < moves.length; j += 1) {
if (i !== j && collides(moves[i], moves[j])) {
mask |= 1 << j;
}
}
CONFLICT_MASK.push(mask);
}
// 위상 묶기 — 먼저 나온 묶음에 충돌하는 방향이 없으면 거기에 넣는다
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]);
}
}
const slot = PHASE_GREEN + ALL_RED_GAP;
const cycle = slot * PHASE_ORDER.length;
let f = 30;
function phaseIndex() { return Math.floor((((f % cycle) + cycle) % cycle) / slot); }
function inGap() { return ((((f % cycle) + cycle) % cycle) % slot) >= PHASE_GREEN; }
function activeSet() { return inGap() ? [] : PHASE_ORDER[phaseIndex()]; }
function posOn(m, u) {
const t = clamp(u, 0, 1) * SAMPLES;
const i = Math.min(Math.floor(t), SAMPLES - 1);
const k = t - i;
return [lerp(m.pts[i][0], m.pts[i + 1][0], k), lerp(m.pts[i][1], m.pts[i + 1][1], k)];
}
const scene = {
step: function () { f += 1; },
label: function () {
if (inGap()) {
return '전 방향 정지 — 빈 틈';
}
const g = PHASE_ORDER[phaseIndex()];
const m0 = moves[g[0]];
const ns = m0.approach === 0 || m0.approach === 2;
if (ns) {
return m0.left ? '남북 좌회전 — 마주 보는 좌회전만' : '남북 직진 — 마주 보는 직진만';
}
return m0.left ? '동서 좌회전 — 마주 보는 좌회전만' : '동서 직진 — 마주 보는 직진만';
},
draw: function () {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const half = LANE_OFFSET + 14;
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(CENTER_X - half, 0, half * 2, H);
ctx.fillRect(0, CENTER_Y - half, W, half * 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let y = 4; y < H; y += 20) {
if (Math.abs(y - CENTER_Y) > half + 6) {
ctx.fillRect(CENTER_X - 1, y, 2, 10);
}
}
for (let x = 4; x < W; x += 20) {
if (Math.abs(x - CENTER_X) > half + 6) {
ctx.fillRect(x, CENTER_Y - 1, 10, 2);
}
}
const act = activeSet();
const lit = [false, false, false, false];
for (let a = 0; a < act.length; a += 1) {
lit[moves[act[a]].approach] = true;
}
// 팔마다 차로 색칠 — 켜진 쪽은 초록, 꺼진 쪽은 옅은 빨강
for (let a = 0; a < 4; a += 1) {
ctx.fillStyle = rgba(lit[a] ? GREEN_RGB : RED_RGB, lit[a] ? 0.2 : 0.08);
if (a === 0) {
ctx.fillRect(CENTER_X - half, 0, half * 2, CENTER_Y - half);
}
else if (a === 2) {
ctx.fillRect(CENTER_X - half, CENTER_Y + half, half * 2, H - CENTER_Y - half);
}
else if (a === 1) {
ctx.fillRect(CENTER_X + half, CENTER_Y - half, W - CENTER_X - half, half * 2);
}
else {
ctx.fillRect(0, CENTER_Y - half, CENTER_X - half, half * 2);
}
}
// 정지선 신호 띠
for (let a = 0; a < 4; a += 1) {
const d = dirs[a];
const r = rightOf(d);
const bx = CENTER_X - d[0] * (half + 3) + r[0] * LANE_OFFSET;
const by = CENTER_Y - d[1] * (half + 3) + r[1] * LANE_OFFSET;
ctx.fillStyle = rgba(lit[a] ? GREEN_RGB : RED_RGB, 1);
if (d[0] === 0) {
ctx.fillRect(bx - LANE_OFFSET - 2, by - 2, LANE_OFFSET * 2 + 4, 4);
}
else {
ctx.fillRect(bx - 2, by - LANE_OFFSET - 2, 4, LANE_OFFSET * 2 + 4);
}
}
for (let i = 0; i < moves.length; i += 1) {
const m = moves[i];
const on = act.indexOf(i) >= 0;
ctx.beginPath();
ctx.moveTo(m.pts[0][0], m.pts[0][1]);
for (let k = 1; k < m.pts.length; k += 1) {
ctx.lineTo(m.pts[k][0], m.pts[k][1]);
}
ctx.lineWidth = on ? 4 : 2;
ctx.lineCap = 'round';
ctx.strokeStyle = on ? rgba(GREEN_RGB, 0.9) : rgba(RED_RGB, 0.28);
ctx.stroke();
const e = m.pts[m.pts.length - 1];
const q = m.pts[m.pts.length - 3];
const ang = Math.atan2(e[1] - q[1], e[0] - q[0]);
ctx.beginPath();
ctx.moveTo(e[0] + Math.cos(ang) * 6, e[1] + Math.sin(ang) * 6);
ctx.lineTo(e[0] + Math.cos(ang + 2.5) * ARROW_LEN * 0.7, e[1] + Math.sin(ang + 2.5) * ARROW_LEN * 0.7);
ctx.lineTo(e[0] + Math.cos(ang - 2.5) * ARROW_LEN * 0.7, e[1] + Math.sin(ang - 2.5) * ARROW_LEN * 0.7);
ctx.closePath();
ctx.fillStyle = on ? rgba(GREEN_RGB, 1) : rgba(RED_RGB, 0.4);
ctx.fill();
if (on) {
for (let k = 0; k < DOTS_PER_MOVE; k += 1) {
const u = (((f * DOT_SPEED * 3 + k / DOTS_PER_MOVE + i * 0.13) % 1) + 1) % 1;
const p = posOn(m, u);
ctx.beginPath();
ctx.arc(p[0], p[1], 5.5, 0, PI2);
ctx.fillStyle = rgba(CAR_RGBS[(i + k) % CAR_RGBS.length], 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK_RGB, 0.6);
ctx.stroke();
}
}
}
ctx.lineCap = 'butt';
// 모두 빨강인 틈 — 교차로 가운데가 붉게 번진다
if (inGap()) {
glow(ctx, CENTER_X, CENTER_Y, 70, RED_RGB, 0.32);
}
}
};
if (reduce) {
f = 30;
}
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlFourWayPhaseRingArrows.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const ARROW_LEN = 13;
const ALL_RED_GAP = 16;
const PHASE_GREEN = 72;
const LANE_OFFSET = 11;
const PATH_REACH = 112;
const SAMPLES = 48;
const APPROACH_N = 14;
const TURN_N = 20;
const EXIT_N = 14;
const TURN_HALF = 22;
const CENTER_X = 240;
const CENTER_Y = 146;
const DOTS_PER_MOVE = 3;
const DOT_SPEED = 0.011;
interface Move { approach: number; left: boolean; pts: Pt[]; }
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const dirs: Pt[] = [[0, 1], [-1, 0], [0, -1], [1, 0]];
// 진행 방향 d 의 오른쪽 · 왼쪽 벡터 (화면 y 는 아래로 자란다)
function rightOf(d: Pt): Pt { return [-d[1], d[0]]; }
function leftOf(d: Pt): Pt { return [d[1], -d[0]]; }
function makeMove(approach: number, left: boolean): Move {
const d = dirs[approach];
const r = rightOf(d);
const p0: Pt = [CENTER_X + r[0] * LANE_OFFSET - d[0] * PATH_REACH, CENTER_Y + r[1] * LANE_OFFSET - d[1] * PATH_REACH];
const pts: Pt[] = [];
if (!left) {
const p1: Pt = [CENTER_X + r[0] * LANE_OFFSET + d[0] * PATH_REACH, CENTER_Y + r[1] * LANE_OFFSET + d[1] * PATH_REACH];
for (let i = 0; i <= SAMPLES; i += 1) {
const u = i / SAMPLES;
pts.push([lerp(p0[0], p1[0], u), lerp(p0[1], p1[1], u)]);
}
} else {
// 좌회전 — 먼 곳에서 교차로 가장자리까지는 직선, 교차로 안에서만 2차 곡선으로 꺾는다
const l = leftOf(d);
const e: Pt = [CENTER_X + r[0] * LANE_OFFSET - d[0] * TURN_HALF, CENTER_Y + r[1] * LANE_OFFSET - d[1] * TURN_HALF];
const c: Pt = [CENTER_X - l[0] * LANE_OFFSET + d[0] * LANE_OFFSET, CENTER_Y - l[1] * LANE_OFFSET + d[1] * LANE_OFFSET];
const x: Pt = [CENTER_X + d[0] * LANE_OFFSET + l[0] * TURN_HALF, CENTER_Y + d[1] * LANE_OFFSET + l[1] * TURN_HALF];
const p2: Pt = [CENTER_X + d[0] * LANE_OFFSET + l[0] * PATH_REACH, CENTER_Y + d[1] * LANE_OFFSET + l[1] * PATH_REACH];
for (let i = 0; i < APPROACH_N; i += 1) {
const u = i / APPROACH_N;
pts.push([lerp(p0[0], e[0], u), lerp(p0[1], e[1], u)]);
}
for (let i = 0; i < TURN_N; i += 1) {
const u = i / TURN_N;
const a = (1 - u) * (1 - u);
const b = 2 * u * (1 - u);
const q = u * u;
pts.push([a * e[0] + b * c[0] + q * x[0], a * e[1] + b * c[1] + q * x[1]]);
}
for (let i = 0; i <= EXIT_N; i += 1) {
const u = i / EXIT_N;
pts.push([lerp(x[0], p2[0], u), lerp(x[1], p2[1], u)]);
}
}
return { approach: approach, left: left, pts: pts };
}
const moves: Move[] = [makeMove(0, false), makeMove(2, false), makeMove(0, true), makeMove(2, true), makeMove(1, false), makeMove(3, false), makeMove(1, true), makeMove(3, true)];
// 충돌표 — 같은 쪽에서 나오는 둘, 마주 보는 직진과 좌회전, 직각으로 만나는 모든 짝이 서로 부딪힌다.
// 마주 보는 직진끼리, 마주 보는 좌회전끼리만 부딪히지 않는다
function collides(a: Move, b: Move): boolean {
if (a.approach === b.approach) { return true; }
if ((a.approach + 2) % 4 === b.approach) { return a.left !== b.left; }
return true;
}
const CONFLICT_MASK: number[] = [];
for (let i = 0; i < moves.length; i += 1) {
let mask = 0;
for (let j = 0; j < moves.length; j += 1) {
if (i !== j && collides(moves[i], moves[j])) { mask |= 1 << j; }
}
CONFLICT_MASK.push(mask);
}
// 위상 묶기 — 먼저 나온 묶음에 충돌하는 방향이 없으면 거기에 넣는다
const PHASE_ORDER: number[][] = [];
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]); }
}
const slot = PHASE_GREEN + ALL_RED_GAP;
const cycle = slot * PHASE_ORDER.length;
let f = 30;
function phaseIndex(): number { return Math.floor((((f % cycle) + cycle) % cycle) / slot); }
function inGap(): boolean { return ((((f % cycle) + cycle) % cycle) % slot) >= PHASE_GREEN; }
function activeSet(): number[] { return inGap() ? [] : PHASE_ORDER[phaseIndex()]; }
function posOn(m: Move, u: number): Pt {
const t = clamp(u, 0, 1) * SAMPLES;
const i = Math.min(Math.floor(t), SAMPLES - 1);
const k = t - i;
return [lerp(m.pts[i][0], m.pts[i + 1][0], k), lerp(m.pts[i][1], m.pts[i + 1][1], k)];
}
const scene: Scene = {
step: function (): void { f += 1; },
label: function (): string {
if (inGap()) { return '전 방향 정지 — 빈 틈'; }
const g = PHASE_ORDER[phaseIndex()];
const m0 = moves[g[0]];
const ns = m0.approach === 0 || m0.approach === 2;
if (ns) { return m0.left ? '남북 좌회전 — 마주 보는 좌회전만' : '남북 직진 — 마주 보는 직진만'; }
return m0.left ? '동서 좌회전 — 마주 보는 좌회전만' : '동서 직진 — 마주 보는 직진만';
},
draw: function (): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const half = LANE_OFFSET + 14;
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(CENTER_X - half, 0, half * 2, H);
ctx.fillRect(0, CENTER_Y - half, W, half * 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let y = 4; y < H; y += 20) {
if (Math.abs(y - CENTER_Y) > half + 6) { ctx.fillRect(CENTER_X - 1, y, 2, 10); }
}
for (let x = 4; x < W; x += 20) {
if (Math.abs(x - CENTER_X) > half + 6) { ctx.fillRect(x, CENTER_Y - 1, 10, 2); }
}
const act = activeSet();
const lit: boolean[] = [false, false, false, false];
for (let a = 0; a < act.length; a += 1) { lit[moves[act[a]].approach] = true; }
// 팔마다 차로 색칠 — 켜진 쪽은 초록, 꺼진 쪽은 옅은 빨강
for (let a = 0; a < 4; a += 1) {
ctx.fillStyle = rgba(lit[a] ? GREEN_RGB : RED_RGB, lit[a] ? 0.2 : 0.08);
if (a === 0) { ctx.fillRect(CENTER_X - half, 0, half * 2, CENTER_Y - half); }
else if (a === 2) { ctx.fillRect(CENTER_X - half, CENTER_Y + half, half * 2, H - CENTER_Y - half); }
else if (a === 1) { ctx.fillRect(CENTER_X + half, CENTER_Y - half, W - CENTER_X - half, half * 2); }
else { ctx.fillRect(0, CENTER_Y - half, CENTER_X - half, half * 2); }
}
// 정지선 신호 띠
for (let a = 0; a < 4; a += 1) {
const d = dirs[a];
const r = rightOf(d);
const bx = CENTER_X - d[0] * (half + 3) + r[0] * LANE_OFFSET;
const by = CENTER_Y - d[1] * (half + 3) + r[1] * LANE_OFFSET;
ctx.fillStyle = rgba(lit[a] ? GREEN_RGB : RED_RGB, 1);
if (d[0] === 0) { ctx.fillRect(bx - LANE_OFFSET - 2, by - 2, LANE_OFFSET * 2 + 4, 4); }
else { ctx.fillRect(bx - 2, by - LANE_OFFSET - 2, 4, LANE_OFFSET * 2 + 4); }
}
for (let i = 0; i < moves.length; i += 1) {
const m = moves[i];
const on = act.indexOf(i) >= 0;
ctx.beginPath();
ctx.moveTo(m.pts[0][0], m.pts[0][1]);
for (let k = 1; k < m.pts.length; k += 1) { ctx.lineTo(m.pts[k][0], m.pts[k][1]); }
ctx.lineWidth = on ? 4 : 2;
ctx.lineCap = 'round';
ctx.strokeStyle = on ? rgba(GREEN_RGB, 0.9) : rgba(RED_RGB, 0.28);
ctx.stroke();
const e = m.pts[m.pts.length - 1];
const q = m.pts[m.pts.length - 3];
const ang = Math.atan2(e[1] - q[1], e[0] - q[0]);
ctx.beginPath();
ctx.moveTo(e[0] + Math.cos(ang) * 6, e[1] + Math.sin(ang) * 6);
ctx.lineTo(e[0] + Math.cos(ang + 2.5) * ARROW_LEN * 0.7, e[1] + Math.sin(ang + 2.5) * ARROW_LEN * 0.7);
ctx.lineTo(e[0] + Math.cos(ang - 2.5) * ARROW_LEN * 0.7, e[1] + Math.sin(ang - 2.5) * ARROW_LEN * 0.7);
ctx.closePath();
ctx.fillStyle = on ? rgba(GREEN_RGB, 1) : rgba(RED_RGB, 0.4);
ctx.fill();
if (on) {
for (let k = 0; k < DOTS_PER_MOVE; k += 1) {
const u = (((f * DOT_SPEED * 3 + k / DOTS_PER_MOVE + i * 0.13) % 1) + 1) % 1;
const p = posOn(m, u);
ctx.beginPath();
ctx.arc(p[0], p[1], 5.5, 0, PI2);
ctx.fillStyle = rgba(CAR_RGBS[(i + k) % CAR_RGBS.length], 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK_RGB, 0.6);
ctx.stroke();
}
}
}
ctx.lineCap = 'butt';
// 모두 빨강인 틈 — 교차로 가운데가 붉게 번진다
if (inGap()) {
glow(ctx, CENTER_X, CENTER_Y, 70, RED_RGB, 0.32);
}
}
};
if (reduce) { f = 30; }
return scene;
}
export default function TlFourWayPhaseRingArrows() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표</p>
</div>
</main>
);
}08 · 사거리에서 부딪히지 않는 방향끼리 묶어 켜는 화살표 — 측정 누적면적 20.43% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">초록불 — 진행</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
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-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
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: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix, iy) {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
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);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state) {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx, cx, cy, r, lit) {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx, x, y, len, wid, rgb, braking) {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx, roadY, roadH, stopX, seedTrees) {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
constructor(seed, p) {
this.cars = [];
this.nextAt = 0;
this.made = 0;
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v, s, vL, g0) {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f, minGap, maxGap, v0) {
if (f < this.nextAt) {
return false;
}
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) {
return false;
}
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs, onCross, watchX) {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) {
k = j;
break;
}
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) {
c.sigK = k;
c.decided = false;
c.go = false;
}
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
}
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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) {
onCross(c, watchX);
}
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX) {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) {
n += 1;
}
}
return n;
}
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
scene.step();
}
}
function setButton() {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved() {
if (scene.point) {
scene.point(px, py);
}
if (reduce) {
show();
}
}
function onButton() {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
rootEl.classList.remove('is-demo');
}
else {
rootEl.classList.add('is-demo');
}
setButton();
}
function onAction() {
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
return;
}
onButton();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) {
show();
}
}
});
listen(canvas, 'pointermove', function (e) {
const p = toLocal(e);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
const GAP_THRESHOLD = 60;
const EXTEND_STEP = 50;
const MAX_GREEN = 300;
const MIN_GREEN = 90;
const DETECTOR_X = 330;
const STOP_LINE_X = 372;
const YELLOW_LEN = 24;
const RED_LEN = 120;
const ROAD_Y = 100;
const ROAD_H = 72;
const CAR_LEN = 40;
const CAR_WID = 26;
const BAR_X = 40;
const BAR_W = 400;
const BAR_Y = 206;
const DENSE_FRAMES = 520;
const SEED = 446;
function makeScene(ctx, reduce) {
const lane = new Lane(SEED, { accel: 0.06, brake: 0.08, gap: 10, lineGap: 3, vmax: 2.1, headway: 8, delta: 4, len: CAR_LEN });
const sig = { x: STOP_LINE_X, state: SIG_RED };
const sigs = [sig];
let f = 0;
let phaseF = RED_LEN - 40;
let greenElapsed = 0;
let budget = MIN_GREEN;
let gapTimer = 0;
let ended = '';
const pips = [];
let detLit = 0;
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;
}
function startGreen() {
sig.state = SIG_GREEN;
phaseF = 0;
greenElapsed = 0;
budget = MIN_GREEN;
gapTimer = 0;
pips.length = 0;
ended = '';
}
const scene = {
step: function () {
f += 1;
phaseF += 1;
if (detLit > 0) {
detLit -= 1;
}
const dense = (Math.floor(f / DENSE_FRAMES) % 2) === 0;
if (dense) {
lane.maybeSpawn(f, 30, 46, 2);
}
else {
lane.maybeSpawn(f, 110, 180, 2);
}
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';
}
}
}
else if (sig.state === SIG_YELLOW) {
if (phaseF >= YELLOW_LEN) {
sig.state = SIG_RED;
phaseF = 0;
}
}
else if (phaseF >= RED_LEN) {
startGreen();
}
lane.step(sigs, onDetect, DETECTOR_X);
},
// 누르면 감지선 바로 위로 차 한 대가 들어온다
tap: function () {
const last = lane.cars.length > 0 ? lane.cars[lane.cars.length - 1] : null;
if (!last || last.x - last.len > 16) {
lane.nextAt = 0;
lane.maybeSpawn(f, 30, 40, 2);
}
},
label: function () {
if (sig.state === SIG_GREEN) {
return '초록 연장 중 — 차가 이어짐';
}
if (sig.state === SIG_YELLOW) {
return ended === 'max' ? '노란불 — 상한에 도달' : '노란불 — 차가 끊겨 초록을 거둠';
}
return TXT_STOP;
},
draw: function () {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, STOP_LINE_X, 99);
// 정지선 앞 차로 색칠 — 신호 상태가 도로 전체에 번진다
ctx.fillStyle = rgba(SIGNAL_RGBS[sig.state], sig.state === SIG_RED ? 0.1 : 0.17);
ctx.fillRect(0, ROAD_Y - ROAD_H / 2, STOP_LINE_X, ROAD_H);
// 감지선 — 차가 지나는 순간 켜진다
rrect(ctx, DETECTOR_X - 12, ROAD_Y - ROAD_H / 2 + 6, 24, ROAD_H - 12, 4);
ctx.fillStyle = detLit > 0 ? rgba(YELLOW_RGB, 0.9) : 'rgba(255,255,255,0.45)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK_RGB, 0.7);
ctx.stroke();
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
drawHead(ctx, STOP_LINE_X + 20, ROAD_Y - ROAD_H / 2 - 36, 7, litOf(sig.state));
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(STOP_LINE_X + 18, ROAD_Y - ROAD_H / 2 - 6, 4, 6);
// 초록 예산 막대 — 흐른 시간 · 늘어난 한도 · 늘릴 때마다 찍힌 점 · 상한
rrect(ctx, BAR_X, BAR_Y, BAR_W, 16, 6);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fill();
const inGreen = sig.state === SIG_GREEN;
const limitW = BAR_W * (inGreen ? budget : (sig.state === SIG_YELLOW ? greenElapsed : 0)) / MAX_GREEN;
ctx.fillStyle = rgba(GREEN_RGB, 0.28);
rrect(ctx, BAR_X, BAR_Y, Math.max(limitW, 0), 16, 6);
ctx.fill();
const doneW = BAR_W * (inGreen || sig.state === SIG_YELLOW ? greenElapsed : 0) / MAX_GREEN;
ctx.fillStyle = rgba(GREEN_RGB, 1);
rrect(ctx, BAR_X, BAR_Y, Math.max(doneW, 0), 16, 6);
ctx.fill();
for (let i = 0; i < pips.length; i += 1) {
const px = BAR_X + BAR_W * pips[i] / MAX_GREEN;
ctx.beginPath();
ctx.moveTo(px, BAR_Y - 8);
ctx.lineTo(px + 4, BAR_Y - 3);
ctx.lineTo(px, BAR_Y + 2);
ctx.lineTo(px - 4, BAR_Y - 3);
ctx.closePath();
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
}
ctx.fillStyle = rgba(RED_RGB, 1);
ctx.fillRect(BAR_X + BAR_W - 3, BAR_Y - 4, 3, 24);
ctx.fillStyle = rgba(INK_RGB, 0.55);
ctx.fillRect(BAR_X + BAR_W * MIN_GREEN / MAX_GREEN - 1, BAR_Y - 2, 2, 20);
// 차 사이 간격 시계 — 기준에 닿으면 초록을 거둔다
const gw = BAR_W * 0.5;
rrect(ctx, BAR_X, BAR_Y + 30, gw, 10, 4);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fill();
const gf = inGreen ? clamp(gapTimer / GAP_THRESHOLD, 0, 1) : 0;
ctx.fillStyle = rgba(gf > 0.8 ? RED_RGB : YELLOW_RGB, 1);
rrect(ctx, BAR_X, BAR_Y + 30, Math.max(gw * gf, 0), 10, 4);
ctx.fill();
}
};
for (let i = 0; i < 300; i += 1) {
scene.step();
}
void reduce;
return scene;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./TlGapOutGreenExtension.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1200;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const SIG_GREEN = 0;
const SIG_YELLOW = 1;
const SIG_RED = 2;
const GREEN_RGB = [61, 214, 140];
const YELLOW_RGB = [255, 200, 64];
const RED_RGB = [255, 92, 102];
const SIGNAL_RGBS = [GREEN_RGB, YELLOW_RGB, RED_RGB];
const SKY_RGB = [226, 238, 252];
const GRASS_RGB = [206, 236, 214];
const ROAD_RGB = [188, 192, 212];
const ROAD_EDGE_RGB = [162, 166, 192];
const MARK_RGB = [250, 250, 255];
const INK_RGB = [58, 52, 84];
const CAR_RGBS = [[255, 184, 196], [170, 208, 255], [196, 176, 255], [158, 232, 200], [255, 222, 150], [255, 196, 224]];
const TXT_GO = '초록불 — 진행';
const TXT_READY = '노란불 — 정지 준비';
const TXT_STOP = '빨간불 — 정지';
type Pt = [number, number];
interface Scene {
step(): void;
draw(): void;
label(): string;
point?(x: number, y: number): void;
tap?(): void;
}
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); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(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;
};
}
// 격자 점의 값 — 정수 두 개를 섞어 0..1 로 (같은 입력은 언제나 같은 값)
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
// 2차원 값 잡음 — 네 격자 점을 부드럽게 보간
function vnoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
return lerp(lerp(hash2(ix, iy), hash2(ix + 1, iy), fx), lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), fx), fy);
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// 주기 [초록, 노랑, 빨강] 프레임 수에서 프레임 f 의 신호 상태
function phaseAt(f: number, g: number, y: number, r: number): number {
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: number): string {
return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
}
// 신호 상태 -> 빨강·노랑·초록 렌즈 밝기 (위에서 아래)
function litOf(state: number): number[] {
return [state === SIG_RED ? 1 : 0, state === SIG_YELLOW ? 1 : 0, state === SIG_GREEN ? 1 : 0];
}
const LENS_RGBS = [RED_RGB, YELLOW_RGB, GREEN_RGB];
// 세로 신호등 머리 — 렌즈 세 개와 켜진 렌즈의 빛 번짐
function drawHead(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, lit: number[]): void {
const step = r * 2.5;
rrect(ctx, cx - r * 1.45, cy - step - r * 1.45, r * 2.9, step * 2 + r * 2.9, r * 0.9);
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
const ly = cy + (i - 1) * step;
ctx.beginPath();
ctx.arc(cx, ly, r, 0, PI2);
ctx.fillStyle = rgba(LENS_RGBS[i], 0.2 + 0.8 * lit[i]);
ctx.fill();
if (lit[i] > 0.02) {
glow(ctx, cx, ly, r * 3.2, LENS_RGBS[i], 0.55 * lit[i]);
}
}
}
// 위에서 본 차 — 앞 범퍼가 x, 가운데가 y. 제동 중이면 뒷등이 켜진다
function drawCar(ctx: CanvasRenderingContext2D, x: number, y: number, len: number, wid: number, rgb: number[], braking: boolean): void {
const x0 = x - len;
rrect(ctx, x0, y - wid / 2, len, wid, 6);
ctx.fillStyle = rgba(rgb, 1);
ctx.fill();
rrect(ctx, x - len * 0.62, y - wid / 2 + 3, len * 0.34, wid - 6, 3);
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.fill();
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fillRect(x - 3, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x - 3, y + wid / 2 - 6, 3, 4);
ctx.fillStyle = braking ? rgba(RED_RGB, 1) : 'rgba(150,120,150,0.8)';
ctx.fillRect(x0, y - wid / 2 + 2, 3, 4);
ctx.fillRect(x0, y + wid / 2 - 6, 3, 4);
if (braking) {
glow(ctx, x0, y, 12, RED_RGB, 0.45);
}
}
// 한 줄 도로 장면의 바탕 — 잔디 위에 도로띠 하나, 정지선과 횡단보도
function drawLaneBackdrop(ctx: CanvasRenderingContext2D, roadY: number, roadH: number, stopX: number, seedTrees: number): void {
ctx.fillStyle = rgba(GRASS_RGB, 1);
ctx.fillRect(0, 0, W, H);
const rnd = mulberry32(seedTrees);
for (let i = 0; i < 14; i += 1) {
const tx = 16 + rnd() * (W - 32);
const up = rnd() < 0.5;
const ty = up ? roadY - roadH / 2 - 22 - rnd() * 26 : roadY + roadH / 2 + 22 + rnd() * 26;
ctx.beginPath();
ctx.arc(tx, ty, 9 + rnd() * 6, 0, PI2);
ctx.fillStyle = 'rgba(160,214,184,0.9)';
ctx.fill();
}
ctx.fillStyle = rgba(ROAD_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, roadH);
ctx.fillStyle = rgba(ROAD_EDGE_RGB, 1);
ctx.fillRect(0, roadY - roadH / 2, W, 2);
ctx.fillRect(0, roadY + roadH / 2 - 2, W, 2);
ctx.fillStyle = rgba(MARK_RGB, 0.8);
for (let x = 6; x < W; x += 36) {
ctx.fillRect(x, roadY - 1, 18, 2);
}
for (let k = 0; k < 5; k += 1) {
ctx.fillRect(stopX + 5, roadY - roadH / 2 + 3 + k * (roadH - 6) / 5, 12, (roadH - 6) / 5 - 3);
}
ctx.fillRect(stopX - 2, roadY - roadH / 2, 3, roadH);
}
interface Car { x: number; v: number; len: number; rgb: number[]; go: boolean; decided: boolean; sigK: number; brake: boolean; id: number; }
interface Sig { x: number; state: number; }
interface LaneParams { accel: number; brake: number; gap: number; lineGap: number; vmax: number; headway: number; delta: number; len: number; }
// 한 줄 차량 흐름 — 앞차 간격(IDM)과 정지선을 같은 식으로 다룬다. 차는 앞쪽부터 0번이다
class Lane {
cars: Car[] = [];
p: LaneParams;
rnd: () => number;
nextAt = 0;
made = 0;
constructor(seed: number, p: LaneParams) {
this.p = p;
this.rnd = mulberry32(seed);
}
accelFor(v: number, s: number, vL: number, g0: number): number {
const p = this.p;
const sStar = g0 + Math.max(0, v * p.headway + v * (v - vL) / (2 * Math.sqrt(p.accel * p.brake)));
return p.accel * (1 - Math.pow(v / p.vmax, p.delta) - Math.pow(sStar / Math.max(s, 0.5), 2));
}
// f 프레임에, 뒤쪽이 비어 있고 때가 됐으면 한 대 들여보낸다
maybeSpawn(f: number, minGap: number, maxGap: number, v0: number): boolean {
if (f < this.nextAt) { return false; }
const last = this.cars.length > 0 ? this.cars[this.cars.length - 1] : null;
if (last && last.x - last.len < this.p.gap + 10) { return false; }
const rgb = CAR_RGBS[this.made % CAR_RGBS.length];
this.cars.push({ x: -2, v: v0, len: this.p.len, rgb: rgb, go: false, decided: false, sigK: -1, brake: false, id: this.made });
this.made += 1;
this.nextAt = f + minGap + Math.floor(this.rnd() * (maxGap - minGap + 1));
return true;
}
// 신호 목록은 x 오름차순. 한 번 호출 = 한 프레임. 정지선을 넘은 차(detect 용) id 를 돌려준다
step(sigs: Sig[], onCross: (c: Car, x: number) => void, watchX: number): void {
const p = this.p;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
let acc = this.accelFor(c.v, 1e6, c.v, p.gap);
if (i > 0) {
const L = this.cars[i - 1];
acc = Math.min(acc, this.accelFor(c.v, (L.x - L.len) - c.x, L.v, p.gap));
}
let k = -1;
for (let j = 0; j < sigs.length; j += 1) {
if (sigs[j].x >= c.x) { k = j; break; }
}
let clampX = 1e9;
if (k >= 0) {
const sg = sigs[k];
if (c.sigK !== k) { c.sigK = k; c.decided = false; c.go = false; }
if (sg.state === SIG_GREEN) {
c.decided = false;
c.go = false;
} 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;
}
c.brake = acc < -0.012 || c.v < 0.05;
if (prevX < watchX && c.x >= watchX) { onCross(c, watchX); }
}
while (this.cars.length > 0 && this.cars[0].x - this.cars[0].len > W + 20) {
this.cars.shift();
}
}
// 정지선 뒤에서 서 있는 차 수
waiting(stopX: number): number {
let n = 0;
for (let i = 0; i < this.cars.length; i += 1) {
const c = this.cars[i];
if (c.v < 0.25 && c.x < stopX + 2 && c.x > stopX - 260) { n += 1; }
}
return n;
}
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const scene = makeScene(ctx0, reduce);
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let lastLabel = '';
let px = W / 2;
let py = H / 2;
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
scene.draw();
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) { scene.step(); }
}
function setButton(): void {
btnEl.textContent = reduce ? '다음 장면' : (paused ? '재생' : '멈춤');
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function moved(): void {
if (scene.point) { scene.point(px, py); }
if (reduce) { show(); }
}
function onButton(): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) { rootEl.classList.remove('is-demo'); } else { rootEl.classList.add('is-demo'); }
setButton();
}
function onAction(): void {
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
return;
}
onButton();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
scene.step();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
if (scene.tap) {
scene.tap();
if (reduce) { show(); }
}
});
listen(canvas, 'pointermove', function (e: Event): void {
const p = toLocal(e as PointerEvent);
px = p[0];
py = p[1];
moved();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onAction();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
px = clamp(px + dx, 0, W);
py = clamp(py + dy, 0, H);
moved();
});
advance(REDUCE_START_MS);
setButton();
show();
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
const GAP_THRESHOLD = 60;
const EXTEND_STEP = 50;
const MAX_GREEN = 300;
const MIN_GREEN = 90;
const DETECTOR_X = 330;
const STOP_LINE_X = 372;
const YELLOW_LEN = 24;
const RED_LEN = 120;
const ROAD_Y = 100;
const ROAD_H = 72;
const CAR_LEN = 40;
const CAR_WID = 26;
const BAR_X = 40;
const BAR_W = 400;
const BAR_Y = 206;
const DENSE_FRAMES = 520;
const SEED = 446;
function makeScene(ctx: CanvasRenderingContext2D, reduce: boolean): Scene {
const lane = new Lane(SEED, { accel: 0.06, brake: 0.08, gap: 10, lineGap: 3, vmax: 2.1, headway: 8, delta: 4, len: CAR_LEN });
const sig: Sig = { x: STOP_LINE_X, state: SIG_RED };
const sigs: Sig[] = [sig];
let f = 0;
let phaseF = RED_LEN - 40;
let greenElapsed = 0;
let budget = MIN_GREEN;
let gapTimer = 0;
let ended = '';
const pips: number[] = [];
let detLit = 0;
function onDetect(c: Car, x: number): void {
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;
}
function startGreen(): void {
sig.state = SIG_GREEN;
phaseF = 0;
greenElapsed = 0;
budget = MIN_GREEN;
gapTimer = 0;
pips.length = 0;
ended = '';
}
const scene: Scene = {
step: function (): void {
f += 1;
phaseF += 1;
if (detLit > 0) { detLit -= 1; }
const dense = (Math.floor(f / DENSE_FRAMES) % 2) === 0;
if (dense) { lane.maybeSpawn(f, 30, 46, 2); } else { lane.maybeSpawn(f, 110, 180, 2); }
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'; }
}
} else if (sig.state === SIG_YELLOW) {
if (phaseF >= YELLOW_LEN) {
sig.state = SIG_RED;
phaseF = 0;
}
} else if (phaseF >= RED_LEN) {
startGreen();
}
lane.step(sigs, onDetect, DETECTOR_X);
},
// 누르면 감지선 바로 위로 차 한 대가 들어온다
tap: function (): void {
const last = lane.cars.length > 0 ? lane.cars[lane.cars.length - 1] : null;
if (!last || last.x - last.len > 16) {
lane.nextAt = 0;
lane.maybeSpawn(f, 30, 40, 2);
}
},
label: function (): string {
if (sig.state === SIG_GREEN) { return '초록 연장 중 — 차가 이어짐'; }
if (sig.state === SIG_YELLOW) { return ended === 'max' ? '노란불 — 상한에 도달' : '노란불 — 차가 끊겨 초록을 거둠'; }
return TXT_STOP;
},
draw: function (): void {
drawLaneBackdrop(ctx, ROAD_Y, ROAD_H, STOP_LINE_X, 99);
// 정지선 앞 차로 색칠 — 신호 상태가 도로 전체에 번진다
ctx.fillStyle = rgba(SIGNAL_RGBS[sig.state], sig.state === SIG_RED ? 0.1 : 0.17);
ctx.fillRect(0, ROAD_Y - ROAD_H / 2, STOP_LINE_X, ROAD_H);
// 감지선 — 차가 지나는 순간 켜진다
rrect(ctx, DETECTOR_X - 12, ROAD_Y - ROAD_H / 2 + 6, 24, ROAD_H - 12, 4);
ctx.fillStyle = detLit > 0 ? rgba(YELLOW_RGB, 0.9) : 'rgba(255,255,255,0.45)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK_RGB, 0.7);
ctx.stroke();
for (let i = 0; i < lane.cars.length; i += 1) {
const c = lane.cars[i];
drawCar(ctx, c.x, ROAD_Y, c.len, CAR_WID, c.rgb, c.brake);
}
drawHead(ctx, STOP_LINE_X + 20, ROAD_Y - ROAD_H / 2 - 36, 7, litOf(sig.state));
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fillRect(STOP_LINE_X + 18, ROAD_Y - ROAD_H / 2 - 6, 4, 6);
// 초록 예산 막대 — 흐른 시간 · 늘어난 한도 · 늘릴 때마다 찍힌 점 · 상한
rrect(ctx, BAR_X, BAR_Y, BAR_W, 16, 6);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fill();
const inGreen = sig.state === SIG_GREEN;
const limitW = BAR_W * (inGreen ? budget : (sig.state === SIG_YELLOW ? greenElapsed : 0)) / MAX_GREEN;
ctx.fillStyle = rgba(GREEN_RGB, 0.28);
rrect(ctx, BAR_X, BAR_Y, Math.max(limitW, 0), 16, 6);
ctx.fill();
const doneW = BAR_W * (inGreen || sig.state === SIG_YELLOW ? greenElapsed : 0) / MAX_GREEN;
ctx.fillStyle = rgba(GREEN_RGB, 1);
rrect(ctx, BAR_X, BAR_Y, Math.max(doneW, 0), 16, 6);
ctx.fill();
for (let i = 0; i < pips.length; i += 1) {
const px = BAR_X + BAR_W * pips[i] / MAX_GREEN;
ctx.beginPath();
ctx.moveTo(px, BAR_Y - 8);
ctx.lineTo(px + 4, BAR_Y - 3);
ctx.lineTo(px, BAR_Y + 2);
ctx.lineTo(px - 4, BAR_Y - 3);
ctx.closePath();
ctx.fillStyle = rgba(INK_RGB, 1);
ctx.fill();
}
ctx.fillStyle = rgba(RED_RGB, 1);
ctx.fillRect(BAR_X + BAR_W - 3, BAR_Y - 4, 3, 24);
ctx.fillStyle = rgba(INK_RGB, 0.55);
ctx.fillRect(BAR_X + BAR_W * MIN_GREEN / MAX_GREEN - 1, BAR_Y - 2, 2, 20);
// 차 사이 간격 시계 — 기준에 닿으면 초록을 거둔다
const gw = BAR_W * 0.5;
rrect(ctx, BAR_X, BAR_Y + 30, gw, 10, 4);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fill();
const gf = inGreen ? clamp(gapTimer / GAP_THRESHOLD, 0, 1) : 0;
ctx.fillStyle = rgba(gf > 0.8 ? RED_RGB : YELLOW_RGB, 1);
rrect(ctx, BAR_X, BAR_Y + 30, Math.max(gw * gf, 0), 10, 4);
ctx.fill();
}
};
for (let i = 0; i < 300; i += 1) { scene.step(); }
void reduce;
return scene;
}
export default function TlGapOutGreenExtension() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">초록불 — 진행</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호</p>
</div>
</main>
);
}09 · 차가 끊기면 초록을 거두고 이어지면 늘리는 감응 신호 — 측정 누적면적 10.003% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 로 그려서, 파일 하나만 옮겨도 돈다.









