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:
BIN
docs/redesign/round5/gantry-after-1024.webp
Normal file
BIN
docs/redesign/round5/gantry-after-1024.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/redesign/round5/gantry-after-1440-docked.webp
Normal file
BIN
docs/redesign/round5/gantry-after-1440-docked.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/redesign/round5/gantry-after-1440-idle.webp
Normal file
BIN
docs/redesign/round5/gantry-after-1440-idle.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/redesign/round5/gantry-after-1440-incoming.webp
Normal file
BIN
docs/redesign/round5/gantry-after-1440-incoming.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/redesign/round5/gantry-after-390.webp
Normal file
BIN
docs/redesign/round5/gantry-after-390.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/redesign/round5/gantry-before-1440.webp
Normal file
BIN
docs/redesign/round5/gantry-before-1440.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -12,6 +12,7 @@ import './styles/refinements.css';
|
|||||||
import './styles/title-navigation.css';
|
import './styles/title-navigation.css';
|
||||||
import './styles/wayfinding.css';
|
import './styles/wayfinding.css';
|
||||||
import './styles/route.css';
|
import './styles/route.css';
|
||||||
|
import './styles/gantry.css';
|
||||||
import './styles/print.css';
|
import './styles/print.css';
|
||||||
|
|
||||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ onUnmounted(() => removePageListeners());
|
|||||||
:journey="journey"
|
:journey="journey"
|
||||||
:section-label="activeSectionLabel"
|
:section-label="activeSectionLabel"
|
||||||
:exit="exitFor(journey.activeSectionId)"
|
:exit="exitFor(journey.activeSectionId)"
|
||||||
|
:next-label="nextLabelFor(journey.activeSectionId)"
|
||||||
|
:next-exit="nextExitFor(journey.activeSectionId)"
|
||||||
/>
|
/>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<SiteNavigation
|
<SiteNavigation
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ const props = defineProps<{
|
|||||||
journey: JourneySnapshot;
|
journey: JourneySnapshot;
|
||||||
sectionLabel: string;
|
sectionLabel: string;
|
||||||
exit?: string | undefined;
|
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)}%`);
|
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.
|
// The dash clock is decorative and CSS-generated, so SSR and hydrated text stay identical.
|
||||||
@@ -24,7 +31,7 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="journey-hud" aria-hidden="true" :data-exit="exit" :data-clock="clock">
|
<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>
|
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
||||||
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ onBeforeUnmount(() => {
|
|||||||
compactQuery = null;
|
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 => {
|
const navigate = (event: MouseEvent): void => {
|
||||||
props.onSectionLinkClick(event, true);
|
props.onSectionLinkClick(event, true);
|
||||||
if (window.matchMedia("(max-width: 767px)").matches)
|
if (window.matchMedia("(max-width: 767px)").matches)
|
||||||
@@ -117,5 +133,13 @@ const navigate = (event: MouseEvent): void => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
<button
|
||||||
|
v-if="enhanced"
|
||||||
|
type="button"
|
||||||
|
class="gantry-window"
|
||||||
|
:aria-label="windowLabel"
|
||||||
|
:title="windowLabel"
|
||||||
|
@click="driveOn"
|
||||||
|
></button>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { createHorizonAir } from '@/scene/world/horizon-air';
|
|||||||
import { createExpressway } from '@/scene/world/expressway';
|
import { createExpressway } from '@/scene/world/expressway';
|
||||||
import { createDistricts } from '@/scene/world/districts';
|
import { createDistricts } from '@/scene/world/districts';
|
||||||
import { createSignStage } from '@/scene/world/sign-stage';
|
import { createSignStage } from '@/scene/world/sign-stage';
|
||||||
import { usesRoadsideSign } from '@/scene/world/sign-layout';
|
|
||||||
import { createSpeedWake } from '@/scene/world/speed-wake';
|
import { createSpeedWake } from '@/scene/world/speed-wake';
|
||||||
import { createRoad } from '@/scene/world/road';
|
import { createRoad } from '@/scene/world/road';
|
||||||
import { createTerrain } from '@/scene/world/terrain';
|
import { createTerrain } from '@/scene/world/terrain';
|
||||||
@@ -175,7 +174,6 @@ export async function createProceduralSceneGeneration(
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let renderedFrames = 0;
|
let renderedFrames = 0;
|
||||||
let atmosphericTime = 0;
|
let atmosphericTime = 0;
|
||||||
let signCameraWeight = 0;
|
|
||||||
let overdrive = 0;
|
let overdrive = 0;
|
||||||
let terrainSurge = 0;
|
let terrainSurge = 0;
|
||||||
let musicPulse = 0;
|
let musicPulse = 0;
|
||||||
@@ -186,6 +184,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
let introProgress: number | null = null;
|
let introProgress: number | null = null;
|
||||||
let introFrames = 0;
|
let introFrames = 0;
|
||||||
let introSlowFrames = 0;
|
let introSlowFrames = 0;
|
||||||
|
let gantryState = '';
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
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.
|
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
|
||||||
const cameraX = wideComposition ? composition.x : 0;
|
const cameraX = wideComposition ? composition.x : 0;
|
||||||
const targetX = 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 =
|
const targetShift =
|
||||||
height < 560 && width >= 768
|
height < 560 && width >= 768
|
||||||
? 0.22
|
? 0.22
|
||||||
: wideComposition
|
: wideComposition
|
||||||
? THREE.MathUtils.lerp(
|
? composition.shift
|
||||||
composition.shift,
|
|
||||||
composition.shift * 0.28,
|
|
||||||
signCameraWeight,
|
|
||||||
)
|
|
||||||
: 0;
|
: 0;
|
||||||
const targetVerticalShift = wideComposition
|
const targetVerticalShift = wideComposition
|
||||||
? 0
|
? 0
|
||||||
@@ -558,14 +545,12 @@ export async function createProceduralSceneGeneration(
|
|||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
const stage = signStage.update(width, height);
|
const stage = signStage.update(width, height);
|
||||||
sign.compose(
|
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
||||||
camera,
|
const gantry = sign.dockState();
|
||||||
width,
|
if (gantry !== gantryState) {
|
||||||
height,
|
gantryState = gantry;
|
||||||
stage.navigationBottom,
|
document.documentElement.dataset.gantry = gantry;
|
||||||
now,
|
}
|
||||||
car.group.position,
|
|
||||||
);
|
|
||||||
if (TEST_BUILD && snapshot.sign !== null) {
|
if (TEST_BUILD && snapshot.sign !== null) {
|
||||||
const presentation = sign.inspect(camera, width, height);
|
const presentation = sign.inspect(camera, width, height);
|
||||||
if (presentation !== null) {
|
if (presentation !== null) {
|
||||||
@@ -600,6 +585,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
delete document.documentElement.dataset.gantry;
|
||||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||||
// defaults before the next renderer creates its empty array textures.
|
// defaults before the next renderer creates its empty array textures.
|
||||||
const gl = renderer.getContext();
|
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 type { SceneResourceBag } from '@/scene/resources';
|
||||||
import { usesRoadsideSign } from './sign-layout';
|
import type { SignDock, SignTilt } from './sign';
|
||||||
|
|
||||||
const stageOwners = new WeakMap<HTMLElement, object>();
|
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
|
/** The reading window starts at the gantry's lower edge: one stationary mask
|
||||||
* moves the content behind it. Changing chapter-sized gradients on every
|
* hides content under the beam (and behind its see-through sign window) and
|
||||||
* scroll invalidates their rasterized content in Chromium. */
|
* 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) {
|
export function createSignStage(resources: SceneResourceBag) {
|
||||||
const main = document.querySelector<HTMLElement>('.portfolio-main');
|
const main = document.querySelector<HTMLElement>('.portfolio-main');
|
||||||
const stage = main?.closest<HTMLElement>('.reading-stage');
|
const stage = main?.closest<HTMLElement>('.reading-stage');
|
||||||
const viewport = main?.parentElement;
|
const viewport = main?.parentElement;
|
||||||
const nav = document.querySelector<HTMLElement>('.site-navigation');
|
const nav = document.querySelector<HTMLElement>('.site-navigation');
|
||||||
|
const dockElement = () =>
|
||||||
|
document.querySelector<HTMLElement>('.gantry-window');
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const lazyImages = main
|
const lazyImages = main
|
||||||
? Array.from(main.querySelectorAll<HTMLImageElement>('img[loading="lazy"]'))
|
? Array.from(main.querySelectorAll<HTMLImageElement>('img[loading="lazy"]'))
|
||||||
@@ -27,11 +32,14 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
: [];
|
: [];
|
||||||
const token = {};
|
const token = {};
|
||||||
let dirty = true;
|
let dirty = true;
|
||||||
let stageBottom = 176;
|
let stageBottom = 72;
|
||||||
let navigationBottom = 66;
|
let navigationBottom = 72;
|
||||||
let readingTop = 220;
|
let readingTop = 90;
|
||||||
let topEdge = 188;
|
let topEdge = 72;
|
||||||
let feather = 32;
|
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 contentHeight = 0;
|
||||||
let viewportHeight = 0;
|
let viewportHeight = 0;
|
||||||
let viewportWidth = 0;
|
let viewportWidth = 0;
|
||||||
@@ -53,17 +61,9 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
lastOffset = offset;
|
lastOffset = offset;
|
||||||
viewport.scrollTop = offset;
|
viewport.scrollTop = offset;
|
||||||
}
|
}
|
||||||
// Quantize the opening reveal: the mask image is rebuilt at most
|
const top = topEdge;
|
||||||
// REVEAL_STEPS times on the way in instead of on every scroll event.
|
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)`;
|
||||||
const reveal = Math.round(Math.min(
|
// This image stays unchanged until the gantry or the viewport resizes.
|
||||||
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.
|
|
||||||
if (mask !== lastMask) {
|
if (mask !== lastMask) {
|
||||||
viewport.style.maskImage = mask;
|
viewport.style.maskImage = mask;
|
||||||
lastMask = mask;
|
lastMask = mask;
|
||||||
@@ -90,30 +90,39 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
dirty = false;
|
dirty = false;
|
||||||
viewportWidth = width;
|
viewportWidth = width;
|
||||||
viewportHeight = height;
|
viewportHeight = height;
|
||||||
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
|
navigationBottom = nav?.getBoundingClientRect().bottom ?? 72;
|
||||||
const faceWidth = Math.min(
|
const windowElement = dockElement();
|
||||||
width - 44,
|
if (windowElement && windowElement !== observedDock) {
|
||||||
width < 900 ? 288 : 356,
|
observedDock = windowElement;
|
||||||
height < 650 ? 250 : 356,
|
observer.observe(windowElement);
|
||||||
);
|
resources.listen(windowElement, 'pointermove', (event: Event) => {
|
||||||
const shelfBottom = navigationBottom + 24 + faceWidth / 3.18;
|
if (!(event instanceof PointerEvent)) return;
|
||||||
const roadside = usesRoadsideSign(width, height);
|
const rect = windowElement.getBoundingClientRect();
|
||||||
feather = height < 650 ? 28 : roadside ? 32 : 48;
|
tilt.x = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width) * 2 - 1));
|
||||||
stageBottom = shelfBottom + (height < 650 ? feather : 0);
|
tilt.y = Math.max(-1, Math.min(1, ((event.clientY - rect.top) / rect.height) * 2 - 1));
|
||||||
const short = height < 560;
|
}, { passive: true });
|
||||||
const proposedTopEdge = short
|
resources.listen(windowElement, 'pointerleave', () => {
|
||||||
? navigationBottom + 10
|
tilt.x = 0;
|
||||||
: height < 650
|
tilt.y = 0;
|
||||||
? shelfBottom - 22
|
}, { passive: true });
|
||||||
: roadside
|
}
|
||||||
? navigationBottom + 128 + (height - 900) * 0.085
|
const rect = windowElement?.getBoundingClientRect();
|
||||||
: shelfBottom;
|
if (rect && rect.width > 0 && rect.height > 0) {
|
||||||
topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36);
|
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;
|
readingTop = topEdge + feather;
|
||||||
if (main && stage && viewport?.classList.contains('reading-viewport')) {
|
if (main && stage && viewport?.classList.contains('reading-viewport')) {
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
'--sign-stage-bottom',
|
'--sign-stage-bottom',
|
||||||
`${(short ? topEdge : stageBottom).toFixed(2)}px`,
|
`${stageBottom.toFixed(2)}px`,
|
||||||
);
|
);
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
'--sign-reading-top',
|
'--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 });
|
resources.listen(window, 'scroll', moveContent, { passive: true });
|
||||||
@@ -171,7 +180,8 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
if (ownsStage()) update(root.clientWidth, window.innerHeight);
|
if (ownsStage()) update(root.clientWidth, window.innerHeight);
|
||||||
};
|
};
|
||||||
resources.listen(window, 'resize', layoutChanged, { passive: true });
|
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 (main) observer.observe(main);
|
||||||
if (nav) observer.observe(nav);
|
if (nav) observer.observe(nav);
|
||||||
for (const { heading } of headings) observer.observe(heading);
|
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;
|
if (event.target.matches('h1,h2,main')) return;
|
||||||
moveContent();
|
moveContent();
|
||||||
const rect = event.target.getBoundingClientRect();
|
const rect = event.target.getBoundingClientRect();
|
||||||
const bottom =
|
const bottom = window.innerHeight - 24;
|
||||||
window.innerHeight < 560 && window.innerWidth < 768
|
|
||||||
? window.innerHeight - 164
|
|
||||||
: window.innerHeight - 74;
|
|
||||||
if (rect.top < readingTop)
|
if (rect.top < readingTop)
|
||||||
window.scrollBy({ top: rect.top - readingTop - 12, behavior: 'instant' });
|
window.scrollBy({ top: rect.top - readingTop - 12, behavior: 'instant' });
|
||||||
else if (rect.bottom > bottom)
|
else if (rect.bottom > bottom)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as THREE from 'three';
|
|||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import type { SignSnapshot } from '@/journey/types';
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
import {
|
import {
|
||||||
SIGN_ROUTE_ORDER,
|
|
||||||
signRouteFor,
|
signRouteFor,
|
||||||
signViewportProfileFor,
|
signViewportProfileFor,
|
||||||
type SignCorridor,
|
type SignCorridor,
|
||||||
@@ -10,14 +9,35 @@ import {
|
|||||||
type SignViewportProfile,
|
type SignViewportProfile,
|
||||||
} from '@/journey/sign-routes';
|
} from '@/journey/sign-routes';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout';
|
|
||||||
|
|
||||||
interface ProjectingCamera {
|
interface ProjectingCamera {
|
||||||
position: { x: number; y: number; z: number };
|
position: { x: number; y: number; z: number };
|
||||||
rotation: { y: number };
|
rotation: { y: number };
|
||||||
|
quaternion: unknown;
|
||||||
updateMatrixWorld(force: boolean): void;
|
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 =
|
const SIGN_MARKER_FONT =
|
||||||
'600 60px "Geist Mono Variable", ui-monospace, monospace';
|
'600 60px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
||||||
@@ -32,10 +52,11 @@ export interface SignWorld {
|
|||||||
camera: ProjectingCamera,
|
camera: ProjectingCamera,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
navigationBottom: number,
|
dock: SignDock,
|
||||||
now: number,
|
now: number,
|
||||||
carAnchor?: { x: number; z: number },
|
tilt?: SignTilt,
|
||||||
): void;
|
): void;
|
||||||
|
dockState(): GantryState;
|
||||||
setViewportAspect(aspect: number): void;
|
setViewportAspect(aspect: number): void;
|
||||||
setViewportSize(width: number, height: number): void;
|
setViewportSize(width: number, height: number): void;
|
||||||
update(sign: SignSnapshot | null, now?: number): void;
|
update(sign: SignSnapshot | null, now?: number): void;
|
||||||
@@ -108,7 +129,7 @@ export function createSign(
|
|||||||
options: SignRenderOptions = {},
|
options: SignRenderOptions = {},
|
||||||
): SignWorld {
|
): SignWorld {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = 'roadside-sign';
|
group.name = 'gantry-sign';
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
|
|
||||||
const housingMaterial = resources.own(
|
const housingMaterial = resources.own(
|
||||||
@@ -246,7 +267,7 @@ export function createSign(
|
|||||||
faceMaterial,
|
faceMaterial,
|
||||||
);
|
);
|
||||||
face.name = 'sign-face';
|
face.name = 'sign-face';
|
||||||
face.position.z = 0.54;
|
face.position.z = FACE_Z;
|
||||||
face.userData.textureUpdateCount = 0;
|
face.userData.textureUpdateCount = 0;
|
||||||
face.userData.fontReadyWhenDrawn = false;
|
face.userData.fontReadyWhenDrawn = false;
|
||||||
group.add(face);
|
group.add(face);
|
||||||
@@ -303,41 +324,6 @@ export function createSign(
|
|||||||
}
|
}
|
||||||
group.add(backRails);
|
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();
|
const fasteners = new THREE.Group();
|
||||||
fasteners.name = 'sign-fasteners';
|
fasteners.name = 'sign-fasteners';
|
||||||
const fastenerGeometry = resources.own(
|
const fastenerGeometry = resources.own(
|
||||||
@@ -354,20 +340,6 @@ export function createSign(
|
|||||||
}
|
}
|
||||||
group.add(fasteners);
|
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(
|
const sweepMaterial = resources.own(
|
||||||
'material',
|
'material',
|
||||||
new THREE.ShaderMaterial({
|
new THREE.ShaderMaterial({
|
||||||
@@ -406,36 +378,28 @@ export function createSign(
|
|||||||
let viewportWidth = 1440;
|
let viewportWidth = 1440;
|
||||||
let viewportHeight = 900;
|
let viewportHeight = 900;
|
||||||
let previousNow = 0;
|
let previousNow = 0;
|
||||||
let screenX = 0.5;
|
let screenX = 720;
|
||||||
let screenY = 120;
|
let screenY = 36;
|
||||||
let screenWidth = 340;
|
let screenWidth = 256;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let roadsideMix = 0;
|
let tiltX = 0;
|
||||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
let tiltY = 0;
|
||||||
let dwellDrift = 0;
|
let state: GantryState = 'idle';
|
||||||
let departureDrift = 0;
|
|
||||||
const physical = roadsideSignMetrics(900);
|
|
||||||
const point = new THREE.Vector3();
|
const point = new THREE.Vector3();
|
||||||
const ray = new THREE.Vector3();
|
const ray = new THREE.Vector3();
|
||||||
const forward = new THREE.Vector3();
|
const forward = new THREE.Vector3();
|
||||||
const worldRight = new THREE.Vector3();
|
const worldRight = new THREE.Vector3();
|
||||||
const worldLeft = new THREE.Vector3();
|
const worldLeft = new THREE.Vector3();
|
||||||
const corner = 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 = [
|
const corners = [
|
||||||
[-4, -1],
|
[-4, -1],
|
||||||
[4, -1],
|
[4, -1],
|
||||||
[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(
|
function worldPoint(
|
||||||
out: typeof point,
|
out: typeof point,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -506,13 +470,10 @@ export function createSign(
|
|||||||
if (!sign) {
|
if (!sign) {
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
initialized = false;
|
initialized = false;
|
||||||
previousPhase = null;
|
state = 'idle';
|
||||||
dwellDrift = 0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target !== sign.targetId) {
|
if (target !== sign.targetId) {
|
||||||
previousPhase = null;
|
|
||||||
dwellDrift = 0;
|
|
||||||
target = sign.targetId;
|
target = sign.targetId;
|
||||||
face.userData.textureUpdateCount += 1;
|
face.userData.textureUpdateCount += 1;
|
||||||
face.userData.fontReadyWhenDrawn =
|
face.userData.fontReadyWhenDrawn =
|
||||||
@@ -529,15 +490,17 @@ export function createSign(
|
|||||||
initialized = false;
|
initialized = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
compose(
|
dockState() {
|
||||||
camera,
|
return state;
|
||||||
width,
|
},
|
||||||
height,
|
compose(camera, width, height, dock, now, tilt = REST_TILT) {
|
||||||
navigationBottom,
|
|
||||||
now,
|
|
||||||
carAnchor = { x: 0, z: 0.72 },
|
|
||||||
) {
|
|
||||||
if (!snapshot) return;
|
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;
|
viewportWidth = width;
|
||||||
viewportHeight = height;
|
viewportHeight = height;
|
||||||
camera.updateMatrixWorld(true);
|
camera.updateMatrixWorld(true);
|
||||||
@@ -549,110 +512,79 @@ export function createSign(
|
|||||||
const route = signRouteFor(snapshot.targetId);
|
const route = signRouteFor(snapshot.targetId);
|
||||||
activeCorridor = route.corridor ?? 'right';
|
activeCorridor = route.corridor ?? 'right';
|
||||||
const side = activeCorridor.includes('left') ? -1 : 1;
|
const side = activeCorridor.includes('left') ? -1 : 1;
|
||||||
const compact = width < 900;
|
// The face fills the gantry window; its housing tucks behind the beam.
|
||||||
const shortSide = height < 560 && width >= 768;
|
const targetX = dock.left + dock.width / 2;
|
||||||
const lowerStrip = height < 560 && !shortSide;
|
const targetY = dock.top + dock.height / 2;
|
||||||
const targetWidth = Math.min(
|
const targetWidth = Math.max(40, Math.min(dock.width, dock.height * 4));
|
||||||
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;
|
|
||||||
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
|
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
|
||||||
previousNow = now;
|
previousNow = now;
|
||||||
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
|
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;
|
screenX += (targetX - screenX) * mix;
|
||||||
screenY += (targetY - screenY) * mix;
|
screenY += (targetY - screenY) * mix;
|
||||||
screenWidth += (targetWidth - screenWidth) * mix;
|
screenWidth += (targetWidth - screenWidth) * mix;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
const p = smoothstep(clamp01(snapshot.progress));
|
const p = smoothstep(clamp01(snapshot.progress));
|
||||||
let arrival =
|
const arrival =
|
||||||
snapshot.phase === 'anticipate'
|
snapshot.phase === 'anticipate'
|
||||||
? 0
|
? 0
|
||||||
: snapshot.phase === 'approach'
|
: snapshot.phase === 'approach'
|
||||||
? p
|
? p
|
||||||
: 1;
|
: 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 depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
|
||||||
let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
|
let cx = THREE.MathUtils.lerp(width * (0.5 + side * 0.07), screenX, lateral);
|
||||||
let cy = THREE.MathUtils.lerp(
|
let cy = THREE.MathUtils.lerp(height * 0.44, screenY, arrival);
|
||||||
height * 0.43,
|
let projectedWidth =
|
||||||
screenY,
|
screenWidth * THREE.MathUtils.lerp(0.14, 1, arrival * arrival);
|
||||||
lowerStrip ? arrival * arrival * arrival : arrival,
|
let pitch = 0;
|
||||||
);
|
|
||||||
let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
|
|
||||||
if (snapshot.phase === 'depart') {
|
if (snapshot.phase === 'depart') {
|
||||||
depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
|
// Passing under the gantry: up and out of the top of the frame,
|
||||||
cx += side * p * width * 0.3;
|
// seen more and more from below.
|
||||||
projectedWidth *= 1 + p * 0.3;
|
depth = THREE.MathUtils.lerp(closeDepth, 9, p);
|
||||||
}
|
cy = screenY - p * p * (screenY + dock.height * 1.6);
|
||||||
if (snapshot.phase !== 'depart') {
|
projectedWidth *= 1 + p * 0.45;
|
||||||
projectedWidth = Math.min(width - 44, projectedWidth);
|
pitch = -p * 0.85;
|
||||||
cx = THREE.MathUtils.clamp(
|
|
||||||
cx,
|
|
||||||
projectedWidth / 2 + 14,
|
|
||||||
width - projectedWidth / 2 - 14,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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(point, cx, cy, depth, camera);
|
||||||
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
||||||
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
||||||
if (lowerStrip) point.y = Math.max(1.4, point.y);
|
const scale = worldRight.distanceTo(worldLeft) / 8;
|
||||||
// A wide-screen sign is a real roadside object: fixed lateral offset,
|
// The face sits FACE_Z in front of the group origin: set the origin
|
||||||
// fixed mast height, constant size, and travel only along the road axis.
|
// back by that much so the face itself lands on the target plane.
|
||||||
// Camera movement changes its projection, never its world-space anchor.
|
point.addScaledVector(forward, FACE_Z * scale);
|
||||||
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);
|
|
||||||
group.position.set(point.x, point.y, point.z);
|
group.position.set(point.x, point.y, point.z);
|
||||||
group.scale.setScalar(
|
group.scale.setScalar(scale);
|
||||||
THREE.MathUtils.lerp(
|
// Parallel to the image plane, so the docked face fits the window
|
||||||
worldRight.distanceTo(worldLeft) / 8.64,
|
// exactly; hover turns it toward the pointer.
|
||||||
physical.scale,
|
group.quaternion.copy(camera.quaternion);
|
||||||
roadsideMix,
|
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * 0.3));
|
||||||
),
|
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, pitch - tiltY * 0.24));
|
||||||
);
|
|
||||||
group.rotation.y = THREE.MathUtils.lerp(
|
|
||||||
camera.rotation.y - side * 0.065,
|
|
||||||
-side * 0.1,
|
|
||||||
roadsideMix,
|
|
||||||
);
|
|
||||||
plantMounting();
|
|
||||||
const opacity = clamp01(snapshot.opacity);
|
const opacity = clamp01(snapshot.opacity);
|
||||||
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
|
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
|
||||||
// The frame catches light first; one moving highlight resolves into a quiet face.
|
// The frame catches light first; one moving highlight resolves into a quiet face.
|
||||||
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
|
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.uSweep.value = arrival * 1.5 - 0.2;
|
||||||
sweepMaterial.uniforms.uOpacity.value =
|
sweepMaterial.uniforms.uOpacity.value =
|
||||||
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
|
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
|
||||||
group.visible = opacity > 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];
|
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(
|
function chamferedRect(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -735,8 +661,9 @@ function chamferedRect(
|
|||||||
context.closePath();
|
context.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Face layout shared with the DOM plates: exit tab, "then" line, chapter
|
/** One-line gantry face, legible when docked in a 64 px window: a stacked
|
||||||
* label and an exit arrow that points toward the sign's side of the road. */
|
* exit tab, the chapter label, a lane rule and an exit arrow that points to
|
||||||
|
* the sign's side of the road. */
|
||||||
function drawLabel(
|
function drawLabel(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
route: SignRouteSpec,
|
route: SignRouteSpec,
|
||||||
@@ -749,59 +676,50 @@ function drawLabel(
|
|||||||
context.scale(width / 2_048, height / 512);
|
context.scale(width / 2_048, height / 512);
|
||||||
context.imageSmoothingEnabled = true;
|
context.imageSmoothingEnabled = true;
|
||||||
context.imageSmoothingQuality = 'high';
|
context.imageSmoothingQuality = 'high';
|
||||||
context.textAlign = 'left';
|
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
|
|
||||||
// Exit tab.
|
// Stacked exit tab.
|
||||||
context.font = '720 66px "Geist Mono Variable", ui-monospace, monospace';
|
|
||||||
const tabText = `EXIT ${route.marker}`;
|
|
||||||
const tabWidth = context.measureText(tabText).width + 72;
|
|
||||||
context.fillStyle = accent;
|
context.fillStyle = accent;
|
||||||
chamferedRect(context, 96, 50, tabWidth, 116, 18);
|
chamferedRect(context, 72, 72, 300, 368, 40);
|
||||||
context.fill();
|
context.fill();
|
||||||
context.fillStyle = '#160b22';
|
context.fillStyle = '#160b22';
|
||||||
context.fillText(tabText, 132, 110);
|
context.textAlign = 'center';
|
||||||
|
context.font = '700 64px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
// "Then" line: the next exit on the route.
|
context.fillText('EXIT', 222, 150);
|
||||||
const next = nextRouteLabel(route.id);
|
context.font = '760 200px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
context.font = SIGN_MARKER_FONT;
|
context.fillText(route.marker, 222, 316);
|
||||||
context.fillStyle = '#d8c9ea';
|
|
||||||
context.fillText(
|
|
||||||
next === null ? 'END OF ROUTE' : `THEN \u2192 ${next.toUpperCase()}`,
|
|
||||||
96 + tabWidth + 40,
|
|
||||||
110,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Chapter label.
|
// Chapter label.
|
||||||
|
context.textAlign = 'left';
|
||||||
context.fillStyle = '#fff2df';
|
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.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.
|
// Lane rule: solid under the label, dashed toward the arrow.
|
||||||
context.globalAlpha = 0.7;
|
context.globalAlpha = 0.75;
|
||||||
context.fillStyle = accent;
|
context.fillStyle = accent;
|
||||||
context.fillRect(96, 448, 1_380, 6);
|
context.fillRect(432, 410, 1_080, 10);
|
||||||
context.globalAlpha = 0.35;
|
context.globalAlpha = 0.4;
|
||||||
for (let x = 1_500; x < 1_940; x += 64) context.fillRect(x, 448, 34, 6);
|
for (let x = 1_540; x < 1_960; x += 64) context.fillRect(x, 410, 34, 10);
|
||||||
context.globalAlpha = 1;
|
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';
|
const leansLeft = route.corridor === 'left' || route.corridor === 'outer-left';
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(1_800, 270);
|
context.translate(1_820, 236);
|
||||||
if (leansLeft) context.scale(-1, 1);
|
if (leansLeft) context.scale(-1, 1);
|
||||||
context.rotate(-Math.PI / 4);
|
context.rotate(-Math.PI / 4);
|
||||||
context.strokeStyle = accent;
|
context.strokeStyle = accent;
|
||||||
context.lineWidth = 22;
|
context.lineWidth = 30;
|
||||||
context.lineCap = 'square';
|
context.lineCap = 'square';
|
||||||
context.lineJoin = 'miter';
|
context.lineJoin = 'miter';
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(-110, 0);
|
context.moveTo(-120, 0);
|
||||||
context.lineTo(100, 0);
|
context.lineTo(110, 0);
|
||||||
context.moveTo(30, -70);
|
context.moveTo(30, -80);
|
||||||
context.lineTo(100, 0);
|
context.lineTo(110, 0);
|
||||||
context.lineTo(30, 70);
|
context.lineTo(30, 80);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
context.restore();
|
context.restore();
|
||||||
context.restore();
|
context.restore();
|
||||||
@@ -812,11 +730,11 @@ function fittedLabelSize(
|
|||||||
label: string,
|
label: string,
|
||||||
maximumWidth: number,
|
maximumWidth: number,
|
||||||
): 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`;
|
context.font = `650 ${size}px "Geist Variable", sans-serif`;
|
||||||
if (context.measureText(label).width <= maximumWidth) return size;
|
if (context.measureText(label).width <= maximumWidth) return size;
|
||||||
}
|
}
|
||||||
return 112;
|
return 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTextureLimit(value: number | undefined): number {
|
function normalizeTextureLimit(value: number | undefined): number {
|
||||||
|
|||||||
517
src/styles/gantry.css
Normal file
517
src/styles/gantry.css
Normal 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; }
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
import type { SignSnapshot } from '@/journey/types';
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
import { SceneResourceBag } from '@/scene/resources';
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
import { createChaseCamera } from '@/scene/world/environment';
|
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 {
|
interface SignMesh {
|
||||||
position: { z: number };
|
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 };
|
const profile: SignSnapshot = { targetId: 'profile', phase: 'dwell', progress: 1, opacity: 1 };
|
||||||
|
|
||||||
// Follow the live sequence: update journey state, then compose with the camera,
|
// 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 = {}) {
|
function setup(width = 1440, height = 900, options: SignRenderOptions = {}) {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
owned.push(resources);
|
owned.push(resources);
|
||||||
const sign = createSign(resources, options);
|
const sign = createSign(resources, options);
|
||||||
const camera = createChaseCamera(width / height);
|
const camera = createChaseCamera(width / height);
|
||||||
let now = 0;
|
let now = 0;
|
||||||
let navigationBottom = 68;
|
let dock = windowFor(width);
|
||||||
|
let tilt: SignTilt = { x: 0, y: 0 };
|
||||||
sign.setViewportSize(width, height);
|
sign.setViewportSize(width, height);
|
||||||
return {
|
return {
|
||||||
resources, sign, camera,
|
resources, sign, camera,
|
||||||
|
get dock() { return dock; },
|
||||||
render(snapshot: SignSnapshot | null, frames = 1) {
|
render(snapshot: SignSnapshot | null, frames = 1) {
|
||||||
for (let frame = 0; frame < frames; frame++) {
|
for (let frame = 0; frame < frames; frame++) {
|
||||||
now += 1000 / 60;
|
now += 1000 / 60;
|
||||||
sign.update(snapshot, now);
|
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);
|
return sign.inspect(camera, width, height);
|
||||||
},
|
},
|
||||||
@@ -50,17 +58,18 @@ function setup(width = 1440, height = 900, options: SignRenderOptions = {}) {
|
|||||||
camera.aspect = width / height;
|
camera.aspect = width / height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
sign.setViewportSize(width, height);
|
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) {
|
function expectDocked(bounds: SignPresentation['bounds'], dock: SignDock, tolerance = 2) {
|
||||||
expect(bounds.left).toBeGreaterThanOrEqual(8);
|
expect(Math.abs(bounds.left - dock.left)).toBeLessThan(tolerance);
|
||||||
expect(bounds.right).toBeLessThanOrEqual(width - 8);
|
expect(Math.abs(bounds.right - (dock.left + dock.width))).toBeLessThan(tolerance);
|
||||||
expect(bounds.top).toBeGreaterThanOrEqual(navigationBottom);
|
expect(Math.abs(bounds.top - dock.top)).toBeLessThan(tolerance);
|
||||||
expect(bounds.bottom).toBeLessThanOrEqual(height - 8);
|
expect(Math.abs(bounds.bottom - (dock.top + dock.height))).toBeLessThan(tolerance);
|
||||||
expect(bounds.width).toBeGreaterThan(80);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('composed route signs', () => {
|
describe('composed route signs', () => {
|
||||||
@@ -127,90 +136,82 @@ describe('composed route signs', () => {
|
|||||||
expect(h.resources.snapshot()).toEqual(allocation);
|
expect(h.resources.snapshot()).toEqual(allocation);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eases between roadside and compact layouts over successive rendered frames', () => {
|
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();
|
const h = setup(width, height);
|
||||||
h.render(profile, 120);
|
const docked = h.render(profile, 120)!;
|
||||||
const origin = h.sign.group.position.clone();
|
expectDocked(docked.bounds, h.dock);
|
||||||
h.resize(844, 390);
|
expect(h.sign.group.userData.anchoring).toBe('gantry');
|
||||||
h.render(profile);
|
expect(h.sign.dockState()).toBe('docked');
|
||||||
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('moves the compact shelf below a taller navigator without a one-frame teleport', () => {
|
it('follows a moved window over successive frames without a one-frame teleport', () => {
|
||||||
const h = setup(390, 844);
|
const h = setup();
|
||||||
const original = h.render(profile, 60)!.bounds.top;
|
const original = h.render(profile, 60)!.bounds.left;
|
||||||
h.setNavigationBottom(180);
|
h.setDock({ ...h.dock, left: h.dock.left + 300 });
|
||||||
const first = h.render({ ...profile, navigationArrival: true })!.bounds.top;
|
const first = h.render(profile)!.bounds.left;
|
||||||
const settled = h.render({ ...profile, navigationArrival: true }, 120)!.bounds;
|
const settled = h.render(profile, 120)!.bounds;
|
||||||
expect(first).toBeGreaterThan(original);
|
expect(first).toBeGreaterThan(original);
|
||||||
expect(first - original).toBeLessThan((settled.top - original) / 2);
|
expect(first - original).toBeLessThan((settled.left - original) / 2);
|
||||||
expectInViewport(settled, 390, 844, 180);
|
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 h = setup();
|
||||||
const dwell = h.render(profile, 120)!;
|
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
|
||||||
const position = h.sign.group.position.clone();
|
const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds;
|
||||||
const scale = h.sign.group.scale.x;
|
const late = h.render({ ...profile, phase: 'approach', progress: 0.7, opacity: 1 })!.bounds;
|
||||||
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5 })!;
|
const docked = h.render(profile)!.bounds;
|
||||||
expect(h.sign.group.position.x).toBeCloseTo(position.x, 6);
|
expect(h.sign.dockState()).toBe('docked');
|
||||||
expect(h.sign.group.position.y).toBeCloseTo(position.y, 6);
|
expect(early.width).toBeLessThan(late.width);
|
||||||
expect(h.sign.group.position.z).toBeGreaterThan(position.z);
|
expect(late.width).toBeLessThan(docked.width + 1);
|
||||||
expect(h.sign.group.scale.x).toBeCloseTo(scale, 6);
|
expect(early.top).toBeGreaterThan(late.top);
|
||||||
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width);
|
expect(late.top).toBeGreaterThan(docked.top - 1);
|
||||||
const passingZ = h.sign.group.position.z;
|
expect(early.top).toBeGreaterThan(900 * 0.3);
|
||||||
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);
|
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);
|
h.render(null);
|
||||||
expect(h.sign.group.visible).toBe(false);
|
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) => {
|
it('turns toward the pointer only while docked, and settles back', () => {
|
||||||
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', () => {
|
|
||||||
const h = setup();
|
const h = setup();
|
||||||
h.render(profile, 120);
|
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(null);
|
||||||
h.render({ ...profile, targetId: 'experience' }, 120);
|
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
|
||||||
expect(h.sign.group.position.x).toBeGreaterThan(11.6);
|
const approaching = group.quaternion.clone();
|
||||||
expect(h.sign.group.userData.anchoring).toBe('roadside');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user