feat: redesign roadside sign faces as exit signs with chapter accents

This commit is contained in:
2026-10-10 17:46:17 +03:00
parent 45cc1056ee
commit 8a77fc02b2

View File

@@ -2,6 +2,7 @@ import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { SignSnapshot } from '@/journey/types';
import {
SIGN_ROUTE_ORDER,
signRouteFor,
signViewportProfileFor,
type SignCorridor,
@@ -18,7 +19,7 @@ interface ProjectingCamera {
}
const SIGN_MARKER_FONT =
'560 46px "Geist Mono Variable", ui-monospace, monospace';
'600 60px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
export interface SignWorld {
group: SignGroup;
@@ -516,6 +517,7 @@ export function createSign(
face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn =
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
edgeMaterial.emissive.set(SIGN_ACCENTS[sign.targetId]);
if (context)
drawLabel(
context,
@@ -693,60 +695,116 @@ function createClippedShape(width: number, height: number, cut: number) {
return shape;
}
/** Chapter accents, matching the CSS --chapter-accent of each panel. */
const SIGN_ACCENTS: Readonly<Record<SectionId, string>> = {
start: '#ff83c2',
profile: '#ff83c2',
experience: '#c4a2ff',
infrastructure: '#8be9ed',
'selected-builds': '#ffa68d',
'additional-work': '#ff83c2',
toolkit: '#c4a2ff',
contact: '#ffa68d',
};
export function signAccentFor(id: SectionId): string {
return SIGN_ACCENTS[id];
}
function nextRouteLabel(id: SectionId): string | null {
const index = SIGN_ROUTE_ORDER.indexOf(id);
const next = SIGN_ROUTE_ORDER[index + 1];
return next === undefined ? null : signRouteFor(next).label;
}
function chamferedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
cut: number,
): void {
context.beginPath();
context.moveTo(x + cut, y);
context.lineTo(x + width - cut, y);
context.lineTo(x + width, y + cut);
context.lineTo(x + width, y + height);
context.lineTo(x, y + height);
context.lineTo(x, y + cut);
context.closePath();
}
/** Face layout shared with the DOM plates: exit tab, "then" line, chapter
* label and an exit arrow that points toward the sign's side of the road. */
function drawLabel(
context: CanvasRenderingContext2D,
route: SignRouteSpec,
width: number,
height: number,
): void {
const accent = SIGN_ACCENTS[route.id];
context.clearRect(0, 0, width, height);
context.save();
context.scale(width / 2_048, height / 512);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.globalAlpha = 0.16;
context.fillStyle = '#cfabcd';
for (const [x, y, scratchWidth] of [
[312, 84, 142],
[742, 424, 96],
[1_116, 66, 188],
[1_488, 446, 112],
] as const) {
context.fillRect(x, y, scratchWidth, 2);
}
context.globalAlpha = 1;
context.fillStyle = '#ff95cd';
context.fillRect(82, 72, 10, 368);
context.fillStyle = '#bbaae0';
context.font = SIGN_MARKER_FONT;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
context.globalAlpha = 0.6;
context.fillStyle = '#61416d';
context.fillRect(142, 174, 1_610, 2);
// Exit tab.
context.font = '720 66px "Geist Mono Variable", ui-monospace, monospace';
const tabText = `EXIT ${route.marker}`;
const tabWidth = context.measureText(tabText).width + 72;
context.fillStyle = accent;
chamferedRect(context, 96, 50, tabWidth, 116, 18);
context.fill();
context.fillStyle = '#160b22';
context.fillText(tabText, 132, 110);
context.globalAlpha = 1;
// "Then" line: the next exit on the route.
const next = nextRouteLabel(route.id);
context.font = SIGN_MARKER_FONT;
context.fillStyle = '#d8c9ea';
context.fillText(
next === null ? 'END OF ROUTE' : `THEN \u2192 ${next.toUpperCase()}`,
96 + tabWidth + 40,
110,
);
// Chapter label.
context.fillStyle = '#fff2df';
const labelSize = fittedLabelSize(context, route.label, 1_520);
const labelSize = fittedLabelSize(context, route.label, 1_480);
context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
context.fillText(route.label, 142, 326);
context.fillText(route.label, 96, 322);
context.strokeStyle = '#ffb879';
context.lineWidth = 10;
// Lane rule under the label.
context.globalAlpha = 0.7;
context.fillStyle = accent;
context.fillRect(96, 448, 1_380, 6);
context.globalAlpha = 0.35;
for (let x = 1_500; x < 1_940; x += 64) context.fillRect(x, 448, 34, 6);
context.globalAlpha = 1;
// Exit arrow: leans toward the roadside the sign stands on.
const leansLeft = route.corridor === 'left' || route.corridor === 'outer-left';
context.save();
context.translate(1_800, 270);
if (leansLeft) context.scale(-1, 1);
context.rotate(-Math.PI / 4);
context.strokeStyle = accent;
context.lineWidth = 22;
context.lineCap = 'square';
context.lineJoin = 'miter';
context.beginPath();
context.moveTo(1_776, 304);
context.lineTo(1_890, 304);
context.lineTo(1_846, 260);
context.moveTo(1_890, 304);
context.lineTo(1_846, 348);
context.moveTo(-110, 0);
context.lineTo(100, 0);
context.moveTo(30, -70);
context.lineTo(100, 0);
context.lineTo(30, 70);
context.stroke();
context.restore();
context.restore();
}
function fittedLabelSize(