fix: make nightdrive signage reliable and legible

This commit is contained in:
2026-09-03 17:07:00 +03:00
parent 4404c07f93
commit b075041918
50 changed files with 4435 additions and 200 deletions

View File

@@ -6,7 +6,7 @@ import type { JourneySnapshot } from "@/journey/types";
const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>;
journey: JourneySnapshot;
onSectionLinkClick: (event: MouseEvent) => void;
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
}>();
const navigationLabels: Readonly<Record<SectionId, string>> = {
@@ -42,7 +42,7 @@ onBeforeUnmount(() => {
});
const navigate = (event: MouseEvent): void => {
props.onSectionLinkClick(event);
props.onSectionLinkClick(event, true);
if (window.matchMedia("(max-width: 767px)").matches)
disclosure.value?.removeAttribute("open");
};

View File

@@ -121,6 +121,8 @@ export function useJourneySections(
const sign = signController.sample({
now,
source: navigationState.value.source,
navigationEpoch: navigationState.value.epoch,
navigationDestinationId: navigationState.value.signDestinationId,
direction: driveSnapshot.direction,
acceleration: driveSnapshot.acceleration,
deltaY: driveSnapshot.scrollY - previousScrollY,

View File

@@ -33,7 +33,7 @@ const isEditableTarget = (target: EventTarget | null): boolean =>
export interface KeyboardNavigation {
state: Readonly<Ref<NavigationState>>;
onSectionLinkClick(event: MouseEvent): void;
onSectionLinkClick(event: MouseEvent, requestSign?: boolean): void;
notePassiveSection(id: SectionId): void;
}
@@ -44,6 +44,7 @@ export function useKeyboardNavigation(
epoch: 0,
source: "initial",
destinationId: null,
signDestinationId: null,
});
let controller: NavigationController | undefined;
let teardown = (): void => {};
@@ -60,7 +61,7 @@ export function useKeyboardNavigation(
else controller.restore(id, target);
};
const onSectionLinkClick = (event: MouseEvent): void => {
const onSectionLinkClick = (event: MouseEvent, requestSign = false): void => {
if (
event.defaultPrevented ||
event.button !== 0 ||
@@ -76,7 +77,7 @@ export function useKeyboardNavigation(
const validated = validateSectionAnchor(event.currentTarget, canonicalIds);
if (validated === null) return;
event.preventDefault();
controller.navigate(validated.id, validated.target);
controller.navigate(validated.id, validated.target, { requestSign });
};
onMounted(() => {

View File

@@ -9,6 +9,7 @@ export interface NavigationState {
epoch: number;
source: JourneySource;
destinationId: SectionId | null;
signDestinationId: SectionId | null;
}
export interface NavigationRuntime {
@@ -28,7 +29,11 @@ export interface NavigationRuntime {
}
export interface NavigationController {
navigate(id: SectionId, target: HTMLElement): number;
navigate(
id: SectionId,
target: HTMLElement,
options?: { requestSign?: boolean },
): number;
noteUserScrollIntent(): number;
notePassiveSection(id: SectionId): void;
initialize(id: SectionId, target: HTMLElement): number;
@@ -118,6 +123,7 @@ export function createNavigationController(
epoch: 0,
source: "initial",
destinationId: null,
signDestinationId: null,
};
const subscribers = new Set<(state: NavigationState) => void>();
let frameId: number | undefined;
@@ -159,12 +165,14 @@ export function createNavigationController(
const beginEpoch = (
source: JourneySource,
destinationId: SectionId | null,
signDestinationId: SectionId | null = null,
): number => {
cancelPending();
currentState = {
epoch: currentState.epoch + 1,
source,
destinationId,
signDestinationId,
};
if (TEST_BUILD)
window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
@@ -292,9 +300,13 @@ export function createNavigationController(
};
return {
navigate(id, target) {
navigate(id, target, options) {
requireTarget(id, target);
const epoch = beginEpoch("explicit-navigation", id);
const epoch = beginEpoch(
"explicit-navigation",
id,
options?.requestSign === true ? id : null,
);
if (canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id) {
runtime.pushHash(`#${id}`);
}

View File

@@ -8,6 +8,7 @@ import {
SIGN_LIFECYCLE,
SIGN_ROUTE_ORDER,
signRouteFor,
signTriggerMaximumVhFor,
signViewportProfileFor,
type SignViewportProfile,
} from '@/journey/sign-routes';
@@ -18,15 +19,27 @@ interface VisibleState {
latchedAt: number;
approachDuration: number;
viewportProfile: SignViewportProfile;
navigationArrival: boolean;
dwellStartedAt: number | null;
departStartedAt: number | null;
traversalGeneration: number;
generation: number;
}
interface PendingTarget {
targetId: SectionId;
navigationArrival: boolean;
}
type State =
| { kind: 'idle' }
| VisibleState
| { kind: 'cooldown'; until: number };
| {
kind: 'cooldown';
targetId: SectionId;
traversalGeneration: number;
until: number;
};
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
@@ -35,8 +48,17 @@ const smoothstep = (minimum: number, maximum: number, value: number): number =>
return progress * progress * (3 - 2 * progress);
};
const withNavigationArrival = (
snapshot: SignSnapshot,
navigationArrival: boolean,
): SignSnapshot => {
if (navigationArrival) snapshot.navigationArrival = true;
return snapshot;
};
const isFiniteSample = (input: SignControllerSample): boolean =>
Number.isFinite(input.now) &&
Number.isFinite(input.navigationEpoch) &&
Number.isFinite(input.acceleration) &&
Number.isFinite(input.deltaY) &&
(input.viewportWidth === undefined || (
@@ -51,13 +73,17 @@ export function createSignController(): SignController {
let state: State = { kind: 'idle' };
let lastNow: number | null = null;
let generation = 0;
let pendingTargetId: SectionId | null = null;
let pendingTarget: PendingTarget | null = null;
let lastNavigationEpoch = 0;
let lastNavigationTargetId: SectionId | null = null;
let traversalGeneration = 0;
const completedTraversalTargets = new Set<SectionId>();
const reset = (now?: number): void => {
generation += 1;
state = { kind: 'idle' };
pendingTargetId = null;
pendingTarget = null;
lastNavigationTargetId = null;
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
};
@@ -67,6 +93,7 @@ export function createSignController(): SignController {
acceleration: number,
viewportWidth: number,
viewportHeight: number,
navigationArrival = false,
): VisibleState => {
generation += 1;
return {
@@ -76,8 +103,10 @@ export function createSignController(): SignController {
approachDuration: SIGN_LIFECYCLE.approachMs
- SIGN_LIFECYCLE.approachReductionMs * clamp01(acceleration),
viewportProfile: signViewportProfileFor(viewportWidth, viewportHeight),
navigationArrival,
dwellStartedAt: null,
departStartedAt: null,
traversalGeneration,
generation,
};
};
@@ -104,58 +133,74 @@ export function createSignController(): SignController {
if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
if (progress >= 1) {
if (visible.dwellStartedAt !== null) completedTraversalTargets.add(visible.targetId);
if (
visible.dwellStartedAt !== null
&& visible.traversalGeneration === traversalGeneration
) {
completedTraversalTargets.add(visible.targetId);
}
state = {
kind: 'cooldown',
targetId: visible.targetId,
traversalGeneration: visible.traversalGeneration,
until: visible.departStartedAt
+ SIGN_LIFECYCLE.departMs
+ SIGN_LIFECYCLE.cooldownMs,
};
return null;
}
return {
return withNavigationArrival({
targetId: visible.targetId,
phase: 'depart',
progress,
opacity: 1 - smoothstep(0.1, 1, progress),
opacity: 1 - smoothstep(0.58, 0.94, progress),
viewportProfile: visible.viewportProfile,
};
}, visible.navigationArrival);
}
const elapsed = Math.max(0, now - visible.latchedAt);
if (elapsed < SIGN_LIFECYCLE.anticipateMs) {
return {
return withNavigationArrival({
targetId: visible.targetId,
phase: 'anticipate',
progress: clamp01(elapsed / SIGN_LIFECYCLE.anticipateMs),
opacity: 0,
viewportProfile: visible.viewportProfile,
};
}, visible.navigationArrival);
}
const approachElapsed = elapsed - SIGN_LIFECYCLE.anticipateMs;
if (approachElapsed < visible.approachDuration) {
const progress = clamp01(approachElapsed / visible.approachDuration);
return {
return withNavigationArrival({
targetId: visible.targetId,
phase: 'approach',
progress,
opacity: smoothstep(0, 0.42, progress),
viewportProfile: visible.viewportProfile,
};
}, visible.navigationArrival);
}
if (visible.dwellStartedAt === null) visible.dwellStartedAt = now;
return {
return withNavigationArrival({
targetId: visible.targetId,
phase: 'dwell',
progress: 1,
opacity: 1,
viewportProfile: visible.viewportProfile,
};
}, visible.navigationArrival);
};
const canonicalCandidate = (input: SignControllerSample): SectionId | null => {
const rearmRoutesFrom = (targetId: SectionId): void => {
const targetIndex = SIGN_ROUTE_ORDER.indexOf(targetId);
if (targetIndex < 0) return;
for (let index = targetIndex; index < SIGN_ROUTE_ORDER.length; index += 1) {
const routeId = SIGN_ROUTE_ORDER[index];
if (routeId !== undefined) completedTraversalTargets.delete(routeId);
}
};
const passiveCandidate = (input: SignControllerSample): SectionId | null => {
if (
input.direction !== 1
|| input.deltaY <= 0
@@ -175,7 +220,11 @@ export function createSignController(): SignController {
) return null;
const route = signRouteFor(input.candidateId);
return route.eligible
&& input.candidateDistanceVh <= route.triggerMaximumVh
&& input.candidateDistanceVh <= signTriggerMaximumVhFor(
route,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
)
&& !completedTraversalTargets.has(route.id)
? route.id
: null;
@@ -188,32 +237,130 @@ export function createSignController(): SignController {
}
lastNow = input.now;
if (!input.motionAllowed || input.source !== 'passive-scroll') {
if (input.source !== 'passive-scroll') {
const receivedNavigationEpoch = input.source !== 'passive-scroll'
&& input.navigationEpoch > lastNavigationEpoch;
const receivedNavigationIntent = input.source === 'explicit-navigation'
&& receivedNavigationEpoch;
let navigationCandidate: SectionId | null = null;
let repeatedNavigationIntent = false;
if (receivedNavigationEpoch) {
lastNavigationEpoch = input.navigationEpoch;
if (receivedNavigationIntent) {
const destinationId = input.navigationDestinationId;
repeatedNavigationIntent = destinationId !== null
&& destinationId === lastNavigationTargetId
&& (
input.activeSectionId === destinationId
|| (state.kind === 'visible' && state.targetId === destinationId)
);
lastNavigationTargetId = destinationId;
} else {
lastNavigationTargetId = null;
}
if (!repeatedNavigationIntent) {
traversalGeneration += 1;
completedTraversalTargets.clear();
}
const destinationId = input.navigationDestinationId;
if (
receivedNavigationIntent
&& destinationId !== null
&& !repeatedNavigationIntent
) {
const route = signRouteFor(destinationId);
if (route.eligible) navigationCandidate = route.id;
}
}
if (!input.motionAllowed) {
reset(input.now);
return null;
}
if (input.source === 'initial' || input.source === 'history-restoration') {
reset(input.now);
return null;
}
const candidate = canonicalCandidate(input);
const discontinuous =
if (
receivedNavigationIntent
&& navigationCandidate === null
&& !repeatedNavigationIntent
) {
pendingTarget = null;
if (state.kind === 'idle' || state.kind === 'cooldown') {
state = { kind: 'idle' };
return null;
}
}
if (input.source === 'passive-scroll' && input.direction < 0) {
pendingTarget = null;
if (input.candidateId !== null && input.candidateDistanceVh !== null) {
const retreatRoute = signRouteFor(input.candidateId);
if (
retreatRoute.eligible
&& input.candidateDistanceVh
>= signTriggerMaximumVhFor(
retreatRoute,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
) + SIGN_LIFECYCLE.rearmMarginVh
) {
traversalGeneration += 1;
rearmRoutesFrom(retreatRoute.id);
}
}
}
const candidate = receivedNavigationIntent
? navigationCandidate
: input.source === 'passive-scroll'
? passiveCandidate(input)
: null;
const discontinuous = input.source === 'passive-scroll' && (
input.crossedBoundaries > 1
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75;
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75
);
if (state.kind === 'cooldown') {
if (candidate !== null) pendingTargetId = candidate;
if (
receivedNavigationIntent
&& input.navigationDestinationId === state.targetId
&& pendingTarget?.targetId === state.targetId
) {
pendingTarget.navigationArrival = true;
}
if (
candidate !== null
&& (
candidate !== state.targetId
|| receivedNavigationIntent
|| state.traversalGeneration !== traversalGeneration
)
) {
pendingTarget = {
targetId: candidate,
navigationArrival: receivedNavigationIntent,
};
}
if (input.now < state.until) return null;
state = { kind: 'idle' };
if (pendingTargetId !== null && !completedTraversalTargets.has(pendingTargetId)) {
const targetId = pendingTargetId;
pendingTargetId = null;
const queued = pendingTarget;
pendingTarget = null;
if (
queued !== null
&& (
queued.navigationArrival
|| !completedTraversalTargets.has(queued.targetId)
)
) {
state = latch(
targetId,
queued.targetId,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
queued.navigationArrival,
);
return snapshotVisible(state, input.now);
}
@@ -227,28 +374,66 @@ export function createSignController(): SignController {
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
receivedNavigationIntent,
);
return snapshotVisible(state, input.now);
}
if (candidate !== null && candidate !== state.targetId) {
pendingTargetId = candidate;
const navigationTargetsVisibleSign = receivedNavigationIntent
&& input.navigationDestinationId === state.targetId;
if (navigationTargetsVisibleSign) {
pendingTarget = null;
if (!repeatedNavigationIntent) {
state.traversalGeneration = traversalGeneration;
}
if (
!state.navigationArrival
&& state.dwellStartedAt !== null
&& state.departStartedAt === null
) {
state.dwellStartedAt = input.now;
}
state.navigationArrival = true;
}
const candidateStartsNewPresentation = candidate !== null && (
candidate !== state.targetId
|| (
input.source === 'passive-scroll'
&& state.traversalGeneration !== traversalGeneration
)
);
if (candidateStartsNewPresentation && candidate !== null) {
pendingTarget = {
targetId: candidate,
navigationArrival: receivedNavigationIntent,
};
}
const current = snapshotVisible(state, input.now);
if (current === null) return null;
if (
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
&& candidate !== null
&& candidate !== state.targetId
receivedNavigationIntent
&& navigationCandidate === null
&& !repeatedNavigationIntent
&& current.opacity <= SIGN_LIFECYCLE.concealedOpacity
) {
pendingTargetId = null;
state = { kind: 'idle' };
return null;
}
if (
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
&& candidateStartsNewPresentation
&& candidate !== null
) {
pendingTarget = null;
state = latch(
candidate,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
receivedNavigationIntent,
);
return snapshotVisible(state, input.now);
}
@@ -264,7 +449,10 @@ export function createSignController(): SignController {
const activeIndex = SIGN_ROUTE_ORDER.indexOf(input.activeSectionId);
const targetIndex = SIGN_ROUTE_ORDER.indexOf(state.targetId);
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
const targetPassed = input.source === 'passive-scroll'
&& activeIndex >= 0
&& targetIndex >= 0
&& activeIndex > targetIndex;
if (targetPassed && state.departStartedAt === null) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
@@ -274,7 +462,7 @@ export function createSignController(): SignController {
return snapshotVisible(state, input.now);
}
if (input.direction < 0) {
if (input.source === 'passive-scroll' && input.direction < 0) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
@@ -293,7 +481,7 @@ export function createSignController(): SignController {
};
const needsFrame = (): boolean => {
if (state.kind === 'cooldown') return pendingTargetId !== null;
if (state.kind === 'cooldown') return pendingTarget !== null;
return state.kind === 'visible' && lastNow !== null;
};

View File

@@ -3,6 +3,9 @@ import type { SectionId } from '@/content/types';
export type SignCorridor = 'left' | 'right' | 'outer-left' | 'outer-right';
export type SignViewportProfile = 'wide' | 'tall' | 'short';
export const PORTRAIT_SIGN_TRIGGER_MAXIMUM_VH = 0.23;
export const SHORT_LANDSCAPE_SIGN_TRIGGER_MAXIMUM_VH = 0.38;
export interface SignRouteSpec {
id: SectionId;
label: string;
@@ -10,6 +13,7 @@ export interface SignRouteSpec {
eligible: boolean;
corridor: SignCorridor | null;
triggerMaximumVh: number;
dwellOffsetX?: number;
}
export const SIGN_LIFECYCLE = Object.freeze({
@@ -20,6 +24,7 @@ export const SIGN_LIFECYCLE = Object.freeze({
departMs: 700,
cooldownMs: 350,
concealedOpacity: 0.02,
rearmMarginVh: 0.2,
});
export const SIGN_ROUTE_ORDER: readonly SectionId[] = [
@@ -41,22 +46,22 @@ const routeSpecs: Readonly<Record<SectionId, SignRouteSpec>> = Object.freeze({
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
},
experience: {
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
},
infrastructure: {
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55, dwellOffsetX: -1.6,
},
'selected-builds': {
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
},
'additional-work': {
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
},
toolkit: {
id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
},
contact: {
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
},
});
@@ -73,3 +78,18 @@ export function signViewportProfileFor(
const aspect = safeWidth / safeHeight;
return safeHeight < 560 && aspect >= 1 ? 'short' : aspect < 1.05 ? 'tall' : 'wide';
}
export function signTriggerMaximumVhFor(
route: SignRouteSpec,
width: number,
height: number,
): number {
const profile = signViewportProfileFor(width, height);
if (profile === 'tall') {
return Math.min(route.triggerMaximumVh, PORTRAIT_SIGN_TRIGGER_MAXIMUM_VH);
}
if (profile === 'short') {
return Math.min(route.triggerMaximumVh, SHORT_LANDSCAPE_SIGN_TRIGGER_MAXIMUM_VH);
}
return route.triggerMaximumVh;
}

View File

@@ -40,11 +40,14 @@ export interface SignSnapshot {
progress: number;
opacity: number;
viewportProfile?: SignViewportProfile;
navigationArrival?: true;
}
export interface SignControllerSample {
now: number;
source: JourneySource;
navigationEpoch: number;
navigationDestinationId: SectionId | null;
direction: -1 | 0 | 1;
acceleration: number;
deltaY: number;

View File

@@ -577,6 +577,9 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
...(snapshot.sign.viewportProfile === undefined
? {}
: { viewportProfile: snapshot.sign.viewportProfile }),
...(snapshot.sign.navigationArrival === true
? { navigationArrival: true as const }
: {}),
} : null,
arrived: Boolean(snapshot.arrived),
};

View File

@@ -100,6 +100,7 @@ export async function createProceduralSceneGeneration(
await prepareSignTypography(context.signal);
const sign = createSign(context.resources, {
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
scene.add(
@@ -228,7 +229,7 @@ export async function createProceduralSceneGeneration(
response.bloomEnergy,
);
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
sign.update(snapshot.sign);
sign.update(snapshot.sign, now);
atmosphere.update(
driveDistance,
visualSpeed,
@@ -310,6 +311,7 @@ export async function createProceduralSceneGeneration(
direction: snapshot.direction,
...presentation,
rendererPixelRatio: renderer.getPixelRatio(),
viewport: { width, height },
},
}));
}

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_LIFECYCLE,
signRouteFor,
signViewportProfileFor,
type SignCorridor,
@@ -28,64 +29,77 @@ interface ProjectingCamera {
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace';
const SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58;
const SHORT_CORRIDOR_BASE_RELIEF = 20;
const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
const RESPONSIVE_REFLOW_MS = 320;
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
wide: {
left: {
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
dwell: { x: -11.6, y: 7.9, z: -16, scale: 1.25 },
dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
},
right: {
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
dwell: { x: 12.4, y: 7.9, z: -16, scale: 1.25 },
dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
},
'outer-left': {
horizon: { x: -17.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: -21.5, y: 8.55, z: -24, scale: 1.35 },
horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
},
'outer-right': {
horizon: { x: 18.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: 22.5, y: 8.55, z: -24, scale: 1.35 },
horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
},
},
tall: {
left: {
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
dwell: { x: -5.2, y: 8.1, z: -24, scale: 1.3 },
dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
},
right: {
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
dwell: { x: 5.2, y: 8.1, z: -24, scale: 1.3 },
dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
},
'outer-left': {
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
dwell: { x: -7.2, y: 8.72, z: -30, scale: 1.4 },
dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
},
'outer-right': {
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
dwell: { x: 7.2, y: 8.72, z: -30, scale: 1.4 },
dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
},
},
short: {
left: {
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.55 },
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.62 },
},
right: {
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.55 },
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
},
'outer-left': {
horizon: { x: -11.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: -12.6, y: 5.8, z: -32, scale: 2.25 },
horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
},
'outer-right': {
horizon: { x: 11.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: 12.6, y: 5.8, z: -32, scale: 2.25 },
horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
},
},
};
const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
};
export interface SignWorld {
group: SignGroup;
inspect(
@@ -95,7 +109,7 @@ export interface SignWorld {
): SignPresentation | null;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null): void;
update(sign: SignSnapshot | null, now?: number): void;
}
export interface SignPresentation {
@@ -113,6 +127,7 @@ export interface SignPresentation {
texture: {
width: number;
height: number;
maximumSize: number;
anisotropy: number;
fontReadyWhenDrawn: boolean;
};
@@ -120,6 +135,7 @@ export interface SignPresentation {
export interface SignRenderOptions {
maximumAnisotropy?: number;
maximumTextureSize?: number;
}
interface SignGroup {
@@ -163,67 +179,84 @@ export function createSign(
group.visible = false;
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x071019,
roughness: 0.58,
metalness: 0.66,
color: 0x08131c,
roughness: 0.5,
metalness: 0.7,
transparent: true,
opacity: 0,
}));
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x111d27,
roughness: 0.78,
metalness: 0.34,
color: 0x172731,
roughness: 0.72,
metalness: 0.38,
transparent: true,
opacity: 0,
}));
const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x061018,
roughness: 0.9,
metalness: 0.12,
transparent: true,
opacity: 0,
}));
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x142631,
roughness: 0.52,
metalness: 0.72,
color: 0x21343e,
roughness: 0.56,
metalness: 0.68,
transparent: true,
opacity: 0,
}));
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x3a8892,
emissive: 0x53d2dc,
emissiveIntensity: 1.15,
roughness: 0.34,
metalness: 0.58,
transparent: true,
opacity: 0,
}));
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x8b522d,
emissive: 0xe49a58,
emissiveIntensity: 0.72,
color: 0x39747a,
emissive: 0x55b9c0,
emissiveIntensity: 0.42,
roughness: 0.42,
metalness: 0.5,
transparent: true,
opacity: 0,
}));
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x8e6446,
emissive: 0xb9723f,
emissiveIntensity: 0.28,
roughness: 0.48,
metalness: 0.42,
transparent: true,
opacity: 0,
}));
const housing = new THREE.Mesh(
resources.own('geometry', createSignBody(8.55, 2.65, 0.42, 0.22)),
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
housingMaterial,
);
housing.name = 'sign-housing';
housing.position.z = -0.05;
housing.position.z = -0.08;
group.add(housing);
const backplate = new THREE.Mesh(
resources.own('geometry', createSignBody(8.24, 2.34, 0.2, 0.18)),
resources.own('geometry', createSignBody(8.3, 2.38, 0.2, 0.18)),
backplateMaterial,
);
backplate.name = 'sign-backplate';
backplate.position.z = 0.17;
backplate.position.z = 0.15;
group.add(backplate);
const facePanel = new THREE.Mesh(
resources.own('geometry', createSignBody(8.08, 2.08, 0.1, 0.14)),
facePanelMaterial,
);
facePanel.name = 'sign-face-panel';
facePanel.position.z = 0.43;
group.add(facePanel);
const canvas = document.createElement('canvas');
canvas.width = 2_048;
canvas.height = 512;
const maximumTextureSize = normalizeTextureLimit(options.maximumTextureSize);
canvas.width = Math.min(2_048, powerOfTwoAtMost(maximumTextureSize));
canvas.height = Math.max(32, canvas.width / 4);
const context = canvas.getContext('2d');
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
texture.colorSpace = THREE.SRGBColorSpace;
texture.premultiplyAlpha = true;
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
@@ -240,6 +273,7 @@ export function createSign(
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
depthWrite: false,
toneMapped: false,
}));
const face = new THREE.Mesh(
@@ -247,79 +281,123 @@ export function createSign(
faceMaterial,
);
face.name = 'sign-face';
face.position.z = 0.625;
face.position.z = 0.54;
face.userData.textureUpdateCount = 0;
face.userData.fontReadyWhenDrawn = false;
group.add(face);
const edgeLight = new THREE.Group();
edgeLight.name = 'sign-edge-light';
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.62, 0.055));
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.32, 0.045, 0.055));
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.045, 1.56, 0.045));
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.18, 0.04, 0.045));
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
leftEdge.position.set(-3.9, 0, 0.665);
leftEdge.position.set(-3.9, 0, 0.57);
edgeLight.add(leftEdge);
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
upperEdge.position.set(-3.28, 1, 0.665);
upperEdge.position.set(-3.33, 1, 0.57);
edgeLight.add(upperEdge);
group.add(edgeLight);
const routeAccent = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(1.18, 0.055, 0.065)),
resources.own('geometry', new THREE.BoxGeometry(1.08, 0.045, 0.055)),
routeMaterial,
);
routeAccent.name = 'sign-route-accent';
routeAccent.position.set(3.35, -1, 0.665);
routeAccent.position.set(3.38, -1, 0.57);
group.add(routeAccent);
const backRails = new THREE.Group();
backRails.name = 'sign-back-rails';
const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16));
for (const y of [-0.68, 0.68]) {
const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
rail.position.set(0, y, -0.38);
backRails.add(rail);
}
group.add(backRails);
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26));
const post = new THREE.Mesh(
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.145, 5.05, 8)),
postGeometry,
supportMaterial,
);
post.name = 'sign-post';
post.position.set(2.75, -3.58, -0.16);
post.position.set(2.62, -3.58, -0.38);
group.add(post);
const secondaryPost = new THREE.Mesh(postGeometry, supportMaterial);
secondaryPost.name = 'sign-post-secondary';
secondaryPost.position.set(-2.62, -3.58, -0.38);
group.add(secondaryPost);
const mountedPosts = [post, secondaryPost] as const;
const bracket = new THREE.Group();
bracket.name = 'sign-bracket';
const arm = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(2.95, 0.11, 0.15)),
resources.own('geometry', new THREE.BoxGeometry(6.1, 0.13, 0.18)),
supportMaterial,
);
arm.position.set(1.4, -1.24, -0.15);
arm.position.set(0, -1.24, -0.38);
bracket.add(arm);
const brace = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
supportMaterial,
);
brace.position.set(2.05, -1.74, -0.15);
brace.rotation.z = -0.72;
bracket.add(brace);
const braceGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 1.18, 0.14));
for (const side of [-1, 1]) {
const brace = new THREE.Mesh(braceGeometry, supportMaterial);
brace.position.set(side * 2.18, -1.68, -0.38);
brace.rotation.z = side * -0.72;
bracket.add(brace);
}
group.add(bracket);
const footing = new THREE.Mesh(
resources.own('geometry', new THREE.CylinderGeometry(0.28, 0.34, 0.34, 8)),
supportMaterial,
);
const fasteners = new THREE.Group();
fasteners.name = 'sign-fasteners';
const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8));
for (const x of [-3.72, 3.72]) {
for (const y of [-0.78, 0.78]) {
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
fastener.position.set(x, y, 0.57);
fastener.rotation.x = Math.PI * 0.5;
fasteners.add(fastener);
}
}
group.add(fasteners);
const footing = new THREE.Group();
footing.name = 'sign-footing';
footing.position.set(2.75, -6.18, -0.16);
const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8));
for (const x of [-2.62, 2.62]) {
const base = new THREE.Mesh(footingGeometry, supportMaterial);
base.position.x = x;
footing.add(base);
}
footing.position.set(0, -6.18, -0.38);
group.add(footing);
const fadeMaterials = [
[housingMaterial, 0.97],
[backplateMaterial, 1],
[facePanelMaterial, 1],
[supportMaterial, 0.94],
[edgeMaterial, 0.76],
[routeMaterial, 0.82],
[faceMaterial, 1],
] as const;
const departureOrigin = new THREE.Vector3();
const poseTransitionOrigin = new THREE.Vector3();
const poseTransitionDestination = new THREE.Vector3();
let departureScale = 1;
let poseTransitionOriginScale = 1;
let poseTransitionOriginRotationY = 0;
let poseTransitionPending = false;
let poseTransitionStartedAt: number | null = null;
let target: SectionId | null = null;
let pendingViewport: SignViewportProfile = 'wide';
let activeViewport: SignViewportProfile = 'wide';
let pendingAspect = 16 / 9;
let activeAspect = 16 / 9;
let activeNavigationArrival = false;
let activeCorridor: SignCorridor = 'right';
let previousPhase: SignSnapshot['phase'] | null = null;
let renderedOpacity = 0;
const faceCornerCoordinates = [
[-4, -1, 0],
[4, -1, 0],
@@ -341,8 +419,34 @@ export function createSign(
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
};
const applyPose = (pose: SignPose): void => {
group.position.set(pose.x, pose.y, pose.z);
const shortCorridorRelief = (): number => activeViewport === 'short'
? THREE.MathUtils.clamp(
SHORT_CORRIDOR_BASE_RELIEF
+ (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
* SHORT_CORRIDOR_RELIEF_PER_ASPECT,
SHORT_CORRIDOR_MINIMUM_RELIEF,
SHORT_CORRIDOR_MAXIMUM_RELIEF,
)
: 0;
const resolvedPoseX = (
pose: SignPose,
side: number,
weight: number,
routeOffsetX = 0,
): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
const applyPose = (
pose: SignPose,
side: number,
lateralWeight: number,
routeOffsetX = 0,
): void => {
group.position.set(
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
pose.y,
pose.z,
);
group.scale.setScalar(pose.scale);
plantMounting(pose.y, pose.scale);
};
@@ -352,10 +456,38 @@ export function createSign(
const footingY = -groupY / safeScale;
const postTopY = -1.36;
const postHeight = Math.max(0.36, postTopY - footingY);
post.position.y = (postTopY + footingY) * 0.5;
post.scale.set(1 / safeScale, postHeight / 5.05, 1 / safeScale);
for (const mountedPost of mountedPosts) {
mountedPost.position.y = (postTopY + footingY) * 0.5;
mountedPost.scale.set(1, postHeight / 5.05, 1);
}
footing.position.y = footingY;
footing.scale.setScalar(1 / Math.max(1, safeScale));
footing.scale.setScalar(1);
};
const stagePoseTransition = (): void => {
if (previousPhase === null || previousPhase === 'depart') return;
poseTransitionOrigin.copy(group.position);
poseTransitionOriginScale = group.scale.x;
poseTransitionOriginRotationY = group.rotation.y;
poseTransitionPending = true;
poseTransitionStartedAt = null;
};
const stageResponsiveViewport = (
viewport: SignViewportProfile,
aspect: number,
): void => {
const changed = viewport !== pendingViewport
|| Math.abs(aspect - pendingAspect) > 0.001;
if (changed) stagePoseTransition();
pendingViewport = viewport;
pendingAspect = aspect;
if (previousPhase === null) {
activeViewport = pendingViewport;
activeAspect = pendingAspect;
poseTransitionPending = false;
poseTransitionStartedAt = null;
}
};
return {
@@ -395,32 +527,66 @@ export function createSign(
texture: {
width: canvas.width,
height: canvas.height,
maximumSize: maximumTextureSize,
anisotropy: texture.anisotropy,
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
},
};
},
setViewportAspect(aspect) {
pendingViewport = !Number.isFinite(aspect) || aspect < 0.85 ? 'tall' : 'wide';
if (previousPhase === null) activeViewport = pendingViewport;
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
stageResponsiveViewport(nextAspect < 0.85 ? 'tall' : 'wide', nextAspect);
},
setViewportSize(width, height) {
pendingViewport = signViewportProfileFor(width, height);
if (previousPhase === null) activeViewport = pendingViewport;
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
? Math.max(1, width) / height
: 1;
stageResponsiveViewport(signViewportProfileFor(width, height), nextAspect);
},
update(sign) {
update(sign, now) {
if (!sign) {
group.visible = false;
applyOpacity(0);
renderedOpacity = 0;
previousPhase = null;
activeViewport = pendingViewport;
activeAspect = pendingAspect;
activeNavigationArrival = false;
poseTransitionPending = false;
poseTransitionStartedAt = null;
return;
}
if (target !== null && target !== sign.targetId && group.visible) return;
if (
target !== null
&& target !== sign.targetId
&& renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
) return;
const navigationArrival = sign.navigationArrival === true;
if (target !== sign.targetId) {
activeViewport = sign.viewportProfile ?? pendingViewport;
// The renderer owns projection. A quality-tier change can rebuild this world
// while a lifecycle is already visible, so its latched profile may describe
// the generation that was just disposed rather than this canvas.
activeViewport = pendingViewport;
activeAspect = pendingAspect;
activeNavigationArrival = navigationArrival;
poseTransitionPending = false;
poseTransitionStartedAt = null;
updateTexture(sign.targetId);
} else if (navigationArrival !== activeNavigationArrival) {
stagePoseTransition();
activeNavigationArrival = navigationArrival;
}
if (poseTransitionPending && sign.phase === 'depart') {
poseTransitionPending = false;
poseTransitionStartedAt = null;
} else if (poseTransitionPending) {
activeViewport = pendingViewport;
activeAspect = pendingAspect;
if (poseTransitionStartedAt === null) {
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
}
}
const opacity = clamp01(sign.opacity);
@@ -428,41 +594,83 @@ export function createSign(
const route = signRouteFor(sign.targetId);
const corridor = route.corridor ?? 'right';
activeCorridor = corridor;
const { horizon, dwell } = SIGN_POSES[activeViewport][corridor];
const poseSet = SIGN_POSES[activeViewport][corridor];
const horizon = poseSet.horizon;
const dwell = activeViewport === 'tall' && activeNavigationArrival
? PORTRAIT_NAVIGATION_DWELL[corridor]
: poseSet.dwell;
const side = corridor.includes('left') ? -1 : 1;
const routeOffsetX = route.dwellOffsetX ?? 0;
if (sign.phase === 'anticipate') {
applyPose(horizon);
applyPose(horizon, side, 0.35);
} else if (sign.phase === 'approach') {
group.position.set(
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
THREE.MathUtils.lerp(
resolvedPoseX(horizon, side, 0.35),
resolvedPoseX(dwell, side, 1, routeOffsetX),
progress,
),
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
);
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
plantMounting(group.position.y, group.scale.x);
} else if (sign.phase === 'dwell') {
applyPose(dwell);
applyPose(dwell, side, 1, routeOffsetX);
} else {
if (previousPhase !== 'depart') {
if (previousPhase === null) applyPose(dwell);
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX);
departureOrigin.copy(group.position);
departureScale = group.scale.x;
}
const outerCorridor = corridor.startsWith('outer');
const lateralExit = activeViewport === 'wide'
? 14
: activeViewport === 'short' ? 10 : 8;
? outerCorridor ? 2.6 : 3.4
: activeViewport === 'short' ? 2.2 : 2.4;
group.position.set(
departureOrigin.x + progress * side * lateralExit,
departureOrigin.y + progress * 0.72,
departureOrigin.z - progress * 32,
departureOrigin.y,
THREE.MathUtils.lerp(departureOrigin.z, 18, progress),
);
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 0.82, progress));
group.scale.setScalar(departureScale);
plantMounting(group.position.y, group.scale.x);
}
group.rotation.y = -side * (activeViewport === 'wide' ? 0.2 : 0.12);
const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1);
if (poseTransitionPending) {
const elapsed = Number.isFinite(now)
? Math.max(0, now! - (poseTransitionStartedAt ?? now!))
: RESPONSIVE_REFLOW_MS;
const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
poseTransitionDestination.copy(group.position);
const poseTransitionDestinationScale = group.scale.x;
group.position.set(
THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
);
group.scale.setScalar(THREE.MathUtils.lerp(
poseTransitionOriginScale,
poseTransitionDestinationScale,
mix,
));
group.rotation.y = THREE.MathUtils.lerp(
poseTransitionOriginRotationY,
desiredRotationY,
mix,
);
plantMounting(group.position.y, group.scale.x);
if (mix >= 1) {
poseTransitionPending = false;
poseTransitionStartedAt = null;
}
} else {
group.rotation.y = desiredRotationY;
}
applyOpacity(opacity);
group.visible = opacity > 0;
renderedOpacity = opacity;
previousPhase = sign.phase;
},
};
@@ -505,28 +713,75 @@ function drawLabel(
height: number,
): void {
context.clearRect(0, 0, width, height);
context.fillStyle = '#07111a';
context.fillRect(0, 0, width, height);
context.fillStyle = '#4cbac4';
context.fillRect(86, 72, 12, height - 144);
context.fillStyle = '#879da4';
context.save();
context.scale(width / 2_048, height / 512);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.globalAlpha = 0.16;
context.fillStyle = '#8aa1a6';
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 = '#52bbc2';
context.fillRect(82, 72, 10, 368);
context.fillStyle = '#91a6a9';
context.font = SIGN_MARKER_FONT;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(`ROUTE ${route.marker} / NEXT`, 150, 126);
context.fillStyle = '#c8d8da';
context.font = SIGN_LABEL_FONT;
context.fillText(route.label, 150, 326, width - 420);
context.strokeStyle = '#cf8e54';
context.lineWidth = 12;
context.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
context.globalAlpha = 0.6;
context.fillStyle = '#2f4650';
context.fillRect(142, 174, 1_610, 2);
context.globalAlpha = 1;
context.fillStyle = '#d7e4e2';
const labelSize = fittedLabelSize(context, route.label, 1_520);
context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`;
context.fillText(route.label, 142, 326);
context.strokeStyle = '#cb9160';
context.lineWidth = 10;
context.lineCap = 'square';
context.lineJoin = 'miter';
context.beginPath();
context.moveTo(width - 250, 304);
context.lineTo(width - 158, 304);
context.lineTo(width - 198, 264);
context.moveTo(width - 158, 304);
context.lineTo(width - 198, 344);
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.stroke();
context.restore();
}
function fittedLabelSize(
context: CanvasRenderingContext2D,
label: string,
maximumWidth: number,
): number {
for (let size = 164; size >= 112; size -= 4) {
context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`;
if (context.measureText(label).width <= maximumWidth) return size;
}
return 112;
}
function normalizeTextureLimit(value: number | undefined): number {
return value !== undefined && Number.isFinite(value)
? Math.max(32, Math.floor(value))
: 2_048;
}
function powerOfTwoAtMost(value: number): number {
return 2 ** Math.floor(Math.log2(Math.max(1, value)));
}
function clamp01(value: number): number {