feat: redesign roadside sign faces as exit signs with chapter accents
This commit is contained in:
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import type { SignSnapshot } from '@/journey/types';
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
import {
|
import {
|
||||||
|
SIGN_ROUTE_ORDER,
|
||||||
signRouteFor,
|
signRouteFor,
|
||||||
signViewportProfileFor,
|
signViewportProfileFor,
|
||||||
type SignCorridor,
|
type SignCorridor,
|
||||||
@@ -18,7 +19,7 @@ interface ProjectingCamera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SIGN_MARKER_FONT =
|
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';
|
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
||||||
export interface SignWorld {
|
export interface SignWorld {
|
||||||
group: SignGroup;
|
group: SignGroup;
|
||||||
@@ -516,6 +517,7 @@ export function createSign(
|
|||||||
face.userData.textureUpdateCount += 1;
|
face.userData.textureUpdateCount += 1;
|
||||||
face.userData.fontReadyWhenDrawn =
|
face.userData.fontReadyWhenDrawn =
|
||||||
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||||
|
edgeMaterial.emissive.set(SIGN_ACCENTS[sign.targetId]);
|
||||||
if (context)
|
if (context)
|
||||||
drawLabel(
|
drawLabel(
|
||||||
context,
|
context,
|
||||||
@@ -693,60 +695,116 @@ function createClippedShape(width: number, height: number, cut: number) {
|
|||||||
return shape;
|
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(
|
function drawLabel(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
route: SignRouteSpec,
|
route: SignRouteSpec,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
): void {
|
): void {
|
||||||
|
const accent = SIGN_ACCENTS[route.id];
|
||||||
context.clearRect(0, 0, width, height);
|
context.clearRect(0, 0, width, height);
|
||||||
context.save();
|
context.save();
|
||||||
context.scale(width / 2_048, height / 512);
|
context.scale(width / 2_048, height / 512);
|
||||||
context.imageSmoothingEnabled = true;
|
context.imageSmoothingEnabled = true;
|
||||||
context.imageSmoothingQuality = 'high';
|
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.textAlign = 'left';
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
context.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
|
|
||||||
|
|
||||||
context.globalAlpha = 0.6;
|
// Exit tab.
|
||||||
context.fillStyle = '#61416d';
|
context.font = '720 66px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
context.fillRect(142, 174, 1_610, 2);
|
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';
|
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.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
|
||||||
context.fillText(route.label, 142, 326);
|
context.fillText(route.label, 96, 322);
|
||||||
|
|
||||||
context.strokeStyle = '#ffb879';
|
// Lane rule under the label.
|
||||||
context.lineWidth = 10;
|
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.lineCap = 'square';
|
||||||
context.lineJoin = 'miter';
|
context.lineJoin = 'miter';
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(1_776, 304);
|
context.moveTo(-110, 0);
|
||||||
context.lineTo(1_890, 304);
|
context.lineTo(100, 0);
|
||||||
context.lineTo(1_846, 260);
|
context.moveTo(30, -70);
|
||||||
context.moveTo(1_890, 304);
|
context.lineTo(100, 0);
|
||||||
context.lineTo(1_846, 348);
|
context.lineTo(30, 70);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
context.restore();
|
context.restore();
|
||||||
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fittedLabelSize(
|
function fittedLabelSize(
|
||||||
|
|||||||
Reference in New Issue
Block a user