feat: overhead gantry top bar with a docking sign window

The top bar becomes a steel gantry. Its centre window is a mask cut-out the
3D exit sign docks into; between signs the window shows the dash read-out.
The drive controls move into the right bay, and the reading viewport is
clipped at the beam, so the empty band under the bar is gone.
This commit is contained in:
2026-10-10 22:20:48 +03:00
parent 32eb3e4d40
commit 0ae27168cd
16 changed files with 819 additions and 398 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -12,6 +12,7 @@ import './styles/refinements.css';
import './styles/title-navigation.css';
import './styles/wayfinding.css';
import './styles/route.css';
import './styles/gantry.css';
import './styles/print.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();

View File

@@ -253,6 +253,8 @@ onUnmounted(() => removePageListeners());
:journey="journey"
:section-label="activeSectionLabel"
:exit="exitFor(journey.activeSectionId)"
:next-label="nextLabelFor(journey.activeSectionId)"
:next-exit="nextExitFor(journey.activeSectionId)"
/>
<header class="site-header">
<SiteNavigation

View File

@@ -7,8 +7,15 @@ const props = defineProps<{
journey: JourneySnapshot;
sectionLabel: string;
exit?: string | undefined;
nextLabel?: string | undefined;
nextExit?: string | undefined;
}>();
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
const next = computed(() =>
props.nextLabel === undefined ? 'END OF ROUTE' : `NEXT ${props.nextExit ?? ''} \u2192 ${props.nextLabel}`.toUpperCase(),
);
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`);
// The dash clock is decorative and CSS-generated, so SSR and hydrated text stay identical.
@@ -24,7 +31,7 @@ onUnmounted(() => window.clearInterval(timer));
<template>
<aside class="journey-hud" aria-hidden="true" :data-exit="exit" :data-clock="clock">
<span class="journey-hud__route"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__section">{{ sectionLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
</aside>

View File

@@ -93,6 +93,22 @@ onBeforeUnmount(() => {
compactQuery = null;
});
/** The gantry's sign window drives on to the next exit (or back to the
* start from the destination), exactly like following that exit's link. */
const nextIndex = computed(() =>
activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1,
);
const windowLabel = computed(() => {
const next = props.items[nextIndex.value];
if (next === undefined) return "Drive on";
return nextIndex.value === 0
? `Back to the start: ${next.label}`
: `Drive to the next exit: ${next.label}`;
});
const driveOn = (): void => {
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
};
const navigate = (event: MouseEvent): void => {
props.onSectionLinkClick(event, true);
if (window.matchMedia("(max-width: 767px)").matches)
@@ -117,5 +133,13 @@ const navigate = (event: MouseEvent): void => {
</li>
</ul>
</details>
<button
v-if="enhanced"
type="button"
class="gantry-window"
:aria-label="windowLabel"
:title="windowLabel"
@click="driveOn"
></button>
</nav>
</template>

View File

@@ -8,7 +8,6 @@ import { createHorizonAir } from '@/scene/world/horizon-air';
import { createExpressway } from '@/scene/world/expressway';
import { createDistricts } from '@/scene/world/districts';
import { createSignStage } from '@/scene/world/sign-stage';
import { usesRoadsideSign } from '@/scene/world/sign-layout';
import { createSpeedWake } from '@/scene/world/speed-wake';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
@@ -175,7 +174,6 @@ export async function createProceduralSceneGeneration(
let disposed = false;
let renderedFrames = 0;
let atmosphericTime = 0;
let signCameraWeight = 0;
let overdrive = 0;
let terrainSurge = 0;
let musicPulse = 0;
@@ -186,6 +184,7 @@ export async function createProceduralSceneGeneration(
let introProgress: number | null = null;
let introFrames = 0;
let introSlowFrames = 0;
let gantryState = '';
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -427,23 +426,11 @@ export async function createProceduralSceneGeneration(
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0;
const targetX = 0;
const signTarget =
usesRoadsideSign(width, height) && snapshot.sign !== null
? snapshot.sign.phase === 'depart'
? 1 - snapshot.sign.progress
: 1
: 0;
signCameraWeight +=
(signTarget - signCameraWeight) * (1 - Math.exp(-deltaSeconds / 0.48));
const targetShift =
height < 560 && width >= 768
? 0.22
: wideComposition
? THREE.MathUtils.lerp(
composition.shift,
composition.shift * 0.28,
signCameraWeight,
)
? composition.shift
: 0;
const targetVerticalShift = wideComposition
? 0
@@ -558,14 +545,12 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix();
}
const stage = signStage.update(width, height);
sign.compose(
camera,
width,
height,
stage.navigationBottom,
now,
car.group.position,
);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();
if (gantry !== gantryState) {
gantryState = gantry;
document.documentElement.dataset.gantry = gantry;
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {
@@ -600,6 +585,7 @@ export async function createProceduralSceneGeneration(
dispose() {
if (disposed) return;
disposed = true;
delete document.documentElement.dataset.gantry;
// Quality changes reuse the canvas context. Leave texture upload flags at
// defaults before the next renderer creates its empty array textures.
const gl = renderer.getContext();

View File

@@ -1,42 +0,0 @@
import type { SectionId } from '@/content/types';
/** Compact displays keep the readable navigator shelf; wide displays use
* physical roadside signs. This decision is visual and never enters journey state. */
export function usesRoadsideSign(width: number, height: number): boolean {
return width >= 1100 && height >= 560 && width / height > 1.18;
}
// Mast heights are authored for the chapter's driving composition. They do
// not follow projected screen coordinates or the animated camera position.
const MAST_LIFT: Readonly<Record<SectionId, number>> = {
start: 0,
profile: 0,
experience: 1,
infrastructure: 4,
'selected-builds': 1.5,
'additional-work': 2,
toolkit: 2.7,
contact: -0.5,
};
export function roadsideSignMetrics(
height: number,
chapter: SectionId = 'start',
) {
// Short desktop windows need a larger physical housing, rather than a
// doubly shrunken sign (shorter viewport plus smaller world geometry).
const size = Math.min(1.32, Math.max(0.94, 900 / height));
const scale = 2.1 * size;
const chapterLift =
MAST_LIFT[chapter] * Math.min(1, Math.max(0.25, (height - 560) / 160));
// Perspective height converges on tall displays; a linear lift would
// eventually push the housing through the navigation bar.
const tallLift = Math.max(0, 9 * (1 - 900 / height));
return {
scale,
// The entire housing and both supports clear the road and the car envelope.
x: 11.6 + scale * 4.55,
y: 25.5 + chapterLift + tallLift,
dwellZ: -28,
};
}

View File

@@ -1,17 +1,22 @@
import type { SceneResourceBag } from '@/scene/resources';
import { usesRoadsideSign } from './sign-layout';
import type { SignDock, SignTilt } from './sign';
const stageOwners = new WeakMap<HTMLElement, object>();
const REVEAL_STEPS = 24;
/** Content fades out over this many pixels as it slides under the gantry. */
const GANTRY_FEATHER = 18;
/** Keep the reading-edge mask stationary while native document scrolling
* moves the content behind it. Changing chapter-sized gradients on every
* scroll invalidates their rasterized content in Chromium. */
/** The reading window starts at the gantry's lower edge: one stationary mask
* hides content under the beam (and behind its see-through sign window) and
* stays unchanged while native document scrolling moves the content.
* Changing chapter-sized gradients on every scroll invalidates their
* rasterized content in Chromium. */
export function createSignStage(resources: SceneResourceBag) {
const main = document.querySelector<HTMLElement>('.portfolio-main');
const stage = main?.closest<HTMLElement>('.reading-stage');
const viewport = main?.parentElement;
const nav = document.querySelector<HTMLElement>('.site-navigation');
const dockElement = () =>
document.querySelector<HTMLElement>('.gantry-window');
const root = document.documentElement;
const lazyImages = main
? Array.from(main.querySelectorAll<HTMLImageElement>('img[loading="lazy"]'))
@@ -27,11 +32,14 @@ export function createSignStage(resources: SceneResourceBag) {
: [];
const token = {};
let dirty = true;
let stageBottom = 176;
let navigationBottom = 66;
let readingTop = 220;
let topEdge = 188;
let feather = 32;
let stageBottom = 72;
let navigationBottom = 72;
let readingTop = 90;
let topEdge = 72;
const feather = GANTRY_FEATHER;
const dock: SignDock = { left: 592, top: 4, width: 256, height: 64 };
const tilt: SignTilt = { x: 0, y: 0 };
let observedDock: HTMLElement | null = null;
let contentHeight = 0;
let viewportHeight = 0;
let viewportWidth = 0;
@@ -53,17 +61,9 @@ export function createSignStage(resources: SceneResourceBag) {
lastOffset = offset;
viewport.scrollTop = offset;
}
// Quantize the opening reveal: the mask image is rebuilt at most
// REVEAL_STEPS times on the way in instead of on every scroll event.
const reveal = Math.round(Math.min(
1,
Math.max(0, window.scrollY / Math.max(240, viewportHeight * 0.58)),
) * REVEAL_STEPS) / REVEAL_STEPS;
const top = topEdge * reveal;
const bottom =
viewportHeight < 560 && viewportWidth < 768 ? viewportHeight - 164 : null;
const mask = `linear-gradient(to bottom, transparent ${top.toFixed(2)}px, rgb(0 0 0 / 18%) ${(top + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(top + feather * 0.72).toFixed(2)}px, black ${(top + feather).toFixed(2)}px${bottom === null ? '' : `, black ${(bottom - feather).toFixed(2)}px, transparent ${bottom.toFixed(2)}px`})`;
// Once the opening has departed, this image stays unchanged until resize.
const top = topEdge;
const mask = `linear-gradient(to bottom, transparent ${top.toFixed(2)}px, rgb(0 0 0 / 18%) ${(top + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(top + feather * 0.72).toFixed(2)}px, black ${(top + feather).toFixed(2)}px)`;
// This image stays unchanged until the gantry or the viewport resizes.
if (mask !== lastMask) {
viewport.style.maskImage = mask;
lastMask = mask;
@@ -90,30 +90,39 @@ export function createSignStage(resources: SceneResourceBag) {
dirty = false;
viewportWidth = width;
viewportHeight = height;
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
const faceWidth = Math.min(
width - 44,
width < 900 ? 288 : 356,
height < 650 ? 250 : 356,
);
const shelfBottom = navigationBottom + 24 + faceWidth / 3.18;
const roadside = usesRoadsideSign(width, height);
feather = height < 650 ? 28 : roadside ? 32 : 48;
stageBottom = shelfBottom + (height < 650 ? feather : 0);
const short = height < 560;
const proposedTopEdge = short
? navigationBottom + 10
: height < 650
? shelfBottom - 22
: roadside
? navigationBottom + 128 + (height - 900) * 0.085
: shelfBottom;
topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36);
navigationBottom = nav?.getBoundingClientRect().bottom ?? 72;
const windowElement = dockElement();
if (windowElement && windowElement !== observedDock) {
observedDock = windowElement;
observer.observe(windowElement);
resources.listen(windowElement, 'pointermove', (event: Event) => {
if (!(event instanceof PointerEvent)) return;
const rect = windowElement.getBoundingClientRect();
tilt.x = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width) * 2 - 1));
tilt.y = Math.max(-1, Math.min(1, ((event.clientY - rect.top) / rect.height) * 2 - 1));
}, { passive: true });
resources.listen(windowElement, 'pointerleave', () => {
tilt.x = 0;
tilt.y = 0;
}, { passive: true });
}
const rect = windowElement?.getBoundingClientRect();
if (rect && rect.width > 0 && rect.height > 0) {
dock.left = rect.left;
dock.top = rect.top;
dock.width = rect.width;
dock.height = rect.height;
} else {
dock.width = 0;
dock.height = 0;
}
topEdge = navigationBottom;
stageBottom = navigationBottom;
readingTop = topEdge + feather;
if (main && stage && viewport?.classList.contains('reading-viewport')) {
root.style.setProperty(
'--sign-stage-bottom',
`${(short ? topEdge : stageBottom).toFixed(2)}px`,
`${stageBottom.toFixed(2)}px`,
);
root.style.setProperty(
'--sign-reading-top',
@@ -147,7 +156,7 @@ export function createSignStage(resources: SceneResourceBag) {
}
}
}
return { navigationBottom, stageBottom };
return { navigationBottom, stageBottom, dock, tilt };
};
resources.listen(window, 'scroll', moveContent, { passive: true });
@@ -171,7 +180,8 @@ export function createSignStage(resources: SceneResourceBag) {
if (ownsStage()) update(root.clientWidth, window.innerHeight);
};
resources.listen(window, 'resize', layoutChanged, { passive: true });
const observer = resources.observe(new ResizeObserver(layoutChanged));
// The gantry window can move without resizing (the beam's exits re-flow).
const observer: ResizeObserver = resources.observe(new ResizeObserver(layoutChanged));
if (main) observer.observe(main);
if (nav) observer.observe(nav);
for (const { heading } of headings) observer.observe(heading);
@@ -182,10 +192,7 @@ export function createSignStage(resources: SceneResourceBag) {
if (event.target.matches('h1,h2,main')) return;
moveContent();
const rect = event.target.getBoundingClientRect();
const bottom =
window.innerHeight < 560 && window.innerWidth < 768
? window.innerHeight - 164
: window.innerHeight - 74;
const bottom = window.innerHeight - 24;
if (rect.top < readingTop)
window.scrollBy({ top: rect.top - readingTop - 12, behavior: 'instant' });
else if (rect.bottom > bottom)

View File

@@ -2,7 +2,6 @@ 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,
@@ -10,14 +9,35 @@ import {
type SignViewportProfile,
} from '@/journey/sign-routes';
import type { SceneResourceBag } from '@/scene/resources';
import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout';
interface ProjectingCamera {
position: { x: number; y: number; z: number };
rotation: { y: number };
quaternion: unknown;
updateMatrixWorld(force: boolean): void;
}
/** The gantry's sign window in CSS pixels: the face docks into this rect. */
export interface SignDock {
left: number;
top: number;
width: number;
height: number;
}
/** Pointer offset over the docked sign, -1..1 on each axis; 0 at rest. */
export interface SignTilt {
x: number;
y: number;
}
/** What the gantry shows: its own 2D read-out, an incoming sign, the
* docked sign, or the sign passing overhead. */
export type GantryState = 'idle' | 'incoming' | 'docked' | 'passing';
const REST_TILT: SignTilt = { x: 0, y: 0 };
const FACE_Z = 0.54;
const SIGN_MARKER_FONT =
'600 60px "Geist Mono Variable", ui-monospace, monospace';
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
@@ -32,10 +52,11 @@ export interface SignWorld {
camera: ProjectingCamera,
width: number,
height: number,
navigationBottom: number,
dock: SignDock,
now: number,
carAnchor?: { x: number; z: number },
tilt?: SignTilt,
): void;
dockState(): GantryState;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null, now?: number): void;
@@ -108,7 +129,7 @@ export function createSign(
options: SignRenderOptions = {},
): SignWorld {
const group = new THREE.Group();
group.name = 'roadside-sign';
group.name = 'gantry-sign';
group.visible = false;
const housingMaterial = resources.own(
@@ -246,7 +267,7 @@ export function createSign(
faceMaterial,
);
face.name = 'sign-face';
face.position.z = 0.54;
face.position.z = FACE_Z;
face.userData.textureUpdateCount = 0;
face.userData.fontReadyWhenDrawn = false;
group.add(face);
@@ -303,41 +324,6 @@ export function createSign(
}
group.add(backRails);
const postGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(0.22, 5.05, 0.26),
);
const post = new THREE.Mesh(postGeometry, supportMaterial);
post.name = 'sign-post';
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(6.1, 0.13, 0.18)),
supportMaterial,
);
arm.position.set(0, -1.24, -0.38);
bracket.add(arm);
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 fasteners = new THREE.Group();
fasteners.name = 'sign-fasteners';
const fastenerGeometry = resources.own(
@@ -354,20 +340,6 @@ export function createSign(
}
group.add(fasteners);
const footing = new THREE.Group();
footing.name = 'sign-footing';
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 sweepMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
@@ -406,36 +378,28 @@ export function createSign(
let viewportWidth = 1440;
let viewportHeight = 900;
let previousNow = 0;
let screenX = 0.5;
let screenY = 120;
let screenWidth = 340;
let screenX = 720;
let screenY = 36;
let screenWidth = 256;
let initialized = false;
let roadsideMix = 0;
let previousPhase: SignSnapshot['phase'] | null = null;
let dwellDrift = 0;
let departureDrift = 0;
const physical = roadsideSignMetrics(900);
let tiltX = 0;
let tiltY = 0;
let state: GantryState = 'idle';
const point = new THREE.Vector3();
const ray = new THREE.Vector3();
const forward = new THREE.Vector3();
const worldRight = new THREE.Vector3();
const worldLeft = new THREE.Vector3();
const corner = new THREE.Vector3();
const xAxis = new THREE.Vector3(1, 0, 0);
const yAxis = new THREE.Vector3(0, 1, 0);
const turn = new THREE.Quaternion();
const corners = [
[-4, -1],
[4, -1],
[4, 1],
[-4, 1],
];
function plantMounting() {
const footY = -group.position.y / Math.max(0.001, group.scale.x);
const height = Math.max(0.36, -1.36 - footY);
for (const post of mountedPosts) {
post.position.y = (-1.36 + footY) * 0.5;
post.scale.set(1, height / 5.05, 1);
}
footing.position.y = footY;
}
function worldPoint(
out: typeof point,
x: number,
@@ -506,13 +470,10 @@ export function createSign(
if (!sign) {
group.visible = false;
initialized = false;
previousPhase = null;
dwellDrift = 0;
state = 'idle';
return;
}
if (target !== sign.targetId) {
previousPhase = null;
dwellDrift = 0;
target = sign.targetId;
face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn =
@@ -529,15 +490,17 @@ export function createSign(
initialized = false;
}
},
compose(
camera,
width,
height,
navigationBottom,
now,
carAnchor = { x: 0, z: 0.72 },
) {
dockState() {
return state;
},
compose(camera, width, height, dock, now, tilt = REST_TILT) {
if (!snapshot) return;
// No window on the narrowest screens: the gantry shows no sign there.
if (dock.width < 40 || dock.height < 10) {
group.visible = false;
state = 'idle';
return;
}
viewportWidth = width;
viewportHeight = height;
camera.updateMatrixWorld(true);
@@ -549,110 +512,79 @@ export function createSign(
const route = signRouteFor(snapshot.targetId);
activeCorridor = route.corridor ?? 'right';
const side = activeCorridor.includes('left') ? -1 : 1;
const compact = width < 900;
const shortSide = height < 560 && width >= 768;
const lowerStrip = height < 560 && !shortSide;
const targetWidth = Math.min(
width - 44,
compact ? 288 : 356,
height < 650 ? 250 : 356,
);
const targetX = shortSide ? 0.82 : compact ? 0.5 : side < 0 ? 0.23 : 0.77;
const targetY = shortSide
? navigationBottom + 32 + (height - navigationBottom - 96) * 0.25
: lowerStrip
? height - 112
: navigationBottom + 12 + targetWidth / 3.18 / 2;
// The face fills the gantry window; its housing tucks behind the beam.
const targetX = dock.left + dock.width / 2;
const targetY = dock.top + dock.height / 2;
const targetWidth = Math.max(40, Math.min(dock.width, dock.height * 4));
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
previousNow = now;
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
const roadside = usesRoadsideSign(width, height);
roadsideMix += ((roadside ? 1 : 0) - roadsideMix) * mix;
screenX += (targetX - screenX) * mix;
screenY += (targetY - screenY) * mix;
screenWidth += (targetWidth - screenWidth) * mix;
initialized = true;
const p = smoothstep(clamp01(snapshot.progress));
let arrival =
const arrival =
snapshot.phase === 'anticipate'
? 0
: snapshot.phase === 'approach'
? p
: 1;
const closeDepth = lowerStrip ? 12 : 18;
// Lift-off: the sign rises off the horizon on its side of the road and
// curves up into the window, growing as it closes in.
const lateral = 1 - (1 - arrival) * (1 - arrival);
const closeDepth = 18;
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
let cy = THREE.MathUtils.lerp(
height * 0.43,
screenY,
lowerStrip ? arrival * arrival * arrival : arrival,
);
let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
let cx = THREE.MathUtils.lerp(width * (0.5 + side * 0.07), screenX, lateral);
let cy = THREE.MathUtils.lerp(height * 0.44, screenY, arrival);
let projectedWidth =
screenWidth * THREE.MathUtils.lerp(0.14, 1, arrival * arrival);
let pitch = 0;
if (snapshot.phase === 'depart') {
depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
cx += side * p * width * 0.3;
projectedWidth *= 1 + p * 0.3;
}
if (snapshot.phase !== 'depart') {
projectedWidth = Math.min(width - 44, projectedWidth);
cx = THREE.MathUtils.clamp(
cx,
projectedWidth / 2 + 14,
width - projectedWidth / 2 - 14,
);
// Passing under the gantry: up and out of the top of the frame,
// seen more and more from below.
depth = THREE.MathUtils.lerp(closeDepth, 9, p);
cy = screenY - p * p * (screenY + dock.height * 1.6);
projectedWidth *= 1 + p * 0.45;
pitch = -p * 0.85;
}
const tiltMix = 1 - Math.exp(-dt / 0.16);
const docked = snapshot.phase === 'dwell' ? 1 : 0;
tiltX += (tilt.x * docked - tiltX) * (initialized ? tiltMix : 1);
tiltY += (tilt.y * docked - tiltY) * (initialized ? tiltMix : 1);
worldPoint(point, cx, cy, depth, camera);
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
if (lowerStrip) point.y = Math.max(1.4, point.y);
// A wide-screen sign is a real roadside object: fixed lateral offset,
// fixed mast height, constant size, and travel only along the road axis.
// Camera movement changes its projection, never its world-space anchor.
const desiredPhysical = roadsideSignMetrics(height, snapshot.targetId);
for (const key of ['x', 'y', 'scale', 'dwellZ'] as const) {
physical[key] += (desiredPhysical[key] - physical[key]) * mix;
}
// Canonical dwell progress is always 1; visual drift must integrate time.
if (snapshot.phase === 'dwell')
dwellDrift = Math.min(3, dwellDrift + dt * 1.7);
if (snapshot.phase === 'depart' && previousPhase !== 'depart')
departureDrift = dwellDrift;
previousPhase = snapshot.phase;
const roadZ =
snapshot.phase === 'depart'
? THREE.MathUtils.lerp(physical.dwellZ + departureDrift, 48, p)
: THREE.MathUtils.lerp(-230, physical.dwellZ, arrival) + dwellDrift;
point.x = THREE.MathUtils.lerp(
point.x,
carAnchor.x + side * physical.x,
roadsideMix,
);
point.y = THREE.MathUtils.lerp(point.y, physical.y, roadsideMix);
point.z = THREE.MathUtils.lerp(point.z, carAnchor.z + roadZ, roadsideMix);
const scale = worldRight.distanceTo(worldLeft) / 8;
// The face sits FACE_Z in front of the group origin: set the origin
// back by that much so the face itself lands on the target plane.
point.addScaledVector(forward, FACE_Z * scale);
group.position.set(point.x, point.y, point.z);
group.scale.setScalar(
THREE.MathUtils.lerp(
worldRight.distanceTo(worldLeft) / 8.64,
physical.scale,
roadsideMix,
),
);
group.rotation.y = THREE.MathUtils.lerp(
camera.rotation.y - side * 0.065,
-side * 0.1,
roadsideMix,
);
plantMounting();
group.scale.setScalar(scale);
// Parallel to the image plane, so the docked face fits the window
// exactly; hover turns it toward the pointer.
group.quaternion.copy(camera.quaternion);
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * 0.3));
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, pitch - tiltY * 0.24));
const opacity = clamp01(snapshot.opacity);
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
// The frame catches light first; one moving highlight resolves into a quiet face.
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
edgeMaterial.emissiveIntensity = 0.8 + Math.sin(arrival * Math.PI) * 1.4;
edgeMaterial.emissiveIntensity =
0.8 + Math.sin(arrival * Math.PI) * 1.4 + Math.hypot(tiltX, tiltY) * 0.6;
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
sweepMaterial.uniforms.uOpacity.value =
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0;
group.userData.anchoring = roadside ? 'roadside' : 'compact-shelf';
group.userData.anchoring = 'gantry';
state =
opacity <= 0
? 'idle'
: snapshot.phase === 'depart'
? 'passing'
: arrival >= 0.82
? 'docked'
: 'incoming';
},
};
}
@@ -711,12 +643,6 @@ 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,
@@ -735,8 +661,9 @@ function chamferedRect(
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. */
/** One-line gantry face, legible when docked in a 64 px window: a stacked
* exit tab, the chapter label, a lane rule and an exit arrow that points to
* the sign's side of the road. */
function drawLabel(
context: CanvasRenderingContext2D,
route: SignRouteSpec,
@@ -749,59 +676,50 @@ function drawLabel(
context.scale(width / 2_048, height / 512);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.textAlign = 'left';
context.textBaseline = 'middle';
// Exit tab.
context.font = '720 66px "Geist Mono Variable", ui-monospace, monospace';
const tabText = `EXIT ${route.marker}`;
const tabWidth = context.measureText(tabText).width + 72;
// Stacked exit tab.
context.fillStyle = accent;
chamferedRect(context, 96, 50, tabWidth, 116, 18);
chamferedRect(context, 72, 72, 300, 368, 40);
context.fill();
context.fillStyle = '#160b22';
context.fillText(tabText, 132, 110);
// "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,
);
context.textAlign = 'center';
context.font = '700 64px "Geist Mono Variable", ui-monospace, monospace';
context.fillText('EXIT', 222, 150);
context.font = '760 200px "Geist Mono Variable", ui-monospace, monospace';
context.fillText(route.marker, 222, 316);
// Chapter label.
context.textAlign = 'left';
context.fillStyle = '#fff2df';
const labelSize = fittedLabelSize(context, route.label, 1_480);
const labelSize = fittedLabelSize(context, route.label, 1_250);
context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
context.fillText(route.label, 96, 322);
context.fillText(route.label, 432, 236);
// Lane rule under the label.
context.globalAlpha = 0.7;
// Lane rule: solid under the label, dashed toward the arrow.
context.globalAlpha = 0.75;
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.fillRect(432, 410, 1_080, 10);
context.globalAlpha = 0.4;
for (let x = 1_540; x < 1_960; x += 64) context.fillRect(x, 410, 34, 10);
context.globalAlpha = 1;
// Exit arrow: leans toward the roadside the sign stands on.
// Exit arrow: leans toward the roadside the exit is on.
const leansLeft = route.corridor === 'left' || route.corridor === 'outer-left';
context.save();
context.translate(1_800, 270);
context.translate(1_820, 236);
if (leansLeft) context.scale(-1, 1);
context.rotate(-Math.PI / 4);
context.strokeStyle = accent;
context.lineWidth = 22;
context.lineWidth = 30;
context.lineCap = 'square';
context.lineJoin = 'miter';
context.beginPath();
context.moveTo(-110, 0);
context.lineTo(100, 0);
context.moveTo(30, -70);
context.lineTo(100, 0);
context.lineTo(30, 70);
context.moveTo(-120, 0);
context.lineTo(110, 0);
context.moveTo(30, -80);
context.lineTo(110, 0);
context.lineTo(30, 80);
context.stroke();
context.restore();
context.restore();
@@ -812,11 +730,11 @@ function fittedLabelSize(
label: string,
maximumWidth: number,
): number {
for (let size = 180; size >= 128; size -= 4) {
for (let size = 236; size >= 124; size -= 4) {
context.font = `650 ${size}px "Geist Variable", sans-serif`;
if (context.measureText(label).width <= maximumWidth) return size;
}
return 112;
return 120;
}
function normalizeTextureLimit(value: number | undefined): number {

517
src/styles/gantry.css Normal file
View File

@@ -0,0 +1,517 @@
/* ==========================================================================
Round 5: the overhead gantry.
The top bar is a steel gantry spanning the road. Its sign window is a real
cut-out: the WebGL sign docks in it, and between signs the dash read-out
(JourneyHud) shows through it. The drive controls live in its right bay,
so nothing floats over the reading column. Content starts at the beam's
lower edge (sign-stage.ts clips the reading viewport there).
========================================================================== */
:root {
--gantry-h: 72px;
--window-w: 256px;
--window-h: 64px;
--bay-l: 232px;
--bay-r: 232px;
--window-x: calc(50% - var(--window-w) / 2);
/* mask-position percentages resolve against (box - layer), so the cut-out
needs its own x: 50% centres it, 100% - n puts its right edge n in. */
--window-mask-x: 50%;
--window-y: calc((var(--gantry-h) - var(--window-h)) / 2);
--gantry-steel-1: #1d1230;
--gantry-steel-2: #120c22;
--gantry-rail: rgb(214 140 222 / 30%);
--site-navigation-block-size: var(--gantry-h);
}
/* Mid widths: the window sits beside the controls bay. */
@media (min-width: 768px) and (max-width: 1279px) {
:root {
--window-w: 224px;
--window-h: 56px;
--bay-l: 12px;
--bay-r: 112px;
--window-x: calc(100% - var(--bay-r) - var(--window-w) - 6px);
--window-mask-x: calc(100% - var(--bay-r) - 6px);
}
}
@media (max-width: 767px) {
:root {
--gantry-h: 56px;
--window-h: 40px;
--window-w: min(160px, calc(100vw - 236px));
--bay-l: 116px;
--bay-r: 104px;
--window-x: var(--bay-l);
--window-mask-x: var(--bay-l);
}
}
/* ---------- The beam ---------- */
html .site-navigation,
html .site-navigation[data-enhanced='true'],
html .site-navigation[data-enhanced='true'][data-docked='true'] {
inset: 0 0 auto;
min-block-size: var(--gantry-h);
block-size: var(--gantry-h);
transform: none;
border: 0;
background: none;
box-shadow: none;
}
html .site-navigation::before,
html .site-navigation[data-enhanced='true']::before,
html .site-navigation[data-enhanced='true'][data-docked='true']::before {
inset: 0;
transform: none;
border: 0;
border-radius: 0;
/* Rivet rails top and bottom, brushed steel between, a cut-out window. */
background:
repeating-linear-gradient(90deg, rgb(255 190 240 / 26%) 0 2px, transparent 2px 26px) 0 5px / 100% 2px no-repeat,
repeating-linear-gradient(90deg, rgb(255 190 240 / 18%) 0 2px, transparent 2px 26px) 13px calc(100% - 6px) / 100% 2px no-repeat,
linear-gradient(180deg, rgb(255 255 255 / 5%), transparent 30% 70%, rgb(0 0 0 / 30%)),
linear-gradient(100deg, var(--gantry-steel-1), var(--gantry-steel-2) 55%, #170f2a);
box-shadow:
0 1px 0 var(--gantry-rail),
0 14px 34px -8px rgb(6 1 16 / 70%);
-webkit-mask:
linear-gradient(#000 0 0),
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat;
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0),
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat;
mask-composite: exclude;
}
/* Brand plate in the left bay (decorative; the h1 carries the name). */
html .site-navigation::after {
content: 'LUCA P\00CESLARU' / '';
position: absolute;
inset-block: 0;
inset-inline-start: 22px;
display: flex;
align-items: center;
gap: 10px;
color: #f4e6ff;
font: 680 0.74rem/1 var(--mono);
letter-spacing: 0.24em;
pointer-events: none;
white-space: nowrap;
}
@media (max-width: 1279px) {
html .site-navigation::after { display: none; }
}
/* ---------- Exits on the beam ---------- */
@media (min-width: 768px) {
html .site-navigation[data-enhanced] .site-navigation__disclosure,
html .site-navigation .site-navigation__disclosure {
inline-size: 100%;
max-inline-size: none;
block-size: 100%;
margin: 0;
padding-inline: var(--bay-l) calc(var(--bay-r) + var(--window-w) + 18px);
}
html .site-navigation ul {
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: var(--gantry-h);
gap: 0;
padding: 0;
block-size: 100%;
}
html .site-navigation ul::before,
html .site-navigation ul::after {
display: none;
}
html .site-navigation a {
position: relative;
block-size: 100%;
flex-direction: column;
gap: 5px;
padding-inline: 2px;
border-radius: 0;
background: none;
color: #bfaed3;
font: 560 0.72rem/1.1 var(--sans);
letter-spacing: 0.01em;
text-align: center;
}
html .site-navigation a::before {
content: counter(exit, decimal-leading-zero) / '';
margin: 0;
color: color-mix(in srgb, var(--rose, #ff83c2) 70%, #8f7aa6);
font: 650 0.6rem/1 var(--mono);
letter-spacing: 0.14em;
opacity: 0.85;
}
html .site-navigation a:hover,
html .site-navigation a:focus-visible {
background: linear-gradient(180deg, transparent, rgb(255 131 194 / 10%));
color: #fff4fb;
}
html .site-navigation a[aria-current='page'] {
color: #fff4fb;
background: linear-gradient(180deg, transparent 20%, rgb(255 214 147 / 10%));
}
html .site-navigation a[aria-current='page']::before {
color: var(--focus);
opacity: 1;
}
/* Lane lamps under each exit: dim ahead, lit once passed, amber here. */
html .site-navigation li::after {
content: '' / '';
inset: auto 18% 9px;
inline-size: auto;
block-size: 2px;
translate: none;
border: 0;
border-radius: 2px;
background: rgb(198 122 209 / 22%);
box-shadow: none;
transition: background-color 360ms ease, box-shadow 360ms ease, inset 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
html .site-navigation li[data-route='passed']::after {
inline-size: auto;
block-size: 2px;
border: 0;
background: color-mix(in srgb, var(--rose, #ff83c2) 70%, transparent);
}
html .site-navigation li[data-route='here']::after {
inset: auto 10% 9px;
inline-size: auto;
block-size: 3px;
border-radius: 2px;
background: var(--focus);
box-shadow: 0 0 12px rgb(255 214 147 / 70%);
}
}
@media (min-width: 1280px) {
/* Wide: exits 01-04 left of the window, 05-08 right of it. */
html .site-navigation[data-enhanced] .site-navigation__disclosure,
html .site-navigation .site-navigation__disclosure {
padding-inline: var(--bay-l) var(--bay-r);
}
html .site-navigation ul {
grid-template-columns: repeat(4, minmax(0, 1fr)) calc(var(--window-w) + 28px) repeat(4, minmax(0, 1fr));
}
html .site-navigation li:nth-child(5) {
grid-column-start: 6;
}
html .site-navigation a {
font-size: 0.8rem;
}
}
@media (min-width: 768px) and (max-width: 1099px) {
html .site-navigation a::before { display: none; }
html .site-navigation a { font-size: 0.68rem; line-height: 1.05; }
}
/* ---------- The sign window ---------- */
.gantry-window {
position: absolute;
z-index: 1;
inset-block-start: var(--window-y);
inset-inline-start: var(--window-x);
inline-size: var(--window-w);
block-size: var(--window-h);
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
cursor: pointer;
/* A steel lip with a lit inner edge, and depth over the live scene. */
box-shadow:
0 0 0 1px rgb(255 160 220 / 34%),
0 0 0 4px rgb(10 5 20 / 92%),
0 0 0 5px rgb(214 140 222 / 26%),
inset 0 0 18px rgb(6 2 16 / 55%);
transition: box-shadow 320ms ease;
}
.gantry-window::before,
.gantry-window::after {
content: '' / '';
position: absolute;
inline-size: 12px;
block-size: 12px;
border: 2px solid var(--focus);
pointer-events: none;
opacity: 0.75;
transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1), opacity 320ms ease;
}
.gantry-window::before {
inset: -7px auto auto -7px;
border-inline-end: 0;
border-block-end: 0;
}
.gantry-window::after {
inset: auto -7px -7px auto;
border-inline-start: 0;
border-block-start: 0;
}
.gantry-window:hover::before,
.gantry-window:focus-visible::before { transform: translate(-3px, -3px); opacity: 1; }
.gantry-window:hover::after,
.gantry-window:focus-visible::after { transform: translate(3px, 3px); opacity: 1; }
.gantry-window:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 6px;
}
html[data-gantry='docked'] .gantry-window,
html[data-gantry='incoming'] .gantry-window {
box-shadow:
0 0 0 1px rgb(255 214 147 / 70%),
0 0 0 4px rgb(10 5 20 / 92%),
0 0 0 5px rgb(255 214 147 / 35%),
0 0 26px rgb(255 160 220 / 22%),
inset 0 0 18px rgb(6 2 16 / 40%);
}
@media (max-width: 359px) {
.gantry-window { display: none; }
}
/* ---------- The dash read-out, shown through the window ---------- */
html .journey-hud {
position: fixed;
z-index: var(--z-hud);
inset: var(--window-y) auto auto 0;
translate: none;
margin-inline-start: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: 1fr auto;
align-items: center;
column-gap: 9px;
row-gap: 3px;
inline-size: var(--window-w);
max-inline-size: none;
block-size: var(--window-h);
min-block-size: 0;
padding: 7px 11px 6px;
border: 0;
border-radius: 0;
background: linear-gradient(180deg, rgb(20 10 34 / 94%), rgb(12 7 24 / 94%));
box-shadow: none;
color: #cbb9de;
letter-spacing: 0.06em;
pointer-events: none;
transition: opacity 260ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* The window's left edge, without reading the nav's own custom properties. */
@media (min-width: 1280px) {
html .journey-hud { inset-inline-start: calc(50% - var(--window-w) / 2); }
}
@media (min-width: 768px) and (max-width: 1279px) {
html .journey-hud { inset-inline-start: calc(100% - var(--bay-r) - var(--window-w) - 6px); }
}
@media (max-width: 767px) {
html .journey-hud { inset-inline-start: var(--bay-l); }
}
html .journey-hud[data-exit]::before {
grid-column: 1;
grid-row: 1;
align-self: center;
font-size: 0.6rem;
}
html .journey-hud__section {
grid-column: 2;
grid-row: 1;
overflow: hidden;
max-inline-size: none;
color: #fff4fb;
font: 620 0.98rem/1 var(--sans);
letter-spacing: -0.01em;
text-overflow: ellipsis;
white-space: nowrap;
}
html .journey-hud__section::before { display: none; }
html .journey-hud[data-clock]::after {
grid-column: 3;
grid-row: 1;
padding-inline-start: 0;
border: 0;
color: #a998bd;
font: 560 0.58rem/1 var(--mono);
}
html .journey-hud__route {
grid-column: 1 / -1;
grid-row: 2;
gap: 0.35rem;
overflow: hidden;
color: #a998bd;
font: 560 0.56rem/1 var(--mono);
letter-spacing: 0.12em;
white-space: nowrap;
}
html .journey-hud__route span { font-size: inherit; color: inherit; display: inline; }
html .journey-hud__route strong { color: var(--focus); font-size: inherit; }
html .journey-hud__route[data-next]::after {
content: '\00b7 ' attr(data-next) / '';
overflow: hidden;
color: #d6c3ea;
text-overflow: ellipsis;
white-space: pre;
}
html .journey-hud__arrival {
grid-column: 3;
grid-row: 2;
display: block;
color: var(--focus);
font: 720 0.56rem/1 var(--mono);
letter-spacing: 0.16em;
}
/* The read-out yields to the docked sign and returns once it has passed. */
html[data-gantry='incoming'] .journey-hud,
html[data-gantry='docked'] .journey-hud,
html[data-gantry='passing'] .journey-hud {
opacity: 0;
transform: translateY(-6px);
}
@media (max-width: 767px) {
html .journey-hud {
grid-template-rows: 1fr;
padding: 0 8px;
column-gap: 7px;
}
html .journey-hud__route,
html .journey-hud__arrival,
html .journey-hud[data-clock]::after { display: none; }
html .journey-hud[data-exit]::before { display: block; font-size: 0.56rem; }
html .journey-hud__section { font-size: 0.82rem; }
}
@media (max-width: 359px) {
html .journey-hud { display: none; }
}
/* ---------- Drive controls in the right bay ---------- */
html .drive-controls {
position: fixed;
z-index: var(--z-popover);
inset: calc((var(--gantry-h) - 44px) / 2) max(12px, env(safe-area-inset-right)) auto auto;
margin: 0;
inline-size: auto;
max-inline-size: none;
gap: 4px;
}
html .drive-controls__dock {
padding: 0;
gap: 2px;
border: 0;
background: none;
box-shadow: none;
}
html .drive-control-button,
html .drive-controls__settings-trigger {
border: 1px solid rgb(214 140 222 / 26%);
border-radius: 4px;
background: rgb(24 14 40 / 92%);
box-shadow: none;
color: #d9c9e6;
}
html .drive-control-button:hover {
border-color: rgb(255 160 220 / 55%);
background: #2f1c45;
color: #ffd1ec;
}
html .drive-start {
min-inline-size: 0;
gap: 10px;
padding-inline: 12px 14px;
border-radius: 4px;
font-size: 0.6rem;
letter-spacing: 0.12em;
}
@media (max-width: 1279px) {
html .drive-start { inline-size: 44px; padding: 0; justify-content: center; }
html .drive-start > span { display: none; }
}
/* Tooltips and popovers open downward from the beam. */
html .drive-control-button::after {
inset: calc(100% + 0.5rem) auto auto 50%;
transform: translate(-50%, -4px);
}
html .drive-control-button:hover::after,
html .drive-control-button:focus-visible::after {
transform: translate(-50%, 0);
}
html .drive-popover {
position: absolute;
inset: calc(100% + 0.7rem) 0 auto auto;
max-inline-size: calc(100vw - 1rem);
transform-origin: right top;
}
html .drive-controls__audio-status {
position: absolute;
inset: calc(100% + 0.6rem) 0 auto auto;
inline-size: max-content;
max-inline-size: calc(100vw - 1.5rem);
margin: 0;
padding: 0.55rem 0.75rem;
border: 1px solid rgb(214 140 222 / 32%);
border-radius: 4px;
}
@media (max-height: 420px) {
html .drive-controls { position: fixed; inset: calc((var(--gantry-h) - 44px) / 2) 12px auto auto; margin: 0; }
html .journey-hud { display: grid; }
html .drive-popover { inset: calc(100% + 0.5rem) 0 auto auto; }
}
/* ---------- Compact screens ---------- */
@media (max-width: 767px) {
html .site-navigation__disclosure {
position: static;
block-size: 100%;
}
html .site-navigation summary {
inline-size: var(--bay-l);
min-block-size: var(--gantry-h);
padding-inline: 16px 10px;
font-size: 0.7rem;
}
/* The window names the exit; the summary keeps its open/close sign. */
html .site-navigation summary[data-exit]::after {
content: '+' / '';
margin: 0;
padding: 0;
background: none;
color: var(--focus);
font: 500 1.1rem/1 var(--mono);
letter-spacing: 0;
}
html .site-navigation details[open] summary[data-exit]::after { content: '\2212' / ''; }
html .site-navigation ul {
inset: var(--gantry-h) 0 auto;
background: linear-gradient(180deg, var(--gantry-steel-1), var(--gantry-steel-2));
}
}
/* ---------- Content lands right under the beam ---------- */
html[data-expressway='live'] .portfolio-section,
html[data-expressway='live'] #contact,
html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
--chapter-landing-space: calc(var(--sign-reading-top, 90px) + 10px);
--chapter-reading-inset: 10px;
}
/* ---------- Preferences ---------- */
@media (prefers-reduced-motion: reduce) {
.gantry-window,
.gantry-window::before,
.gantry-window::after,
html .journey-hud,
html .site-navigation li::after { transition: none; }
}
@media (forced-colors: active) {
html .site-navigation::before { background: Canvas; }
html .site-navigation::after { color: CanvasText; }
.gantry-window { border: 1px solid ButtonText; box-shadow: none; }
.gantry-window::before,
.gantry-window::after { border-color: Highlight; }
html .journey-hud { background: Canvas; color: CanvasText; border: 1px solid CanvasText; }
html .site-navigation li::after { background: CanvasText; box-shadow: none; }
html .site-navigation li[data-route='here']::after { background: Highlight; }
}
@media print {
.gantry-window { display: none !important; }
}

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import type { SignSnapshot } from '@/journey/types';
import { SceneResourceBag } from '@/scene/resources';
import { createChaseCamera } from '@/scene/world/environment';
import { createSign, routeLabelFor, type SignPresentation, type SignRenderOptions } from '@/scene/world/sign';
import { createSign, routeLabelFor, type SignDock, type SignPresentation, type SignRenderOptions, type SignTilt } from '@/scene/world/sign';
interface SignMesh {
position: { z: number };
@@ -25,22 +25,30 @@ afterEach(() => { owned.splice(0).forEach(resources => resources.dispose()); });
const profile: SignSnapshot = { targetId: 'profile', phase: 'dwell', progress: 1, opacity: 1 };
// Follow the live sequence: update journey state, then compose with the camera,
// viewport and navigation clearance. update() alone does not position a sign.
// viewport and the gantry window. update() alone does not position a sign.
function windowFor(width: number): SignDock {
const w = width >= 1280 ? 256 : width >= 768 ? 224 : 160;
const h = w / 4;
return { left: (width - w) / 2, top: 4, width: w, height: h };
}
function setup(width = 1440, height = 900, options: SignRenderOptions = {}) {
const resources = new SceneResourceBag();
owned.push(resources);
const sign = createSign(resources, options);
const camera = createChaseCamera(width / height);
let now = 0;
let navigationBottom = 68;
let dock = windowFor(width);
let tilt: SignTilt = { x: 0, y: 0 };
sign.setViewportSize(width, height);
return {
resources, sign, camera,
get dock() { return dock; },
render(snapshot: SignSnapshot | null, frames = 1) {
for (let frame = 0; frame < frames; frame++) {
now += 1000 / 60;
sign.update(snapshot, now);
sign.compose(camera, width, height, navigationBottom, now);
sign.compose(camera, width, height, dock, now, tilt);
}
return sign.inspect(camera, width, height);
},
@@ -50,17 +58,18 @@ function setup(width = 1440, height = 900, options: SignRenderOptions = {}) {
camera.aspect = width / height;
camera.updateProjectionMatrix();
sign.setViewportSize(width, height);
dock = windowFor(width);
},
setNavigationBottom(bottom: number) { navigationBottom = bottom; },
setDock(next: SignDock) { dock = next; },
setTilt(next: SignTilt) { tilt = next; },
};
}
function expectInViewport(bounds: SignPresentation['bounds'], width: number, height: number, navigationBottom = 68) {
expect(bounds.left).toBeGreaterThanOrEqual(8);
expect(bounds.right).toBeLessThanOrEqual(width - 8);
expect(bounds.top).toBeGreaterThanOrEqual(navigationBottom);
expect(bounds.bottom).toBeLessThanOrEqual(height - 8);
expect(bounds.width).toBeGreaterThan(80);
function expectDocked(bounds: SignPresentation['bounds'], dock: SignDock, tolerance = 2) {
expect(Math.abs(bounds.left - dock.left)).toBeLessThan(tolerance);
expect(Math.abs(bounds.right - (dock.left + dock.width))).toBeLessThan(tolerance);
expect(Math.abs(bounds.top - dock.top)).toBeLessThan(tolerance);
expect(Math.abs(bounds.bottom - (dock.top + dock.height))).toBeLessThan(tolerance);
}
describe('composed route signs', () => {
@@ -127,90 +136,82 @@ describe('composed route signs', () => {
expect(h.resources.snapshot()).toEqual(allocation);
});
it('eases between roadside and compact layouts over successive rendered frames', () => {
const h = setup();
h.render(profile, 120);
const origin = h.sign.group.position.clone();
h.resize(844, 390);
h.render(profile);
const first = h.sign.group.position.clone();
h.render(profile, 10);
const midpoint = h.sign.group.position.clone();
const final = h.render(profile, 120)!;
const destination = h.sign.group.position.clone();
expect(first.distanceTo(origin)).toBeGreaterThan(0);
expect(first.distanceTo(origin)).toBeLessThan(destination.distanceTo(origin) / 2);
expect(midpoint.distanceTo(destination)).toBeLessThan(first.distanceTo(destination));
expect(h.sign.group.userData.anchoring).toBe('compact-shelf');
expectInViewport(final.bounds, 844, 390);
h.resize(1440, 900);
h.render(profile, 120);
expect(h.sign.group.userData.anchoring).toBe('roadside');
expect(h.sign.group.position.x).toBeCloseTo(origin.x, 2);
it.each([[1440, 900], [1920, 1080], [1024, 768], [390, 844], [844, 390]])('docks the face exactly into the gantry window at %ix%i', (width, height) => {
const h = setup(width, height);
const docked = h.render(profile, 120)!;
expectDocked(docked.bounds, h.dock);
expect(h.sign.group.userData.anchoring).toBe('gantry');
expect(h.sign.dockState()).toBe('docked');
});
it('moves the compact shelf below a taller navigator without a one-frame teleport', () => {
const h = setup(390, 844);
const original = h.render(profile, 60)!.bounds.top;
h.setNavigationBottom(180);
const first = h.render({ ...profile, navigationArrival: true })!.bounds.top;
const settled = h.render({ ...profile, navigationArrival: true }, 120)!.bounds;
it('follows a moved window over successive frames without a one-frame teleport', () => {
const h = setup();
const original = h.render(profile, 60)!.bounds.left;
h.setDock({ ...h.dock, left: h.dock.left + 300 });
const first = h.render(profile)!.bounds.left;
const settled = h.render(profile, 120)!.bounds;
expect(first).toBeGreaterThan(original);
expect(first - original).toBeLessThan((settled.top - original) / 2);
expectInViewport(settled, 390, 844, 180);
expect(first - original).toBeLessThan((settled.left - original) / 2);
expectDocked(settled, h.dock);
});
it('drives past a roadside sign along the road axis at constant physical scale', () => {
it('lifts off the horizon and grows into the window while approaching', () => {
const h = setup();
const dwell = h.render(profile, 120)!;
const position = h.sign.group.position.clone();
const scale = h.sign.group.scale.x;
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5 })!;
expect(h.sign.group.position.x).toBeCloseTo(position.x, 6);
expect(h.sign.group.position.y).toBeCloseTo(position.y, 6);
expect(h.sign.group.position.z).toBeGreaterThan(position.z);
expect(h.sign.group.scale.x).toBeCloseTo(scale, 6);
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width);
const passingZ = h.sign.group.position.z;
h.render({ ...profile, phase: 'depart', progress: 0.9, opacity: 0.1 });
expect(h.sign.group.position.z).toBeGreaterThan(passingZ);
expect(h.sign.group.scale.x).toBeCloseTo(scale, 6);
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds;
const late = h.render({ ...profile, phase: 'approach', progress: 0.7, opacity: 1 })!.bounds;
const docked = h.render(profile)!.bounds;
expect(h.sign.dockState()).toBe('docked');
expect(early.width).toBeLessThan(late.width);
expect(late.width).toBeLessThan(docked.width + 1);
expect(early.top).toBeGreaterThan(late.top);
expect(late.top).toBeGreaterThan(docked.top - 1);
expect(early.top).toBeGreaterThan(900 * 0.3);
});
it('passes overhead and out of the top of the frame as it departs', () => {
const h = setup();
const docked = h.render(profile, 120)!.bounds;
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 })!.bounds;
expect(h.sign.dockState()).toBe('passing');
expect(passing.top).toBeLessThan(docked.top);
expect(passing.width).toBeGreaterThan(docked.width);
const gone = h.render({ ...profile, phase: 'depart', progress: 1, opacity: 0.05 })!.bounds;
expect(gone.bottom).toBeLessThan(docked.bottom);
h.render(null);
expect(h.sign.group.visible).toBe(false);
expect(h.sign.dockState()).toBe('idle');
});
it.each([[390, 844], [844, 390], [1440, 900]])('keeps both sign supports on the ground at %ix%i', (width, height) => {
const h = setup(width, height);
h.render(profile);
const footing = h.sign.group.getObjectByName('sign-footing') as { position: { y: number }; children: unknown[] };
expect(h.sign.group.position.y + footing.position.y * h.sign.group.scale.x).toBeCloseTo(0, 6);
expect(footing.children).toHaveLength(2);
});
it.each([[780, 493], [844, 390]])('keeps short-landscape signs in the right-side shelf at %ix%i', (width, height) => {
const h = setup(width, height);
const presentation = h.render(profile, 60)!;
expectInViewport(presentation.bounds, width, height);
expect(presentation.bounds.left).toBeGreaterThan(width / 2);
expect(h.sign.group.userData.anchoring).toBe('compact-shelf');
});
it.each([[320, 640], [390, 844], [692, 793], [1024, 768]])('keeps a readable compact navigator sign inside %ix%i', (width, height) => {
const h = setup(width, height);
const presentation = h.render({ ...profile, navigationArrival: true }, 60)!;
expectInViewport(presentation.bounds, width, height);
expect(presentation.bounds.width).toBeGreaterThan(200);
expect(presentation.bounds.width).toBeLessThan(380);
expect(h.sign.group.userData.anchoring).toBe('compact-shelf');
});
it('anchors opposite chapter signs on opposite roadsides and clears the car envelope', () => {
it('turns toward the pointer only while docked, and settles back', () => {
const h = setup();
h.render(profile, 120);
expect(h.sign.group.position.x).toBeLessThan(-11.6);
const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } };
const rest = group.quaternion.clone();
h.setTilt({ x: 1, y: -1 });
h.render(profile, 60);
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.2);
h.setTilt({ x: 0, y: 0 });
h.render(profile, 120);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
h.setTilt({ x: 1, y: 1 });
h.render(null);
h.render({ ...profile, targetId: 'experience' }, 120);
expect(h.sign.group.position.x).toBeGreaterThan(11.6);
expect(h.sign.group.userData.anchoring).toBe('roadside');
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
const approaching = group.quaternion.clone();
expect(approaching.angleTo(h.camera.quaternion)).toBeLessThan(0.01);
});
it('shows no sign when the gantry has no window', () => {
const h = setup(320, 640);
h.setDock({ left: 0, top: 0, width: 0, height: 0 });
h.render(profile, 10);
expect(h.sign.group.visible).toBe(false);
expect(h.sign.dockState()).toBe('idle');
});
it('reports an incoming sign before it docks', () => {
const h = setup();
h.render({ ...profile, phase: 'approach', progress: 0.3, opacity: 1 });
expect(h.sign.dockState()).toBe('incoming');
});
});