diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts index 25407f7..936ef18 100644 --- a/src/scene/world/sign.ts +++ b/src/scene/world/sign.ts @@ -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> = { + 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(