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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user