traffic light css 9パターン — 信号で車が止まり緑の波がつながる
traffic light css を色を切り替えるだけのタイマーで作ると、車は赤でも突っ込み、信号は互いにずれ、歩行者は残り秒数が分からない — 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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 緑・黄・赤が時間割どおりに切り替わる信号
- 02 赤信号で前の車との間隔を保って止まる車
- 03 ブロックごとにずらした四つの信号と続く緑の波
- 04 残り秒数を縮む弧で見せる歩行者信号
- 05 見る角度でひさしの影がレンズを覆う信号
- 06 濡れた道路に長くにじむ信号の光の反射
- 07 信号の周期ごとに行列の長さが上下するリアルタイムグラフ
- 08 四つ角でぶつからない方向ごとにまとめて点く矢印
- 09 車が途切れると緑を切り上げ続くと延ばす感応式信号
traffic light css 9パターン — 間隔・ずれ・検知を計算して交差点が生きる
九つの場面はどれも、480×300(W・H)のキャンバス一枚と状態の文字一行、ボタン一つ、キャプションでできています。入力と時間を受け持つ main は共通で、場面ごとに違うのは makeScene だけです。時間は16ミリ秒(FIXED_DT)ごとに区切って進め、乱数を使う場面(02・03・04・06・07・09)は Math.random ではなく種446の mulberry32 で動かすので、開き直しても同じ画面が繰り返されます。01・05・08 は乱数を使いません。
信号の色をキーフレームで順に変えるだけの traffic light animation css は、赤・黄・緑を時間割どおりに点けるだけで、車も人もその信号を知りません。そこでこの記事は三つの計算を足しました。前の車との間隔と停止線までの距離から加速度を求める式、信号ごとに始まりを遅らせる式、感知線を通る車の間隔から緑の長さを決める式です。
車の計算の骨組みは Lane.accelFor 一つです。目標の間隔を s = g0 + max(0, v·headway + v·(v − vL) ÷ (2·√(accel·brake))) とおき、加速度を accel·(1 − (v ÷ vmax)^δ − (s ÷ s)²) で返します。headway・accel・brake は Lane に渡す引数で、02 では定数 HEADWAY(10)・ACCEL(0.04)・BRAKE_DECEL(0.07)がその値になり、03・07・09 はそれぞれ自分の値を渡します。前の車との間隔から出た値と停止線までの距離から出た値のうち小さいほうを使い、下限は −3·brake です(02 では −0.21)。δ は 02・07・09 で4、03 で40なので、03 は巡航速度の近くまで加速が続き、車群が散りません。Lane の定義は九つのファイルにありますが、実際に車列を作るのは 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 html css code として貼って試せる大きさで、最初から最後までつながったファイルは圧縮ファイルに入っています。
01緑・黄・赤が時間割どおりに切り替わる信号
phaseAt がフレーム番号を一周期(緑100・黄30・赤100)で割った余りから信号を選び、relax が三つのレンズの明るさを毎フレーム LIT_RISE の0.16ずつ目標へ近づけます。にじみの半径は明るさ1で78、アルファは明るさの1.6乗(LENS_GAMMA)なので、点くレンズは膨らみ、消えるレンズは静まります。測定では動いたフレームが23/23、累積面積は45.4%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
// 点く側は膨らみ消える側は静まる — 目標の明るさへ 1 フレームに 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台でした。測定では動いたフレームが23/23、累積面積は10.2%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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.0フレームずつ遅れて四つ目で累積9フレームずれても、緑が150フレームあって吸収されます。測定では動いたフレームが23/23、累積面積は12.7%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 信号 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で速くなり、待っている人は点滅が始まると歩き出しません。測定では動いたフレームが14/23、累積面積は13.6%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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ミリ秒で、横は振幅214の正弦波、縦は1.7倍の速さで振幅96の正弦波を重ねた曲線をたどります。測定では動いたフレームが23/23、累積面積は18.5%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 目の位置から見たひさしの開口円のずれ量 — 目が右なら開口が左へ寄り、レンズの右側が隠れる
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回呼ばれます。測定では動いたフレームが23/23、累積面積は45.0%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 反射 — 道路の行ごとに元の行を反転して取り込み、ノイズで横へ揺らし、遠いほど薄くする
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)に積み、点の間は隣の点との中点をつなぐ二次曲線で結び、LINE_SMOOTH の0.5が曲がり具合を決めます。点ごとにその瞬間の信号の色を背景に敷くので、列がどの信号で伸び縮みしたかが読めます。測定では動いたフレームが23/23、累積面積は29.7%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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/23、累積面積は20.4%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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以上になると限度を今に縮めて緑を終えます。延ばすたびにバーへ点が一つ打たれます。測定では動いたフレームが23/23、累積面積は10.0%でした。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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フレーム回して数えた値なので、ブラウザの画面で直接測った数値とは区別してください。
停止間隔を狭めると列が長くなる
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まで揺れます。
矢印キーがページのスクロールを止める
キャンバスにフォーカスがあると、矢印キー・Enter・Space に対して各ファイルが preventDefault を呼びます。矢印キーに反応する(point を動かす)のは 05 だけなので、ほかの場面では何も起きないのにスクロールだけが止まります。このコードを長いページへ貼るときは、キャンバスの上でキーボードのスクロールが止まらないかを先に確かめてください。Enter と Space は、02 と 09 では tap として信号の切り替えと車の投入になり、残りの七つでは一時停止と再生になります。02 と 09 の一時停止はボタンだけでできます。
信号を色だけで読ませない
レンズは上から赤・黄・緑の順(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フレームに縮みます。 |
三つの変形の数字は、すべてノードのシミュレーションで数えた値です。
reduce と矢印キーのときの動き
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 のひさしの影は同じ考え方をレンズの中で格子に数えたものです。
traffic light css を作る前によくある質問
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で描いているので、ファイル一つを移すだけで動きます。









