fix: polish pass for traffic, nav, icons, contact email reveal and dashcam robustness

This commit is contained in:
2026-10-11 15:11:44 +03:00
parent ca12cfda44
commit 1326cee886
49 changed files with 809 additions and 226 deletions

View File

@@ -95,3 +95,10 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Verdict: SHIP with fixes** (Fable, hardware GL 1440x900, wheel-driven drive, frames at eleven section-local points, reverse drive, 1920x1080, 1024x700, 390x844 low tier, reduced motion, perf run): the builder's yard sat on a fixed x -28 parallel to the road, so it converged into the vanishing point under the reading panel and was mostly visible only at the chapter entry (steel also darkened late, timing unwatched). Fixes made during review: (1) **the rails are now a radial siding**: the yard group pivots on the chapter lens and its -z axis is turned by `railYaw(aspect)` so the rail line projects to NDC -0.86 (the middle of the 15% left margin) once the chapter's lens shift is applied, for any viewport aspect; every stop, plate and the crane therefore sit at the same screen x inside the margin at 1440x900 and 1920x1080 and the crane rides *up* the margin, growing, from the far stop to the near one; (2) **the stacks step down toward the camera** (4, 3, 2, 1 containers, far to near) so no stack hides the plate or the crane behind it, and the spreader lands on whichever stack it is over; (3) a raised concrete apron (quay at y 5, 12 m deep, accent bay lines) under the yard so the rolling terrain swell no longer buries the far stacks; (4) span 18 -> 12 m so the near crane fits the 216 px margin with the lens settled; (5) floods x 0.42 -> 0.5 and 9 -> 13 m lamp glows, the road pool dropped (two apron pools remain). Observed: the plates light exactly at the stops forward and back, pink on mauve-grey steel with the night palette (nothing blown: structure stays under the 0.85 bloom threshold, only the lamp glows bloom, softly); the entry is under the EXIT 06 deck with the crane screen-doored and the yard already standing (01); the crane fades before the EXIT 07 deck and never crosses a deck, sign, billboard, relay mast or milestone (the yard is 40-95 m off the road); reverse drive runs the crane back and dims plates in the same places; rAF p50 16.7 / p95 16.8 ms, 0 frames over 34 ms through the whole chapter; no console errors or warnings in any run; reduced motion renders the static poster with nothing; 390x844 (low tier) gets the lite yard, which the full-width panel hides except for pink slivers at the left edge. - **Verdict: SHIP with fixes** (Fable, hardware GL 1440x900, wheel-driven drive, frames at eleven section-local points, reverse drive, 1920x1080, 1024x700, 390x844 low tier, reduced motion, perf run): the builder's yard sat on a fixed x -28 parallel to the road, so it converged into the vanishing point under the reading panel and was mostly visible only at the chapter entry (steel also darkened late, timing unwatched). Fixes made during review: (1) **the rails are now a radial siding**: the yard group pivots on the chapter lens and its -z axis is turned by `railYaw(aspect)` so the rail line projects to NDC -0.86 (the middle of the 15% left margin) once the chapter's lens shift is applied, for any viewport aspect; every stop, plate and the crane therefore sit at the same screen x inside the margin at 1440x900 and 1920x1080 and the crane rides *up* the margin, growing, from the far stop to the near one; (2) **the stacks step down toward the camera** (4, 3, 2, 1 containers, far to near) so no stack hides the plate or the crane behind it, and the spreader lands on whichever stack it is over; (3) a raised concrete apron (quay at y 5, 12 m deep, accent bay lines) under the yard so the rolling terrain swell no longer buries the far stacks; (4) span 18 -> 12 m so the near crane fits the 216 px margin with the lens settled; (5) floods x 0.42 -> 0.5 and 9 -> 13 m lamp glows, the road pool dropped (two apron pools remain). Observed: the plates light exactly at the stops forward and back, pink on mauve-grey steel with the night palette (nothing blown: structure stays under the 0.85 bloom threshold, only the lamp glows bloom, softly); the entry is under the EXIT 06 deck with the crane screen-doored and the yard already standing (01); the crane fades before the EXIT 07 deck and never crosses a deck, sign, billboard, relay mast or milestone (the yard is 40-95 m off the road); reverse drive runs the crane back and dims plates in the same places; rAF p50 16.7 / p95 16.8 ms, 0 frames over 34 ms through the whole chapter; no console errors or warnings in any run; reduced motion renders the static poster with nothing; 390x844 (low tier) gets the lite yard, which the full-width panel hides except for pink slivers at the left edge.
- **Known weaknesses:** the lens eases into the chapter over ~1 s (camera time constant 1.05 s), so for the first second of the chapter the whole yard slides left into the margin with everything else in the world (the first stop at 0.1 can be reached before it settles on a fast scroll, when the crane's right leg sits under the panel edge); at 1024x700 the compact-rail panel is 90% wide with a 5% margin (51 px), so the yard is legible only at the chapter entry and in the gaps between cards (05), not in the margin; the 4-tier far stack is seen at a 30 degree yaw and reads as leaning at a glance; the floods light the stacks as a flat uniform term (no shadows); both work lamps share one strike; the crane's exit run (-80 -> -62 m) is short, so the last 20% of the chapter is a crane standing over the near stack while the yard fades; the yard's screen placement is tuned to the chapter composition constants, so a composition change needs RAIL_NDC rechecked. - **Known weaknesses:** the lens eases into the chapter over ~1 s (camera time constant 1.05 s), so for the first second of the chapter the whole yard slides left into the margin with everything else in the world (the first stop at 0.1 can be reached before it settles on a fast scroll, when the crane's right leg sits under the panel edge); at 1024x700 the compact-rail panel is 90% wide with a 5% margin (51 px), so the yard is legible only at the chapter entry and in the gaps between cards (05), not in the margin; the 4-tier far stack is seen at a 30 degree yaw and reads as leaning at a glance; the floods light the stacks as a flat uniform term (no shadows); both work lamps share one strike; the crane's exit run (-80 -> -62 m) is short, so the last 20% of the chapter is a crane standing over the near stack while the yard fades; the yard's screen placement is tuned to the chapter composition constants, so a composition change needs RAIL_NDC rechecked.
- Screenshots: docs/overnight/round11/ (01 entry under the EXIT 06 deck, crane screen-doored; 02 second stop with the panel up, pink plate in the margin; 03 last stop, the near one-container stack with the work lamps; 04 1920x1080 third stop; 05 1024x700 chapter entry; 06 390x844 low tier; 07 contact sheet of the left margin across the drive, entry to Toolkit). - Screenshots: docs/overnight/round11/ (01 entry under the EXIT 06 deck, crane screen-doored; 02 second stop with the panel up, pink plate in the margin; 03 last stop, the near one-container stack with the work lamps; 04 1920x1080 third stop; 05 1024x700 chapter entry; 06 390x844 low tier; 07 contact sheet of the left margin across the drive, entry to Toolkit).
## Polish pass (owner feedback)
- **What:** traffic as real vehicles (dark low-poly cars and box trucks, paired tail lamps, a centre brake lamp, amber clearance lamps on trucks, fog-coloured sheen, one pool per vehicle with its own tint); shoulder reflectors became delineator plates on the guardrail posts, and the amber road studs were removed; the owner's name left the navbar (it stays in the hero); icon buttons centred (the play/eq tile inherited `justify-content: space-between`); the "Email me" preview is a flex item after the button, so it clears it at any width; DASHCAM opens in two phases (the page folds only once the frame has mounted), retries its chunk once, abandons after 4 s, always has an enabled Close, recovers from an unanswered capture, closes on scene loss, motion withdrawal or a resize below 768, no click-to-capture on the empty frame, and keeps focus inside the dialog on such a click.
- **Weaknesses addressed:** postcard at native resolution (largest 16:9 crop up to 2560 wide) and one chapter vocabulary (EXIT nn); dashcam percent quantised like the tab (5%); reduced-motion SceneCanvas writes html[data-dawn] only on change; SHIFT OVER now turns over on the HUD sub-line at full day; the reduced-motion dawn masks the poster's sunset sky for a blue-zenith, gold-horizon daybreak; mile-marker plates stay in the chapter accent (below the tonemap shoulder) and do not flash while swept past by a navigator jump or a fling (nav-click peak frame mean 91-101 -> 58-62, no rising step over 12); html[data-underpass] waits for the shade to hold 120 ms; lite billboard keeps both floods on (phone board brighter).
- **Budgets:** initial JS 115.43 KB gz (from 119.08: IconGlyph draws the six Phosphor glyphs from their regular-weight paths instead of importing the package components, which ship all six weights each), CSS 26.93 KB gz (+0.24), scene chunk 222.95 KB gz (+1.6), DashcamFrame 3.74 KB gz. 925 tests (79 files), violations [].
- **Left:** relay lamp cores (a saturated violet cannot pass the luminance bloom threshold, so a violet core loses its glow); mast girth at 1024/phones (bounded by the panel-margin layout test); cold-GPU first frame; underpass pier vs billboard edge; yard lens slide and far-stack lean. Headless hardware-GL Chromium loses its GPU process when a project video starts (SharedImage BGRA failure), also at ca12cfd; not an app fault.
- Screenshots: docs/overnight/polish/ (WEBP).

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -414,12 +414,13 @@ onUnmounted(() => removePageListeners());
/> />
<component <component
:is="dashcam.frame.value" :is="dashcam.frame.value"
v-if="dashcam.open.value && dashcam.frame.value" v-if="dashcam.engaged.value && dashcam.frame.value"
:journey="journey" :journey="journey"
:section-label="activeSectionLabel" :section-label="activeSectionLabel"
:name="content.identity.name" :name="content.identity.name"
:city="content.identity.location.city" :city="content.identity.location.city"
:scene-ready="sceneState === 'running'" :scene-ready="sceneState === 'running'"
@ready="dashcam.ready"
@close="dashcam.close" @close="dashcam.close"
/> />
</template> </template>

View File

@@ -35,7 +35,7 @@ const props = defineProps<{
city: string; city: string;
sceneReady: boolean; sceneReady: boolean;
}>(); }>();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: []; ready: [] }>();
/** REC dot sampling rate while open. */ /** REC dot sampling rate while open. */
const PULSE_HZ = 12; const PULSE_HZ = 12;
@@ -43,15 +43,15 @@ const PULSE_HZ = 12;
const CAPTURE_TIMEOUT_MS = 1_200; const CAPTURE_TIMEOUT_MS = 1_200;
const SHUTTER_MS = 120; const SHUTTER_MS = 120;
const surface = ref<HTMLElement | null>(null);
const saveButton = ref<HTMLButtonElement | null>(null); const saveButton = ref<HTMLButtonElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null); const closeButton = ref<HTMLButtonElement | null>(null);
const dot = ref<HTMLElement | null>(null); const dot = ref<HTMLElement | null>(null);
const now = shallowRef(new Date()); const now = shallowRef(new Date());
const busy = shallowRef(false); const busy = shallowRef(false);
const lost = shallowRef(false);
const status = shallowRef(''); const status = shallowRef('');
const unavailable = computed(() => lost.value || !props.sceneReady); /* The shell closes the frame when the scene leaves `running`; this only
covers the frames in between. A capture timeout is not a lost scene. */
const unavailable = computed(() => !props.sceneReady);
const telemetry = computed<DashcamTelemetry>(() => ({ const telemetry = computed<DashcamTelemetry>(() => ({
sectionId: props.journey.activeSectionId, sectionId: props.journey.activeSectionId,
@@ -85,8 +85,8 @@ const finish = (): void => {
window.clearTimeout(captureTimer); window.clearTimeout(captureTimer);
busy.value = false; busy.value = false;
// The button was disabled while busy, which drops focus to the body. // The button was disabled while busy, which drops focus to the body.
const lost = document.activeElement === null || document.activeElement === document.body; const focusDropped = document.activeElement === null || document.activeElement === document.body;
if (lost) void nextTick(() => saveButton.value?.focus()); if (focusDropped) void nextTick(() => saveButton.value?.focus());
}; };
/* The page stays where it was: a scrollbar drag (the only input not /* The page stays where it was: a scrollbar drag (the only input not
@@ -113,18 +113,15 @@ const shoot = (): void => {
finish(); finish();
}); });
}); });
// Context loss mid-capture: the scene never answers; drop the request. // The scene did not answer (a stalled or lost frame): drop the request and
// let the visitor try again; the button comes straight back.
captureTimer = window.setTimeout(() => { captureTimer = window.setTimeout(() => {
cancelDashcamCapture(); cancelDashcamCapture();
lost.value = true; status.value = 'Postcard not taken, try again';
finish(); finish();
}, CAPTURE_TIMEOUT_MS); }, CAPTURE_TIMEOUT_MS);
}; };
const onSurfaceClick = (event: MouseEvent): void => {
if (event.target === surface.value) shoot();
};
const onPointerMove = (event: PointerEvent): void => { const onPointerMove = (event: PointerEvent): void => {
if (event.pointerType !== 'mouse' || !readDashcam().drift) return; if (event.pointerType !== 'mouse' || !readDashcam().drift) return;
setDashcamPointer( setDashcamPointer(
@@ -157,7 +154,6 @@ const CAPTURE = { capture: true } as const;
const ACTIVE = { capture: true, passive: false } as const; const ACTIVE = { capture: true, passive: false } as const;
watch(title, (value) => { document.title = value; }); watch(title, (value) => { document.title = value; });
watch(() => props.sceneReady, (ready) => { if (ready) lost.value = false; });
onMounted(() => { onMounted(() => {
previousTitle = document.title; previousTitle = document.title;
@@ -170,7 +166,8 @@ onMounted(() => {
window.addEventListener('wheel', suppress, ACTIVE); window.addEventListener('wheel', suppress, ACTIVE);
window.addEventListener('touchmove', suppress, ACTIVE); window.addEventListener('touchmove', suppress, ACTIVE);
window.addEventListener('keydown', onKeydown, CAPTURE); window.addEventListener('keydown', onKeydown, CAPTURE);
saveButton.value?.focus(); (saveButton.value?.disabled ? closeButton.value : saveButton.value)?.focus();
emit('ready');
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -190,13 +187,12 @@ onBeforeUnmount(() => {
<template> <template>
<div <div
ref="surface"
class="dashcam-frame" class="dashcam-frame"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Dashcam" aria-label="Dashcam"
tabindex="-1"
:data-unavailable="unavailable ? '' : undefined" :data-unavailable="unavailable ? '' : undefined"
@click="onSurfaceClick"
> >
<span class="dashcam-frame__bar" data-edge="top" aria-hidden="true"></span> <span class="dashcam-frame__bar" data-edge="top" aria-hidden="true"></span>
<span class="dashcam-frame__bar" data-edge="bottom" aria-hidden="true"></span> <span class="dashcam-frame__bar" data-edge="bottom" aria-hidden="true"></span>

View File

@@ -1,14 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, type FunctionalComponent } from 'vue';
import {
PhPause,
PhPlay,
PhSlidersHorizontal,
PhSpeakerHigh,
PhSpeakerX,
PhWaveform,
} from '@phosphor-icons/vue';
export type IconGlyphName = 'camera' | 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume'; export type IconGlyphName = 'camera' | 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -18,33 +8,34 @@ const props = withDefaults(defineProps<{
size: 20, size: 20,
}); });
// Phosphor's camera (regular weight) redrawn as one stroked path: the package /*
// component ships all six weights' paths into the initial chunk. * Regular-weight paths from @phosphor-icons/vue 2.2.1 (MIT, recorded in
const CameraGlyph: FunctionalComponent<{ size?: number }> = ({ size }) => h('svg', { * docs/third-party-notices.md), filled. The package components ship all six
viewBox: '0 0 256 256', width: size, height: size, fill: 'none', stroke: 'currentColor', 'stroke-width': 16, 'stroke-linejoin': 'round', * weights of every glyph into the initial chunk; one path each is all the
}, [h('path', { d: 'M48 64h32l16-24h64l16 24h32a16 16 0 0 1 16 16v112a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V80a16 16 0 0 1 16-16ZM128 96a36 36 0 1 0 0 72a36 36 0 1 0 0-72Z' })]); * controls draw.
CameraGlyph.props = ['size']; */
const FILLED: Readonly<Record<IconGlyphName, string>> = {
const icons = { camera: 'M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z',
camera: CameraGlyph, pause: 'M200,32H160a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm0,176H160V48h40ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Zm0,176H56V48H96Z',
pause: PhPause, play: 'M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z',
play: PhPlay, settings: 'M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H199a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h97a32,32,0,0,0,62,0h17a8,8,0,0,0,0-16Zm-48,24a16,16,0,1,1,16-16A16,16,0,0,1,168,192Z',
settings: PhSlidersHorizontal, sound: 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm54-106.08a40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.58,24,24,0,0,0,0-31.72,8,8,0,0,1,12-10.58ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z',
sound: PhSpeakerHigh, 'sound-muted': 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm101.66-61.3a8,8,0,0,1-11.32,11.32L216,139.31l-18.34,18.35a8,8,0,0,1-11.32-11.32L204.69,128l-18.35-18.34a8,8,0,0,1,11.32-11.32L216,116.69l18.34-18.35a8,8,0,0,1,11.32,11.32L227.31,128Z',
'sound-muted': PhSpeakerX, volume: 'M56,96v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0ZM88,24a8,8,0,0,0-8,8V224a8,8,0,0,0,16,0V32A8,8,0,0,0,88,24Zm40,32a8,8,0,0,0-8,8V192a8,8,0,0,0,16,0V64A8,8,0,0,0,128,56Zm40,32a8,8,0,0,0-8,8v64a8,8,0,0,0,16,0V96A8,8,0,0,0,168,88Zm40-16a8,8,0,0,0-8,8v96a8,8,0,0,0,16,0V80A8,8,0,0,0,208,72Z',
volume: PhWaveform, };
} as const;
const icon = computed(() => icons[props.name]);
</script> </script>
<template> <template>
<component <svg
:is="icon"
class="icon-glyph" class="icon-glyph"
:size="size" xmlns="http://www.w3.org/2000/svg"
weight="regular" viewBox="0 0 256 256"
:width="props.size"
:height="props.size"
aria-hidden="true" aria-hidden="true"
focusable="false" focusable="false"
/> fill="currentColor"
>
<path :d="FILLED[props.name]" />
</svg>
</template> </template>

View File

@@ -53,7 +53,11 @@ onMounted(() => { clientReady.value = true; });
watch( watch(
() => [clientReady.value && !showCanvas.value, props.journey.arrived, sceneState.value] as const, () => [clientReady.value && !showCanvas.value, props.journey.arrived, sceneState.value] as const,
([cssOnly, arrived]) => { ([cssOnly, arrived]) => {
if (cssOnly) document.documentElement.dataset.dawn = arrived ? 'day' : 'night'; // The journey snapshot is replaced on every scroll frame, so this runs
// per frame: write only on a change (a write is a mutation the living
// tab observes).
const next = arrived ? 'day' : 'night';
if (cssOnly && document.documentElement.dataset.dawn !== next) document.documentElement.dataset.dawn = next;
}, },
{ flush: 'post' }, { flush: 'post' },
); );

View File

@@ -6,12 +6,27 @@ const LIVE_STAGE = ".reading-stage[data-reading-stage='live']";
const RISE_MS = 400; const RISE_MS = 400;
/** Phones: no shutter in the gantry bay (dashcam.css), and no `c` key either. */ /** Phones: no shutter in the gantry bay (dashcam.css), and no `c` key either. */
const NARROW = '(max-width: 767px)'; const NARROW = '(max-width: 767px)';
/** One retry for a failed frame chunk, then the open is abandoned. */
export const DASHCAM_RETRY_MS = 350;
/** A frame that has not mounted in this long is abandoned (nothing changed). */
export const DASHCAM_LOAD_TIMEOUT_MS = 4_000;
type FrameModule = { default: Component };
const importFrame = (): Promise<FrameModule> => import('@/components/DashcamFrame.vue');
/** /**
* DASHCAM, DOM side (initial chunk, kept minimal): availability, the `c` key, * DASHCAM, DOM side (initial chunk, kept minimal): availability, the `c` key,
* html[data-dashcam] and the lazy frame. The frame itself (telemetry, focus * html[data-dashcam] and the lazy frame. The frame itself (telemetry, focus
* trap, capture, the shutter) loads on first open or button hover. Starts * trap, capture, the shutter) loads on first open or button hover. Starts
* after mount, so the server HTML carries none of it. * after mount, so the server HTML carries none of it.
*
* Opening is two-phase so the page can never fold away without a frame on
* top of it: show() asks for the frame (rendered once the chunk is in), and
* only the frame's `ready` (its mount) folds the page, closes the lens and
* hands it the title. A chunk that fails twice or never mounts abandons the
* open with nothing changed. Anything that would leave the frame stranded
* (motion withdrawn, the scene dropping out of `running`, a resize to phone
* width) closes it.
*/ */
export function useDashcam(sources: { export function useDashcam(sources: {
/** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */ /** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */
@@ -19,31 +34,70 @@ export function useDashcam(sources: {
sceneRunning: Readonly<Ref<boolean>>; sceneRunning: Readonly<Ref<boolean>>;
/** Fine pointer on high/medium: the lens drifts and the bars ease. */ /** Fine pointer on high/medium: the lens drifts and the bars ease. */
drift: () => boolean; drift: () => boolean;
/** Test seam: the frame chunk loader. */
load?: () => Promise<FrameModule>;
}) { }) {
const load = sources.load ?? importFrame;
/** The page is folded and the frame is mounted. */
const open = shallowRef(false); const open = shallowRef(false);
/** The frame should render (requested and loaded); `open` follows on its mount. */
const engaged = shallowRef(false);
const frame = shallowRef<Component | null>(null); const frame = shallowRef<Component | null>(null);
const available = computed(() => sources.allowed.value && sources.sceneRunning.value); const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
let loading: Promise<void> | null = null; let loading: Promise<boolean> | null = null;
let requested = false;
let riseTimer = 0; let riseTimer = 0;
let loadTimer = 0;
const root = (): HTMLElement => document.documentElement; const root = (): HTMLElement => document.documentElement;
const narrow = (): boolean => window.matchMedia?.(NARROW).matches ?? false;
const prefetch = (): Promise<void> => (loading ??= import('@/components/DashcamFrame.vue').then( const attempt = (): Promise<boolean> => load().then(
(module) => { frame.value = markRaw(module.default); }, (module) => { frame.value = markRaw(module.default); return true; },
() => { loading = null; }, () => false,
)); );
const prefetch = (): Promise<boolean> => {
if (frame.value) return Promise.resolve(true);
return (loading ??= attempt()
.then((ok) => ok || new Promise<boolean>((resolve) => { window.setTimeout(() => { void attempt().then(resolve); }, DASHCAM_RETRY_MS); }))
.then((ok) => { if (!ok) loading = null; return ok; }));
};
/** Drops a pending or mounting open; nothing on the page has changed yet. */
const abandon = (): void => {
requested = false;
engaged.value = false;
window.clearTimeout(loadTimer);
};
const show = (): void => { const show = (): void => {
if (open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return; if (requested || open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
if (window.matchMedia?.(NARROW).matches) return; if (narrow()) return;
requested = true;
window.clearTimeout(loadTimer);
loadTimer = window.setTimeout(() => { if (!open.value) abandon(); }, DASHCAM_LOAD_TIMEOUT_MS);
void prefetch().then((ok) => {
if (!requested) return;
if (!ok || !available.value || narrow()) abandon();
else engaged.value = true;
});
};
/** The frame has mounted: fold the page and close the lens. */
const ready = (): void => {
if (!requested || open.value) return;
window.clearTimeout(loadTimer);
window.clearTimeout(riseTimer); window.clearTimeout(riseTimer);
open.value = true; open.value = true;
setDashcamOpen(true, sources.drift()); setDashcamOpen(true, sources.drift());
root().dataset.dashcam = 'on'; root().dataset.dashcam = 'on';
void prefetch();
}; };
const close = (): void => { const close = (): void => {
if (!open.value) return; if (!open.value) {
if (requested) abandon();
return;
}
abandon();
open.value = false; open.value = false;
setDashcamOpen(false, false); setDashcamOpen(false, false);
root().dataset.dashcam = 'rise'; root().dataset.dashcam = 'rise';
@@ -53,25 +107,31 @@ export function useDashcam(sources: {
const onKeydown = (event: KeyboardEvent): void => { const onKeydown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return; if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return;
if (open.value && event.key === 'Escape') { const active = open.value || requested;
if (active && event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
close(); close();
} else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') { } else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') {
const wasOpen = open.value; if (active) close();
if (wasOpen) close();
else show(); else show();
if (wasOpen || open.value) event.preventDefault(); if (active || requested) event.preventDefault();
} }
}; };
const onResize = (): void => { if ((open.value || requested) && narrow()) close(); };
watch(sources.allowed, (allowed) => { if (!allowed) close(); }); watch(available, (ok) => { if (!ok) close(); });
onMounted(() => window.addEventListener('keydown', onKeydown)); onMounted(() => {
window.addEventListener('keydown', onKeydown);
window.addEventListener('resize', onResize, { passive: true });
});
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onKeydown); window.removeEventListener('keydown', onKeydown);
window.removeEventListener('resize', onResize);
window.clearTimeout(riseTimer); window.clearTimeout(riseTimer);
window.clearTimeout(loadTimer);
if (open.value) setDashcamOpen(false, false); if (open.value) setDashcamOpen(false, false);
delete root().dataset.dashcam; delete root().dataset.dashcam;
}); });
return { open, frame, available, show, close, prefetch }; return { open, engaged, frame, available, show, ready, close, prefetch };
} }

View File

@@ -1,5 +1,6 @@
import { bucharestClock } from '@/lib/operations'; import { bucharestClock } from '@/lib/operations';
import { maintenanceClock } from '@/journey/maintenance-progress'; import { maintenanceClock } from '@/journey/maintenance-progress';
import { quantiseRoute } from '@/journey/tab-feed';
/** /**
* DASHCAM telemetry and the postcard (lazy dashcam chunk only). Every value * DASHCAM telemetry and the postcard (lazy dashcam chunk only). Every value
@@ -27,10 +28,9 @@ export const DASHCAM_COORDINATES = "N44°26' E26°06'";
export const POSTCARD_WIDTH = 1600; export const POSTCARD_WIDTH = 1600;
export const POSTCARD_HEIGHT = 900; export const POSTCARD_HEIGHT = 900;
export const routePercent = (progress: number): number => /** Whole percent on the tab title's 5% step, so the frame, the dashcam's
Math.round(Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)) * 100); * title and the route title never disagree (47% vs 45%). */
export const routePercent = (progress: number): number => Math.round(quantiseRoute(progress) * 100);
export const chapterLabel = (chapter: string): string => chapter.replaceAll('-', ' ').toUpperCase();
/** Live Bucharest time; inside Infrastructure the maintenance window's clock. */ /** Live Bucharest time; inside Infrastructure the maintenance window's clock. */
export function dashcamClock(sectionId: string, maintenance: number | null, now: Date): string { export function dashcamClock(sectionId: string, maintenance: number | null, now: Date): string {
@@ -52,8 +52,9 @@ export function postcardFilename(sectionId: string, clock: string): string {
export const postcardCredit = (name: string, city: string): string => export const postcardCredit = (name: string, city: string): string =>
`NIGHT SHIFT · ${name.toUpperCase()} · ${city.toUpperCase()}`; `NIGHT SHIFT · ${name.toUpperCase()} · ${city.toUpperCase()}`;
export const frameTopLeft = (t: DashcamTelemetry): string => /* One vocabulary in the frame: the navigation's exit and chapter name (the
`DASHCAM · ${chapterLabel(t.chapter)} · EXIT ${t.exit}`; scene's internal chapter ids, e.g. "pre-dawn", are not shown). */
export const frameTopLeft = (t: DashcamTelemetry): string => `DASHCAM · EXIT ${t.exit}`;
export const frameBottomLeft = (t: DashcamTelemetry): string => export const frameBottomLeft = (t: DashcamTelemetry): string =>
`ROUTE ${t.routePercent}% · ${t.sectionLabel.toUpperCase()}`; `ROUTE ${t.routePercent}% · ${t.sectionLabel.toUpperCase()}`;
@@ -66,6 +67,20 @@ export function coverCrop(sw: number, sh: number, dw: number, dh: number): { sx:
return { sx: (sw - cropW) / 2, sy: (sh - cropH) / 2, sw: cropW, sh: cropH }; return { sx: (sw - cropW) / 2, sy: (sh - cropH) / 2, sw: cropW, sh: cropH };
} }
/** Widest postcard written: a 2x buffer at 1440 is downsampled to this. */
export const POSTCARD_MAX_WIDTH = 2560;
/**
* The postcard's size for a sw x sh drawing buffer: the largest 16:9 crop of
* the buffer at its own pixels, never upscaled (a 1440 x 900 buffer stretched
* to 1600 read soft), downsampled only beyond POSTCARD_MAX_WIDTH.
*/
export function postcardSize(sw: number, sh: number): { w: number; h: number } {
if (!(sw > 0 && sh > 0)) return { w: POSTCARD_WIDTH, h: POSTCARD_HEIGHT };
const w = Math.min(POSTCARD_MAX_WIDTH, sw, (sh * 16) / 9);
return { w: Math.round(w), h: Math.round((w * 9) / 16) };
}
/** Frame chrome geometry for a w x h postcard (proportional to 1600 x 900). */ /** Frame chrome geometry for a w x h postcard (proportional to 1600 x 900). */
export function postcardLayout(w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT) { export function postcardLayout(w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT) {
const unit = Math.min(w / POSTCARD_WIDTH, h / POSTCARD_HEIGHT); const unit = Math.min(w / POSTCARD_WIDTH, h / POSTCARD_HEIGHT);
@@ -180,11 +195,13 @@ export function savePostcard(source: Source, t: DashcamTelemetry): Promise<Postc
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
try { try {
canvas = document.createElement('canvas'); canvas = document.createElement('canvas');
canvas.width = POSTCARD_WIDTH; const { w, h } = postcardSize(source.width, source.height);
canvas.height = POSTCARD_HEIGHT; canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (ctx === null) return Promise.resolve('failed'); if (ctx === null) return Promise.resolve('failed');
drawPostcard(ctx as unknown as PostcardContext, source, t); ctx.imageSmoothingQuality = 'high';
drawPostcard(ctx as unknown as PostcardContext, source, t, w, h);
} catch { } catch {
return Promise.resolve('failed'); return Promise.resolve('failed');
} }

View File

@@ -351,7 +351,7 @@ export interface BillboardWorld {
} }
export interface BillboardOptions { export interface BillboardOptions {
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): one static flood. */ /** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): both floods steady. */
mode: 'full' | 'lite'; mode: 'full' | 'lite';
textureSize: number; textureSize: number;
io?: BillboardIO; io?: BillboardIO;
@@ -531,8 +531,9 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
const p = frame.p ?? (presence === 1 && frame.section === 'infrastructure' ? 0 : null); const p = frame.p ?? (presence === 1 && frame.section === 'infrastructure' ? 0 : null);
progress = billboardProgress(standing ? p : null, feed.stops, feed.projects.map((project) => project.id), frame.direction); progress = billboardProgress(standing ? p : null, feed.stops, feed.projects.map((project) => project.id), frame.direction);
// Floods: off while rising; lite keeps one steady lamp. // Floods: off while rising; lite keeps both lamps steady (one lamp left
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, 0]; // the right half of the face unlit, and phone captures read dim).
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, standing ? 1 : 0];
floods[0] = toward(floods[0], left, dt / BILLBOARD_TIMING.flood); floods[0] = toward(floods[0], left, dt / BILLBOARD_TIMING.flood);
floods[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood); floods[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood);

View File

@@ -149,7 +149,7 @@ export async function createProceduralSceneGeneration(
const sun = createSun(context.resources); const sun = createSun(context.resources);
const horizonAir = createHorizonAir(context.resources); const horizonAir = createHorizonAir(context.resources);
const skyline = createSkyline(context.resources); const skyline = createSkyline(context.resources);
const traffic = createTraffic(context.resources, quality); const traffic = createTraffic(context.resources, quality, scene.fog.color);
// Phones and the low tier get the decimated car; it is about 200 px wide // Phones and the low tier get the decimated car; it is about 200 px wide
// there and seen from behind. // there and seen from behind.
const liteCar = const liteCar =
@@ -214,6 +214,7 @@ export async function createProceduralSceneGeneration(
let underpassShade = 0; let underpassShade = 0;
let underpassKey = ''; let underpassKey = '';
let underpassAttr = false; let underpassAttr = false;
let underpassSince = -1;
scene.add( scene.add(
underpass.group, underpass.group,
relay.group, relay.group,
@@ -786,7 +787,13 @@ export async function createProceduralSceneGeneration(
}, },
); );
underpassShade = passage.shade; underpassShade = passage.shade;
const under = shadeDepth(passage.shade) > 0.2; // The HUD tint follows the shade, but only once it has held for
// UNDERPASS_ATTR_HOLD_MS: an approach walk can graze the threshold for a
// frame or three before the real pass. Leaving is immediate.
const shaded = shadeDepth(passage.shade) > 0.2;
if (!shaded) underpassSince = -1;
else if (underpassSince < 0) underpassSince = now;
const under = shaded && now - underpassSince >= UNDERPASS_ATTR_HOLD_MS;
if (under !== underpassAttr) { if (under !== underpassAttr) {
underpassAttr = under; underpassAttr = under;
if (under) document.documentElement.dataset.underpass = 'under'; if (under) document.documentElement.dataset.underpass = 'under';
@@ -934,6 +941,8 @@ export async function createProceduralSceneGeneration(
}; };
} }
/** html[data-underpass] waits for the shade to hold this long (ms). */
const UNDERPASS_ATTR_HOLD_MS = 120;
const INTRO_SECONDS = 4.2; const INTRO_SECONDS = 4.2;
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */ /** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
const OUTRO_SECONDS = 7.0; const OUTRO_SECONDS = 7.0;

View File

@@ -36,7 +36,7 @@ export function createExpressway(
gl_Position = projectionMatrix * view; gl_Position = projectionMatrix * view;
} }
`; `;
function material(luminous: boolean, coral = false) { function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660]) {
return resources.own( return resources.own(
'material', 'material',
new THREE.ShaderMaterial({ new THREE.ShaderMaterial({
@@ -49,7 +49,7 @@ export function createExpressway(
varying vec3 vWorld; varying vec3 vWorld;
varying float vDepth; varying float vDepth;
void main() { void main() {
float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth); float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
float nearFade = smoothstep(0.0, 9.0, vDepth); float nearFade = smoothstep(0.0, 9.0, vDepth);
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x)); vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
vec3 coral = vec3(1.0, 0.18, 0.095); vec3 coral = vec3(1.0, 0.18, 0.095);
@@ -71,7 +71,9 @@ export function createExpressway(
} }
const graphite = material(false); const graphite = material(false);
const cyan = material(true); const cyan = material(true);
const coral = material(true, true); // Delineator reflectors fade out by ~240 m: beyond that a 0.4 m plate is a
// sub-pixel speck, and the specks read as random orange dots (owner).
const coral = material(true, true, [110, 240]);
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1)); const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const dummy = new THREE.Object3D(); const dummy = new THREE.Object3D();
type Part = { type Part = {
@@ -113,12 +115,14 @@ export function createExpressway(
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const z = 30 - (i * WORLD_DEPTH) / pairs; const z = 30 - (i * WORLD_DEPTH) / pairs;
posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 }); posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
// A tall reflector plate on the post's face: guard-rail delineators,
// the post and its plate one readable unit.
reflectors.push({ reflectors.push({
x: side * 9.48, x: side * 9.5,
y: 1.1, y: 1.02,
z: z + 0.18, z: z + 0.17,
sx: 0.14, sx: 0.16,
sy: 0.22, sy: 0.4,
sz: 0.03, sz: 0.03,
}); });
rails.push({ rails.push({

View File

@@ -44,6 +44,14 @@ const PLATE_YAW = 0.3;
const FLASH_SECONDS = 0.3; const FLASH_SECONDS = 0.3;
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */ /** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */
const FLASH_GAP_SECONDS = 0.6; const FLASH_GAP_SECONDS = 0.6;
/** Smoothed section-local progress per second above which a crossing does not
* flash: a navigator jump or a fling sweeps several posts past in well under
* a second, and their flashes stacked into one bright bloom frame on arrival.
* A wheel tick lands in one frame, so the rate is smoothed: it rises over
* ~80 ms (a sweep is caught within three frames) and falls over ~400 ms. */
export const MILESTONE_FLASH_MAX_RATE = 0.8;
const RATE_RISE_SECONDS = 0.08;
const RATE_FALL_SECONDS = 0.4;
const PULSE_GAIN = 0.08; const PULSE_GAIN = 0.08;
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */ /** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
const PLATE_GLOW = 0.42; const PLATE_GLOW = 0.42;
@@ -198,10 +206,14 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
} else if (vPart < 1.5) { } else if (vPart < 1.5) {
// Lit retro-reflective plate: brightest facing the camera, and in the beam. // Lit retro-reflective plate: brightest facing the camera, and in the beam.
// Tinted by the chapter accent so it never reads as one more street lamp. // Tinted by the chapter accent so it never reads as one more street lamp.
color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing); // Kept below the tonemapper's shoulder, so the plate stays the
color += uAccent * 0.35; // chapter colour instead of clipping to white; the beam and the
${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''} // flash lift it in the accent's own hue.
color += vec3(1.0, 0.96, 0.9) * flash * 1.4; vec3 sheet = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.82);
color = sheet * (${PLATE_GLOW.toFixed(2)} + 0.1 * key + 0.32 * facing * facing);
color += uAccent * 0.18;
${full ? 'color += sheet * headlightBeam(vWorld) * (0.3 + 0.8 * facing);' : ''}
color += mix(uAccent, vec3(1.0, 0.96, 0.9), 0.35) * flash * 0.9;
} else { } else {
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8); color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
} }
@@ -267,6 +279,8 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY); const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
const side = new Float32Array(INSTANCES); const side = new Float32Array(INSTANCES);
let lastSection: SectionId | null = null; let lastSection: SectionId | null = null;
let lastProgress: number | null = null;
let rate = 0;
const matrix = new THREE.Matrix4(); const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion(); const rotation = new THREE.Quaternion();
const position = new THREE.Vector3(); const position = new THREE.Vector3();
@@ -281,12 +295,17 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
const posts = milestoneLayout(section, p); const posts = milestoneLayout(section, p);
const changed = section !== lastSection; const changed = section !== lastSection;
lastSection = section; lastSection = section;
const dt = Math.max(deltaSeconds, 1 / 240);
const instant = p !== null && lastProgress !== null && !changed ? Math.abs(p - lastProgress) / dt : 0;
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
lastProgress = p;
let any = false; let any = false;
posts.forEach((post, i) => { posts.forEach((post, i) => {
// The flash fires when the plate crosses the line, either way. // The flash fires when the plate crosses the line, either way.
const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section])); const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section]));
sinceFlash[i]! += deltaSeconds; sinceFlash[i]! += deltaSeconds;
if (full && !changed && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) { if (full && !changed && !sweeping && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
flash[i] = 1; flash[i] = 1;
sinceFlash[i] = 0; sinceFlash[i] = 0;
} else { } else {

View File

@@ -176,9 +176,8 @@ export function createRoad(
color += vec3(0.11, 0.065, 0.17) * shoulderRibs; color += vec3(0.11, 0.065, 0.17) * shoulderRibs;
float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0)); float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0));
color *= 1.0 - joint * 0.5; color *= 1.0 - joint * 0.5;
float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53))) // (Amber edge studs removed in the polish round: sub-pixel beyond
* (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0))); // ~60 m, they aliased into random orange specks along the edge.)
color += uLaneColor * studs * 0.7;
// Rear lamp footprint: grows towards the camera from the actual rear axle. // Rear lamp footprint: grows towards the camera from the actual rear axle.
float behindCar = smoothstep(7.0, 8.8, vWorldZ); float behindCar = smoothstep(7.0, 8.8, vWorldZ);

View File

@@ -3,30 +3,54 @@ import type { SceneResourceBag } from '@/scene/resources';
import type { RenderQualitySettings } from '@/scene/render-quality'; import type { RenderQualitySettings } from '@/scene/render-quality';
/** /**
* Other cars on the expressway, seen only as their lamps: taillight pairs, * Other vehicles on the night shift: dark low-poly cars and box trucks with
* long-exposure trails whose length follows the speed difference, and red * readable rear lamps (a tail-light pair, a high centre brake light on cars,
* amber clearance lamps on trucks), their roofs catching the horizon glow,
* their rear faces our headlights, plus short long-exposure trails and red
* pools on the wet asphalt. Scrolling faster than the traffic overtakes it; * pools on the wet asphalt. Scrolling faster than the traffic overtakes it;
* resting lets the faster lanes pull away. Instances wrap through a hidden band * resting lets the faster lanes pull away. Instances wrap through a hidden
* behind the camera, so nothing pops in view. * band behind the camera, so nothing pops in view.
*
* Owner feedback (polish round): bodiless lamp pairs read as random red dots
* on the road. Each lane now runs at one speed, so vehicles in a lane never
* drive through each other, and adjacent lanes never overlap.
*/ */
/** /**
* The hero car renders at 2.78x scale: its body spans |x| < 2.6 and its nose * The hero car renders at 2.78x scale: its body spans |x| < 2.6 and its nose
* reaches z = -5.5. Traffic never enters that envelope or the near field * reaches z = -5.5. Traffic never enters that envelope or the near field
* around it. Lamps fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z and * around it. Vehicles fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z
* wrap inside the hidden band, so no light ever slides past the car (round 5: * and wrap inside the hidden band, so nothing ever slides past the car.
* bodiless lamp pairs passing the flank read as the car's own taillights
* coming loose).
*/ */
export const HERO_CAR_HALF_WIDTH = 2.6; export const HERO_CAR_HALF_WIDTH = 2.6;
export const TRAFFIC_NEAR_Z = -24; export const TRAFFIC_NEAR_Z = -24;
export const TRAFFIC_FADE_LENGTH = 22; export const TRAFFIC_FADE_LENGTH = 22;
const FAR_Z = -660; const FAR_Z = -660;
const SPAN = TRAFFIC_NEAR_Z - FAR_Z; const SPAN = TRAFFIC_NEAR_Z - FAR_Z;
/** Lane centres. Fast lanes on the left, slower trucks on the right. */ /** Max lateral jitter of a vehicle inside its lane. */
export const TRAFFIC_LANES = [-5.7, -5.7, -4.3, 5.4, 5.7] as const; export const TRAFFIC_LANE_JITTER = 0.1;
export const TRAFFIC_LAMP_HALF_SPACING = { fast: 0.9, slow: 1.0 } as const;
/** Visibility of a lamp, trail or pool at world z (1 = fully lit). */ export type TrafficKind = 'car' | 'truck';
export interface TrafficLane {
x: number;
/** World units per second; one speed per lane, so no one drives through anyone. */
speed: number;
kind: TrafficKind;
}
/** Fast cars on the left, slower cars and box trucks on the right. */
export const TRAFFIC_LANES: readonly TrafficLane[] = [
{ x: -6.5, speed: 46, kind: 'car' },
{ x: 4.0, speed: 16, kind: 'car' },
{ x: -4.0, speed: 33, kind: 'car' },
{ x: 6.6, speed: 11, kind: 'truck' },
];
/** Body half widths and lengths (metres, world scale). */
export const TRAFFIC_BODY = {
car: { halfWidth: 1.1, length: 4.8 },
truck: { halfWidth: 1.25, length: 15 },
} as const;
/** Visibility of a lamp, trail, pool or body at world z (1 = fully lit). */
export function trafficNearFade(z: number): number { export function trafficNearFade(z: number): number {
const t = Math.min(1, Math.max(0, (TRAFFIC_NEAR_Z - z) / TRAFFIC_FADE_LENGTH)); const t = Math.min(1, Math.max(0, (TRAFFIC_NEAR_Z - z) / TRAFFIC_FADE_LENGTH));
return t * t * (3 - 2 * t); return t * t * (3 - 2 * t);
@@ -40,44 +64,114 @@ export function wrapTrafficZ(z: number): number {
return z; return z;
} }
interface Lamp {
x: number;
y: number;
z: number;
w: number;
h: number;
/** 0..0.6 red to red-orange, 2 amber. */
level: number;
}
/** Rear lamps in body space (origin at the rear face, ground level). */
export function trafficLamps(kind: TrafficKind, tint: number): Lamp[] {
const red = tint * 0.6;
if (kind === 'car')
return [
{ x: -0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
{ x: 0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
// Centre high-mounted brake light on the rear window's top edge.
{ x: 0, y: 1.55, z: -0.9, w: 0.62, h: 0.12, level: red },
];
return [
{ x: -0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red },
{ x: 0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red },
// Amber clearance lamps at the top corners give the box its outline.
{ x: -1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 },
{ x: 1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 },
];
}
const LAMPS_PER_VEHICLE = 4;
/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in body space. */
type Box = readonly [number, number, number, number, number, number];
const CAR_BOXES: readonly Box[] = [
[-1.1, 1.1, 0.34, 1.1, -4.8, 0], // body
[-0.92, 0.92, 1.1, 1.6, -3.5, -0.95], // cabin (the rear face is the window)
[-1.08, -0.72, 0, 0.6, -1.25, -0.45], // wheels
[0.72, 1.08, 0, 0.6, -1.25, -0.45],
[-1.08, -0.72, 0, 0.6, -4.25, -3.45],
[0.72, 1.08, 0, 0.6, -4.25, -3.45],
];
const TRUCK_BOXES: readonly Box[] = [
[-1.25, 1.25, 1.05, 4.05, -12.4, 0], // box body
[-1.18, 1.18, 0.55, 3.3, -15, -12.6], // cab
[-1.1, 1.1, 0.42, 0.56, -0.12, 0.02], // under-run bar
[-1.2, -0.7, 0, 1.0, -3.5, -1.5], // rear tandem
[0.7, 1.2, 0, 1.0, -3.5, -1.5],
[-1.2, -0.7, 0, 1.0, -14.4, -13.2], // front axle
[0.7, 1.2, 0, 1.0, -14.4, -13.2],
];
function boxGeometry(boxes: readonly Box[]) {
const positions: number[] = [];
const normals: number[] = [];
for (const [x0, x1, y0, y1, z0, z1] of boxes) {
const part = new THREE.BoxGeometry(x1 - x0, y1 - y0, z1 - z0).toNonIndexed();
part.translate((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
positions.push(...(part.getAttribute('position').array as Float32Array));
normals.push(...(part.getAttribute('normal').array as Float32Array));
part.dispose();
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
return geometry;
}
interface Vehicle { interface Vehicle {
lane: TrafficLane;
x: number; x: number;
z: number; z: number;
speed: number;
width: number;
height: number;
tint: number; tint: number;
} }
export function createTraffic( export function createTraffic(
resources: SceneResourceBag, resources: SceneResourceBag,
quality: RenderQualitySettings, quality: RenderQualitySettings,
/** The horizon fog colour: roofs and upper edges catch it as sheen. */
sheen: unknown = new THREE.Color(0x8a3d7a),
) { ) {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'expressway-traffic'; group.name = 'expressway-traffic';
const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7; const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7;
const lanes = TRAFFIC_LANES;
const vehicles: Vehicle[] = []; const vehicles: Vehicle[] = [];
let seed = 7; let seed = 7;
const random = () => { const random = () => {
seed = (seed * 16807) % 2147483647; seed = (seed * 16807) % 2147483647;
return (seed - 1) / 2147483646; return (seed - 1) / 2147483646;
}; };
const lanes = TRAFFIC_LANES.length;
const perLane = Array.from({ length: lanes }, (_, lane) => Math.ceil((count - lane) / lanes));
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const lane = lanes[i % lanes.length]!; const laneIndex = i % lanes;
// Left lanes run faster than the cruise, the right lane is slower trucks. const lane = TRAFFIC_LANES[laneIndex]!;
const fast = lane < 0; // Evenly spread through the span with jitter smaller than the gap, so
// vehicles in one lane (one speed) keep their spacing forever.
const slot = Math.floor(i / lanes);
const gap = SPAN / perLane[laneIndex]!;
const jitter = (random() - 0.5) * Math.max(0, gap - TRAFFIC_BODY[lane.kind].length - 30);
vehicles.push({ vehicles.push({
x: lane + (random() - 0.5) * 0.5, lane,
z: FAR_Z + 20 + random() * (SPAN - 40), x: lane.x + (random() - 0.5) * 2 * TRAFFIC_LANE_JITTER,
speed: fast ? 26 + random() * 22 : 9 + random() * 6, z: wrapTrafficZ(FAR_Z + gap * (slot + 0.5) + laneIndex * 37 + jitter),
width: fast ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow,
height: fast ? 0.95 : 1.55 + random() * 0.4,
tint: random(), tint: random(),
}); });
} }
const uniforms = { uTime: { value: 0 } }; const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen } };
const nearFade = /* glsl */ `smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0))`;
const vertexShader = /* glsl */ ` const vertexShader = /* glsl */ `
attribute float aLevel; attribute float aLevel;
varying vec2 vUv; varying vec2 vUv;
@@ -89,7 +183,7 @@ export function createTraffic(
vLevel = aLevel; vLevel = aLevel;
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
// Matches trafficNearFade(): dark before the hero car's near field. // Matches trafficNearFade(): dark before the hero car's near field.
vNear = smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0)); vNear = ${nearFade};
vec4 view = viewMatrix * world; vec4 view = viewMatrix * world;
vDepth = -view.z; vDepth = -view.z;
gl_Position = projectionMatrix * view; gl_Position = projectionMatrix * view;
@@ -103,7 +197,8 @@ export function createTraffic(
float far = 1.0 - smoothstep(420.0, 660.0, vDepth); float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
float near = vNear; float near = vNear;
${shape} ${shape}
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), vLevel); vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0));
color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel));
gl_FragColor = vec4(color * glow * far * near, 1.0); gl_FragColor = vec4(color * glow * far * near, 1.0);
#include <tonemapping_fragment> #include <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
@@ -122,40 +217,122 @@ export function createTraffic(
side: THREE.DoubleSide, side: THREE.DoubleSide,
}), }),
); );
// Lamps: a hot core in a soft disc, bright enough to bloom. // Lamps: a hot lamp-shaped core in a soft disc, bright enough to bloom.
const lampMaterial = additive(` const lampMaterial = additive(`
vec2 c = vUv - 0.5; vec2 c = vUv - 0.5;
float core = exp(-pow(abs(c.x) * 2.3, 6.0) - pow(abs(c.y) * 2.4, 6.0));
float r = length(c * vec2(1.0, 1.6)); float r = length(c * vec2(1.0, 1.6));
float glow = exp(-r * r * 26.0) * 3.2 + exp(-r * r * 5.0) * 0.5;`); float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
// Trails: thin, brightest at the lamp, fading over their length. // Trails: thin, brightest at the lamp, fading over their length.
const trailMaterial = additive(` const trailMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0)); float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
float glow = across * pow(vUv.y, 1.6) * 1.1;`); float glow = across * pow(vUv.y, 1.6) * 0.8;`);
// Wet-road pools: long and soft under each car. // Wet-road pools: long and soft under each vehicle.
const poolMaterial = additive(` const poolMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0)); float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
float glow = across * pow(vUv.y, 2.2) * 0.2;`); float glow = across * pow(vUv.y, 2.2) * 0.2;`);
// Bodies: near-black coachwork that reads as a silhouette against the
// horizon, with the fog glow on the roof, our headlights on the rear face,
// a glass rear window, door seams on the trucks and red lamp spill.
const bodyMaterial = (kind: TrafficKind) => resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
varying vec3 vLocal;
varying vec3 vNormal;
varying float vDepth;
varying float vNear;
void main() {
vLocal = position;
vNormal = normal;
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vNear = ${nearFade};
vec4 view = viewMatrix * world;
vDepth = -view.z;
gl_Position = projectionMatrix * view;
}`,
fragmentShader: /* glsl */ `
uniform vec3 uSheen;
varying vec3 vLocal;
varying vec3 vNormal;
varying float vDepth;
varying float vNear;
void main() {
float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear;
if (alpha < 0.02) discard;
vec3 n = normalize(vNormal);
float up = max(0.0, n.y);
float rear = max(0.0, n.z);
float side = abs(n.x);
float wheel = ${kind === 'car' ? 'step(vLocal.y, 0.6) * step(0.7, abs(vLocal.x))' : 'step(vLocal.y, 1.0) * step(0.68, abs(vLocal.x))'};
vec3 color = vec3(0.034, 0.02, 0.058);
// Roof and shoulders: the horizon glow as a satin sheen.
color += uSheen * (up * 0.42 + side * 0.07 * smoothstep(0.5, 1.4, vLocal.y));
// Our headlights on the rear face, falling off down the road.
float beam = exp(-max(0.0, vDepth - 40.0) / 90.0);
color += vec3(0.19, 0.15, 0.27) * rear * beam * 0.55;
${kind === 'car'
? `// Rear window: glass reflects a lavender band of sky.
float glass = rear * step(1.12, vLocal.y) * step(vLocal.z, -0.9);
color = mix(color, vec3(0.05, 0.05, 0.12) + uSheen * 0.22 * smoothstep(1.2, 1.55, vLocal.y), glass);
float lampBand = exp(-pow((vLocal.y - 0.92) / 0.18, 2.0));`
: `// Box doors: a centre seam and two lock bars.
float seam = 1.0 - smoothstep(0.02, 0.05, abs(vLocal.x));
float bars = 1.0 - smoothstep(0.03, 0.06, abs(abs(vLocal.x) - 0.42));
color *= 1.0 - rear * max(seam * 0.6, bars * 0.35);
float lampBand = exp(-pow((vLocal.y - 1.3) / 0.2, 2.0));`}
// The tail lamps spill red on the bumper around them.
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35;
color *= 1.0 - wheel * 0.6;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
transparent: true,
depthWrite: true,
depthTest: true,
}),
);
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1)); const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
const lampCount = count * 2; const lampCount = count * LAMPS_PER_VEHICLE;
const mesh = (name: string, material: unknown, instances: number) => { const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => {
const instanced = new THREE.InstancedMesh(plane, material, instances); const geometry = resources.own('geometry', plane.clone());
const levels = new Float32Array(instances);
for (let i = 0; i < instances; i++) levels[i] = level(i);
geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
const instanced = new THREE.InstancedMesh(geometry, material, instances);
instanced.name = name; instanced.name = name;
instanced.frustumCulled = false; instanced.frustumCulled = false;
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage); instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const levels = new Float32Array(instances);
for (let i = 0; i < instances; i++) levels[i] = vehicles[Math.floor(i / 2) % count]!.tint * 0.6;
instanced.geometry = plane.clone();
resources.own('geometry', instanced.geometry);
instanced.geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
group.add(instanced); group.add(instanced);
return instanced; return instanced;
}; };
const lamps = mesh('traffic-taillights', lampMaterial, lampCount); const lampsFor = vehicles.map((v) => trafficLamps(v.lane.kind, v.tint));
const trails = mesh('traffic-light-trails', trailMaterial, lampCount); const lamps = quad('traffic-lamps', lampMaterial, lampCount, (i) => lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]?.level ?? 0);
const pools = mesh('traffic-road-pools', poolMaterial, count); // Two trails per vehicle, from the tail-lamp pair.
const trails = quad('traffic-light-trails', trailMaterial, count * 2, (i) => vehicles[Math.floor(i / 2)]!.tint * 0.6);
// One pool per vehicle, tinted like that vehicle's own lamps.
const pools = quad('traffic-road-pools', poolMaterial, count, (i) => vehicles[i]!.tint * 0.6);
const bodies = (['car', 'truck'] as const).map((kind) => {
const members = vehicles.filter((v) => v.lane.kind === kind);
const geometry = resources.own('geometry', boxGeometry(kind === 'car' ? CAR_BOXES : TRUCK_BOXES));
const mesh = new THREE.InstancedMesh(geometry, bodyMaterial(kind), Math.max(1, members.length));
mesh.name = `traffic-${kind}-bodies`;
mesh.frustumCulled = false;
mesh.count = members.length;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// Bodies draw before the additive lamps so the lamps sit on them.
mesh.renderOrder = -1;
group.add(mesh);
return { mesh, members };
});
const dummy = new THREE.Object3D(); const dummy = new THREE.Object3D();
const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
return { return {
group, group,
/** /**
@@ -166,33 +343,49 @@ export function createTraffic(
const dt = Math.min(0.1, Math.max(0, deltaSeconds)); const dt = Math.min(0.1, Math.max(0, deltaSeconds));
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const v = vehicles[i]!; const v = vehicles[i]!;
const relative = travelSpeed - v.speed; // > 0: we are overtaking const relative = travelSpeed - v.lane.speed; // > 0: we are overtaking
v.z = wrapTrafficZ(v.z + relative * dt); v.z = wrapTrafficZ(v.z + relative * dt);
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14); const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12);
// Trails stream away from the direction of relative travel. // Trails stream away from the direction of relative travel.
const trailDirection = relative >= 0 ? -1 : 1; const trailDirection = relative >= 0 ? -1 : 1;
for (let side = 0; side < 2; side++) { const own = lampsFor[i]!;
const index = i * 2 + side; for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
const x = v.x + (side === 0 ? -v.width : v.width); const lamp = own[k];
dummy.position.set(x, v.height, v.z); if (lamp === undefined) {
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, hidden);
continue;
}
dummy.position.set(v.x + lamp.x, lamp.y, v.z + lamp.z);
dummy.rotation.set(0, 0, 0); dummy.rotation.set(0, 0, 0);
dummy.scale.set(0.62, 0.36, 1); dummy.scale.set(lamp.w, lamp.h, 1);
dummy.updateMatrix(); dummy.updateMatrix();
lamps.setMatrixAt(index, dummy.matrix); lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, dummy.matrix);
if (k > 1) continue;
// Horizontal ribbon at lamp height, uv.y = 1 at the lamp. // Horizontal ribbon at lamp height, uv.y = 1 at the lamp.
dummy.position.set(x, v.height - 0.02, v.z + (trailDirection * trailLength) / 2); dummy.position.set(v.x + lamp.x, lamp.y - 0.02, v.z + (trailDirection * trailLength) / 2);
dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0); dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0);
dummy.scale.set(0.2, trailLength, 1); dummy.scale.set(0.2, trailLength, 1);
dummy.updateMatrix(); dummy.updateMatrix();
trails.setMatrixAt(index, dummy.matrix); trails.setMatrixAt(i * 2 + k, dummy.matrix);
} }
const halfWidth = TRAFFIC_BODY[v.lane.kind].halfWidth;
const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08); const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08);
dummy.position.set(v.x, 0.04, v.z + poolLength / 2); dummy.position.set(v.x, 0.04, v.z + poolLength / 2);
dummy.rotation.set(-Math.PI / 2, 0, 0); dummy.rotation.set(-Math.PI / 2, 0, 0);
dummy.scale.set(v.width * 2.3, poolLength, 1); dummy.scale.set(halfWidth * 1.7, poolLength, 1);
dummy.updateMatrix(); dummy.updateMatrix();
pools.setMatrixAt(i, dummy.matrix); pools.setMatrixAt(i, dummy.matrix);
} }
for (const { mesh, members } of bodies) {
members.forEach((v, j) => {
dummy.position.set(v.x, 0, v.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
mesh.setMatrixAt(j, dummy.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
}
lamps.instanceMatrix.needsUpdate = true; lamps.instanceMatrix.needsUpdate = true;
trails.instanceMatrix.needsUpdate = true; trails.instanceMatrix.needsUpdate = true;
pools.instanceMatrix.needsUpdate = true; pools.instanceMatrix.needsUpdate = true;

View File

@@ -64,13 +64,15 @@ html[data-dashcam='on'] .content-focus-cue {
position: fixed; position: fixed;
z-index: calc(var(--z-popover, 50) + 10); z-index: calc(var(--z-popover, 50) + 10);
inset: 0; inset: 0;
cursor: crosshair; cursor: default;
color: var(--dash-ink); color: var(--dash-ink);
font: 500 clamp(0.68rem, 0.9vw, 0.82rem) / 1.35 var(--mono); font: 500 clamp(0.68rem, 0.9vw, 0.82rem) / 1.35 var(--mono);
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-shadow: 0 1px 3px rgb(0 0 0 / 65%); text-shadow: 0 1px 3px rgb(0 0 0 / 65%);
animation: dashcam-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: dashcam-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
} }
/* tabindex -1: a click on the empty frame keeps focus inside the dialog. */
.dashcam-frame:focus { outline: none; }
/* The bars are drawn here on every tier: they close with the lens pass's /* The bars are drawn here on every tier: they close with the lens pass's
(same 400 ms, same height) and also cover the outro's own letterbox and (same 400 ms, same height) and also cover the outro's own letterbox and
its typed credit at the destination. Transform only, so nothing lays out. */ its typed credit at the destination. Transform only, so nothing lays out. */
@@ -155,7 +157,8 @@ html[data-dashcam='on'] .content-focus-cue {
.dashcam-frame__actions button:hover:not(:disabled) { border-color: var(--focus, #ffd693); } .dashcam-frame__actions button:hover:not(:disabled) { border-color: var(--focus, #ffd693); }
.dashcam-frame__actions button:focus-visible { outline: 2px solid var(--focus, #ffd693); outline-offset: 2px; } .dashcam-frame__actions button:focus-visible { outline: 2px solid var(--focus, #ffd693); outline-offset: 2px; }
.dashcam-frame__actions button:disabled { cursor: default; opacity: 0.55; } .dashcam-frame__actions button:disabled { cursor: default; opacity: 0.55; }
/* The shutter: a 120 ms white veil. */ /* The shutter: a 120 ms white veil at half strength (one user-initiated
frame; kept well below a full-field white flash). */
html[data-dashcam-shutter] .dashcam-frame, html[data-dashcam-shutter] .dashcam-frame,
html[data-dashcam-shutter] .dashcam-frame__bar { animation: none; } html[data-dashcam-shutter] .dashcam-frame__bar { animation: none; }
html[data-dashcam-shutter] .dashcam-frame::after { html[data-dashcam-shutter] .dashcam-frame::after {
@@ -176,7 +179,7 @@ html[data-dashcam-shutter] .dashcam-frame::after {
to { transform: scaleY(1); } to { transform: scaleY(1); }
} }
@keyframes dashcam-shutter { @keyframes dashcam-shutter {
from { opacity: 0.85; } from { opacity: 0.5; }
to { opacity: 0; } to { opacity: 0; }
} }
@media (forced-colors: active) { @media (forced-colors: active) {

View File

@@ -98,24 +98,8 @@ html .site-navigation[data-enhanced='true'][data-docked='true']::before {
mask-composite: exclude, add; mask-composite: exclude, add;
} }
/* Brand plate in the left bay (decorative; the h1 carries the name). */ /* No brand plate in the left bay: the hero carries the name (owner's call:
html .site-navigation::after { a name in the nav bar read as self-promotion). */
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 ---------- */ /* ---------- Exits on the beam ---------- */
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -529,6 +513,10 @@ html .drive-control-button,
html .drive-controls__settings-trigger { html .drive-controls__settings-trigger {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
/* .drive-start was a flex pill (world.css: space-between, 1rem gap); in a
one-track grid that pushed the equaliser against the tile's left edge. */
place-content: center;
gap: 0;
inline-size: 44px; inline-size: 44px;
min-inline-size: 44px; min-inline-size: 44px;
block-size: 44px; block-size: 44px;
@@ -833,7 +821,6 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
} }
@media (forced-colors: active) { @media (forced-colors: active) {
html .site-navigation::before { background: Canvas; } html .site-navigation::before { background: Canvas; }
html .site-navigation::after { color: CanvasText; }
.gantry-window { border: 1px solid ButtonText; box-shadow: none; } .gantry-window { border: 1px solid ButtonText; box-shadow: none; }
.gantry-window::before, .gantry-window::before,
.gantry-window::after { border-color: Highlight; } .gantry-window::after { border-color: Highlight; }

View File

@@ -61,3 +61,25 @@ html[data-maintenance-step='backup'] .journey-hud__subline { color: #ffc27a; box
color 320ms cubic-bezier(0.22, 1, 0.36, 1); color 320ms cubic-bezier(0.22, 1, 0.36, 1);
} }
} }
/* END OF SHIFT: the plate's arrival word is visually hidden in the gantry
(gantry.css), so the SHIFT OVER swap happens on the sub-line instead: at
full daylight "END OF SHIFT · 05:48" turns over to SHIFT OVER, in gold. */
html[data-dawn='day'] .journey-hud[data-arrived='true'] .journey-hud__subline::before {
content: 'SHIFT OVER' / '';
position: absolute;
inset: 0;
padding: inherit;
background: #0b0918;
box-shadow: inset 0 1px rgb(255 214 147 / 40%);
color: #ffd693;
}
@media (prefers-reduced-motion: no-preference) {
html[data-dawn='day'] .journey-hud[data-arrived='true'] .journey-hud__subline::before {
animation: shift-over 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
@keyframes shift-over {
from { opacity: 0; transform: translateY(-40%); }
to { opacity: 1; transform: none; }
}

View File

@@ -59,16 +59,20 @@
z-index: 1; z-index: 1;
flex-shrink: 0; flex-shrink: 0;
} }
/* The address is a flex item after the link, so it always clears the
button's end whatever the button's width or font; it slides out from
underneath the link (z-index 1 above). */
.contact-email__preview { .contact-email__preview {
position: absolute; position: relative;
inset-inline-start: 116px; flex-shrink: 0;
margin-inline-start: 16px;
white-space: nowrap; white-space: nowrap;
font-size: 0.84rem; font-size: 0.84rem;
color: var(--text); color: var(--text);
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateX(-24px); transform: translateX(-32px);
transition: opacity 200ms ease, transform 260ms ease, visibility 260ms; transition: opacity 220ms ease, transform 280ms cubic-bezier(0.22, 1, 0.36, 1), visibility 280ms;
} }
@media (hover: hover) and (min-width: 768px) { @media (hover: hover) and (min-width: 768px) {
.contact-email__action:is(:hover, :focus-within) .contact-email__preview { .contact-email__action:is(:hover, :focus-within) .contact-email__preview {

View File

@@ -223,6 +223,11 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) #contact .section-eyebrow {
color: #3b1a33; color: #3b1a33;
text-shadow: 0 1px 12px rgb(255 226 196 / 45%); text-shadow: 0 1px 12px rgb(255 226 196 / 45%);
} }
/* Without the scene the eyebrow sits on the CSS daybreak's blue zenith. */
html:is([data-dawn='sunrise'], [data-dawn='day']):has(.journey-world[data-static-fallback='true']) #contact .section-eyebrow {
color: #ffe3c4;
text-shadow: 0 1px 8px rgb(20 16 48 / 60%);
}
/* END OF SHIFT: at full daylight the label reads SHIFT OVER (a string swap). */ /* END OF SHIFT: at full daylight the label reads SHIFT OVER (a string swap). */
html[data-dawn='day'] .journey-hud__arrival { font-size: 0; } html[data-dawn='day'] .journey-hud__arrival { font-size: 0; }
@@ -608,11 +613,19 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
} }
.journey-poster { transition: filter 1.6s ease, opacity 1.6s ease; } .journey-poster { transition: filter 1.6s ease, opacity 1.6s ease; }
/* END OF SHIFT without the scene: the world crossfades to the daybreak sky /* END OF SHIFT without the scene: the world crossfades to the daybreak sky
(indigo, dusty rose, pale amber) under the dimmed night poster. */ under the dimmed night poster.
The poster's sky and its magenta sunset sun are masked out (they read as
dusk); the daybreak sky shows above the horizon with a low gold sun, a
cool clear zenith, and the night terrain dimmed below. */
.journey-world[data-static-fallback='true'][data-arrived='true'] { .journey-world[data-static-fallback='true'][data-arrived='true'] {
background: linear-gradient(180deg, #6b4a7a 0%, #b07a8c 44%, #ffb98a 58%, #ffe1c2 61%, #1a0f14 100%); background:
radial-gradient(circle at 50% 44%, #fff6df 0 3.2%, rgb(255 222 160 / 75%) 6%, rgb(255 190 130 / 28%) 16%, transparent 34%),
linear-gradient(180deg, #2f3a6e 0%, #565a92 26%, #a77890 38%, #f2a676 42.5%, #ffe3b8 44.5%, #2a1820 52%, #1a0f14 100%);
}
.journey-world[data-static-fallback='true'][data-arrived='true'] .journey-poster {
opacity: 0.5;
mask-image: linear-gradient(180deg, transparent 0 43%, #000 54%);
} }
.journey-world[data-static-fallback='true'][data-arrived='true'] .journey-poster { opacity: 0.45; }
.journey-world[data-arrived='true'] .journey-poster { filter: brightness(0.9); } .journey-world[data-arrived='true'] .journey-poster { filter: brightness(0.9); }
.journey-world::after { .journey-world::after {
inset-block-end: 0; inset-block-end: 0;

View File

@@ -187,6 +187,35 @@ describe("SceneCanvas", () => {
wrapper.unmount(); wrapper.unmount();
}); });
it("writes html[data-dawn] without the scene only when the step changes", async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy({ allowSceneRequest: false, maximumTier: "static", reason: "system-reduce" }),
quality: "static",
sceneDescription: "A decorative night road behind the portfolio content.",
},
});
await flushPromises();
expect(document.documentElement.dataset.dawn).toBe("night");
const writes: string[] = [];
const observer = new MutationObserver((records) => {
for (const record of records) if (record.attributeName === "data-dawn") writes.push(document.documentElement.dataset.dawn ?? "");
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-dawn"] });
// A scroll replaces the snapshot every frame; the step does not change.
for (let frame = 1; frame <= 6; frame++) {
await wrapper.setProps({ journey: journey({ scrollY: frame * 40, routeProgress: 0.42 + frame / 100 }) });
await flushPromises();
}
await wrapper.setProps({ journey: journey({ scrollY: 9000, routeProgress: 1, arrived: true }) });
await flushPromises();
observer.disconnect();
expect(writes).toEqual(["day"]);
wrapper.unmount();
expect(document.documentElement.dataset.dawn).toBeUndefined();
});
it("aborts and removes the canvas on a Static transition", async () => { it("aborts and removes the canvas on a Static transition", async () => {
const wrapper = mount(SceneCanvas, { const wrapper = mount(SceneCanvas, {
props: { props: {

View File

@@ -204,8 +204,8 @@ describe('billboard world', () => {
expect(full.inspect().flickers).toBeLessThanOrEqual(Math.ceil(3 / BILLBOARD_TIMING.flickerGap)); expect(full.inspect().flickers).toBeLessThanOrEqual(Math.ceil(3 / BILLBOARD_TIMING.flickerGap));
expect(full.inspect().shimmers).toBe(1); expect(full.inspect().shimmers).toBe(1);
expect(full.inspect().zoom).toBe(1); expect(full.inspect().zoom).toBe(1);
// Lite: one steady flood, no pulse, flicker, shimmer or hover zoom. // Lite: both floods steady (the whole face lit), no pulse, flicker, shimmer or hover zoom.
expect(lite.inspect()).toMatchObject({ floods: [1, 0], flickers: 0, shimmers: 0, zoom: 0 }); expect(lite.inspect()).toMatchObject({ floods: [1, 1], flickers: 0, shimmers: 0, zoom: 0 });
}); });
it('never throws into the render loop', () => { it('never throws into the render loop', () => {

View File

@@ -1,11 +1,13 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { mount } from '@vue/test-utils'; import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue'; import { defineComponent, h, onMounted, ref, type Component } from 'vue';
import DashcamFrame from '@/components/DashcamFrame.vue';
import DriveControls from '@/components/DriveControls.vue'; import DriveControls from '@/components/DriveControls.vue';
import { useDashcam } from '@/composables/useDashcam'; import { DASHCAM_LOAD_TIMEOUT_MS, DASHCAM_RETRY_MS, useDashcam } from '@/composables/useDashcam';
import { readDashcam } from '@/journey/dashcam-feed'; import { cancelDashcamCapture, readDashcam } from '@/journey/dashcam-feed';
import type { JourneySnapshot } from '@/journey/types';
import { validateProductionInputs } from '@/content/schemas'; import { validateProductionInputs } from '@/content/schemas';
import { render } from '@/entry-server'; import { render } from '@/entry-server';
import type { MotionPolicy } from '@/journey/motion-policy'; import type { MotionPolicy } from '@/journey/motion-policy';
@@ -66,11 +68,25 @@ describe('useDashcam', () => {
let stage: HTMLElement; let stage: HTMLElement;
const allowed = ref(true); const allowed = ref(true);
const running = ref(true); const running = ref(true);
/** Stands in for DashcamFrame: announces its mount like the real one. */
const FrameStub = defineComponent({
emits: ['ready', 'close'],
setup(_props, { emit }) {
onMounted(() => emit('ready'));
return () => h('div', { class: 'dashcam-frame' }, [h('button', { class: 'dashcam-close', onClick: () => emit('close') }, 'Close')]);
},
});
let load: ReturnType<typeof vi.fn<() => Promise<{ default: Component }>>>;
const host = () => mount(defineComponent({ const host = () => mount(defineComponent({
setup() { setup() {
const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true }); const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true, load });
return () => h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show }); return () => h('div', [
h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show }),
dashcam.engaged.value && dashcam.frame.value
? h(dashcam.frame.value, { onReady: dashcam.ready, onClose: dashcam.close })
: null,
]);
}, },
}), { attachTo: document.body }); }), { attachTo: document.body });
@@ -79,11 +95,16 @@ describe('useDashcam', () => {
window.dispatchEvent(event); window.dispatchEvent(event);
return event; return event;
}; };
/** Lets the import promise settle and the frame mount. */
const settle = async (): Promise<void> => {
for (let i = 0; i < 4; i++) await flushPromises();
};
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
allowed.value = true; allowed.value = true;
running.value = true; running.value = true;
load = vi.fn(() => Promise.resolve({ default: FrameStub as Component }));
stage = document.createElement('div'); stage = document.createElement('div');
stage.className = 'reading-stage'; stage.className = 'reading-stage';
stage.dataset.readingStage = 'live'; stage.dataset.readingStage = 'live';
@@ -93,16 +114,24 @@ describe('useDashcam', () => {
afterEach(() => { afterEach(() => {
stage.remove(); stage.remove();
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals();
delete document.documentElement.dataset.dashcam; delete document.documentElement.dataset.dashcam;
}); });
it('opens and closes on c, then lifts the rise state after 400 ms', () => { it('folds the page only once the frame has mounted, and closes on c', async () => {
const wrapper = host(); const wrapper = host();
expect(press('c').defaultPrevented).toBe(true); expect(press('c').defaultPrevented).toBe(true);
// The chunk is still in flight: nothing has changed on the page yet.
expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false);
await settle();
expect(wrapper.find('.dashcam-frame').exists()).toBe(true);
expect(document.documentElement.dataset.dashcam).toBe('on'); expect(document.documentElement.dataset.dashcam).toBe('on');
expect(readDashcam()).toMatchObject({ open: true, drift: true }); expect(readDashcam()).toMatchObject({ open: true, drift: true });
expect(press('C', { shiftKey: true }).defaultPrevented).toBe(false); expect(press('C', { shiftKey: true }).defaultPrevented).toBe(false);
press('c'); press('c');
await settle();
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
expect(document.documentElement.dataset.dashcam).toBe('rise'); expect(document.documentElement.dataset.dashcam).toBe('rise');
expect(readDashcam().open).toBe(false); expect(readDashcam().open).toBe(false);
vi.advanceTimersByTime(400); vi.advanceTimersByTime(400);
@@ -110,11 +139,75 @@ describe('useDashcam', () => {
wrapper.unmount(); wrapper.unmount();
}); });
it('closes on Escape', () => { it('closes on Escape and from the frame\'s own Close control', async () => {
const wrapper = host();
press('c');
await settle();
expect(press('Escape').defaultPrevented).toBe(true);
expect(readDashcam().open).toBe(false);
vi.advanceTimersByTime(400);
await wrapper.get('.drive-controls__dashcam').trigger('click');
await settle();
await wrapper.get('.dashcam-close').trigger('click');
await settle();
expect(readDashcam().open).toBe(false);
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
wrapper.unmount();
});
it('retries a failed frame chunk once, then opens', async () => {
load.mockRejectedValueOnce(new Error('chunk'));
const wrapper = host();
press('c');
await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined();
vi.advanceTimersByTime(DASHCAM_RETRY_MS);
await settle();
expect(load).toHaveBeenCalledTimes(2);
expect(document.documentElement.dataset.dashcam).toBe('on');
wrapper.unmount();
});
it('abandons the open with nothing changed when the chunk keeps failing, and can try again', async () => {
load.mockRejectedValueOnce(new Error('chunk')).mockRejectedValueOnce(new Error('chunk'));
const wrapper = host();
press('c');
await settle();
vi.advanceTimersByTime(DASHCAM_RETRY_MS);
await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false);
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
// Not stuck: the next press loads again and opens.
press('c');
await settle();
expect(load).toHaveBeenCalledTimes(3);
expect(document.documentElement.dataset.dashcam).toBe('on');
wrapper.unmount();
});
it('a slow chunk can be cancelled, and a late arrival opens nothing', async () => {
let resolveLoad: (module: { default: Component }) => void = () => {};
load.mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve; }));
const wrapper = host(); const wrapper = host();
press('c'); press('c');
expect(press('Escape').defaultPrevented).toBe(true); expect(press('Escape').defaultPrevented).toBe(true);
expect(readDashcam().open).toBe(false); resolveLoad({ default: FrameStub as Component });
await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
wrapper.unmount();
});
it('gives up on a chunk that never arrives', async () => {
load.mockImplementation(() => new Promise(() => {}));
const wrapper = host();
press('c');
vi.advanceTimersByTime(DASHCAM_LOAD_TIMEOUT_MS);
await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined();
// c is a fresh request again, not a "close" of a phantom open.
expect(press('c').defaultPrevented).toBe(true);
wrapper.unmount(); wrapper.unmount();
}); });
@@ -135,35 +228,104 @@ describe('useDashcam', () => {
['motion is withheld', () => { allowed.value = false; }], ['motion is withheld', () => { allowed.value = false; }],
['the scene is not running', () => { running.value = false; }], ['the scene is not running', () => { running.value = false; }],
['the reading stage is not live', () => { stage.dataset.readingStage = 'still'; }], ['the reading stage is not live', () => { stage.dataset.readingStage = 'still'; }],
])('does nothing when %s', (_label, arrange) => { ])('does nothing when %s', async (_label, arrange) => {
const wrapper = host(); const wrapper = host();
arrange(); arrange();
expect(press('c').defaultPrevented).toBe(false); expect(press('c').defaultPrevented).toBe(false);
await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined(); expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false); expect(readDashcam().open).toBe(false);
wrapper.unmount(); wrapper.unmount();
}); });
it('does nothing on a phone-width viewport, where the button is hidden too', () => { it('does nothing on a phone-width viewport, where the button is hidden too', async () => {
const matchMedia = vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList); vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList));
vi.stubGlobal('matchMedia', matchMedia);
try {
const wrapper = host(); const wrapper = host();
expect(press('c').defaultPrevented).toBe(false); expect(press('c').defaultPrevented).toBe(false);
await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined(); expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false); expect(readDashcam().open).toBe(false);
wrapper.unmount(); wrapper.unmount();
} finally {
vi.unstubAllGlobals();
}
}); });
it('closes when motion is withdrawn while open', async () => { it.each([
['motion is withdrawn', () => { allowed.value = false; }],
['the scene drops back to loading', () => { running.value = false; }],
])('closes when %s while open', async (_label, arrange) => {
const wrapper = host(); const wrapper = host();
press('c'); press('c');
allowed.value = false; await settle();
await vi.runOnlyPendingTimersAsync(); expect(readDashcam().open).toBe(true);
arrange();
await settle();
expect(readDashcam().open).toBe(false); expect(readDashcam().open).toBe(false);
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
wrapper.unmount();
});
it('closes when the window is resized to phone width while open', async () => {
let narrow = false;
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: narrow && query === '(max-width: 767px)' }) as MediaQueryList));
const wrapper = host();
press('c');
await settle();
expect(readDashcam().open).toBe(true);
narrow = true;
window.dispatchEvent(new Event('resize'));
await settle();
expect(readDashcam().open).toBe(false);
expect(document.documentElement.dataset.dashcam).toBe('rise');
expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
wrapper.unmount();
});
});
describe('DashcamFrame', () => {
const journey = {
activeSectionId: 'infrastructure', chapter: 'pre-dawn', routeProgress: 0.47,
sectionActivation: { activeHeadingOffsetVh: -40, nextHeadingOffsetVh: 120 },
} as unknown as JourneySnapshot;
const mountFrame = (sceneReady = true) => mount(DashcamFrame, {
props: { journey, sectionLabel: 'Infrastructure', name: 'Luca Pîslaru', city: 'Bucharest', sceneReady },
attachTo: document.body,
});
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => {
cancelDashcamCapture();
vi.useRealTimers();
});
it('announces its mount and always renders a Close control', async () => {
const wrapper = mountFrame(false);
expect(wrapper.emitted('ready')).toHaveLength(1);
const close = wrapper.get('.dashcam-frame__actions button:last-child');
expect(close.text()).toMatch(/close/iu);
expect(close.attributes('disabled')).toBeUndefined();
expect(document.activeElement).toBe(close.element);
await close.trigger('click');
expect(wrapper.emitted('close')).toHaveLength(1);
wrapper.unmount();
});
it('recovers from a capture the scene never answers', async () => {
const wrapper = mountFrame();
const save = wrapper.get('.dashcam-frame__actions button:first-child');
await save.trigger('click');
expect(save.attributes('disabled')).toBeDefined();
vi.advanceTimersByTime(1_200);
await flushPromises();
expect(save.attributes('disabled')).toBeUndefined();
expect(wrapper.attributes('data-unavailable')).toBeUndefined();
expect(wrapper.text()).toMatch(/try again/iu);
wrapper.unmount();
});
it('takes no postcard from a click on the empty frame', async () => {
const wrapper = mountFrame();
await wrapper.trigger('click');
expect(wrapper.get('.dashcam-frame__actions button:first-child').attributes('disabled')).toBeUndefined();
expect(document.documentElement.dataset.dashcamShutter).toBeUndefined();
wrapper.unmount(); wrapper.unmount();
}); });
}); });

View File

@@ -9,7 +9,6 @@ import {
} from '@/journey/dashcam-feed'; } from '@/journey/dashcam-feed';
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens'; import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
import { import {
chapterLabel,
dashcamClock, dashcamClock,
dashcamTitle, dashcamTitle,
frameBottomLeft, frameBottomLeft,
@@ -117,25 +116,25 @@ describe('dashcam telemetry', () => {
sectionLabel: 'Infrastructure', sectionLabel: 'Infrastructure',
chapter: 'pre-dawn', chapter: 'pre-dawn',
exit: '04', exit: '04',
routePercent: 47, routePercent: 45,
clock: '03:51', clock: '03:51',
name: 'Luca Pîslaru', name: 'Luca Pîslaru',
city: 'Bucharest', city: 'Bucharest',
}; };
it('formats the document title', () => { it('formats the document title', () => {
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 47% · INFRASTRUCTURE · 03:51'); expect(dashcamTitle(telemetry)).toBe('DASHCAM · 45% · INFRASTRUCTURE · 03:51');
}); });
it('formats the frame plates and the credit', () => { it('formats the frame plates and the credit', () => {
expect(frameTopLeft(telemetry)).toBe('DASHCAM · PRE DAWN · EXIT 04'); expect(frameTopLeft(telemetry)).toBe('DASHCAM · EXIT 04');
expect(frameBottomLeft(telemetry)).toBe('ROUTE 47% · INFRASTRUCTURE'); expect(frameBottomLeft(telemetry)).toBe('ROUTE 45% · INFRASTRUCTURE');
expect(postcardCredit(telemetry.name, telemetry.city)).toBe('NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST'); expect(postcardCredit(telemetry.name, telemetry.city)).toBe('NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST');
expect(chapterLabel('selected-builds')).toBe('SELECTED BUILDS');
}); });
it('rounds and clamps route progress', () => { it('rounds and clamps route progress on the tab title\'s 5% step', () => {
expect(routePercent(0.474)).toBe(47); expect(routePercent(0.474)).toBe(45);
expect(routePercent(0.476)).toBe(50);
expect(routePercent(1.4)).toBe(100); expect(routePercent(1.4)).toBe(100);
expect(routePercent(-1)).toBe(0); expect(routePercent(-1)).toBe(0);
expect(routePercent(Number.NaN)).toBe(0); expect(routePercent(Number.NaN)).toBe(0);

View File

@@ -6,6 +6,7 @@ import {
coverCrop, coverCrop,
drawPostcard, drawPostcard,
postcardLayout, postcardLayout,
postcardSize,
type DashcamTelemetry, type DashcamTelemetry,
type PostcardContext, type PostcardContext,
} from '@/lib/dashcam-postcard'; } from '@/lib/dashcam-postcard';
@@ -15,7 +16,7 @@ const telemetry: DashcamTelemetry = {
sectionLabel: 'Infrastructure', sectionLabel: 'Infrastructure',
chapter: 'pre-dawn', chapter: 'pre-dawn',
exit: '04', exit: '04',
routePercent: 47, routePercent: 45,
clock: '03:51', clock: '03:51',
name: 'Luca Pîslaru', name: 'Luca Pîslaru',
city: 'Bucharest', city: 'Bucharest',
@@ -53,6 +54,19 @@ function stubContext() {
const source = (width: number, height: number) => ({ width, height }) as unknown as HTMLCanvasElement; const source = (width: number, height: number) => ({ width, height }) as unknown as HTMLCanvasElement;
describe('postcardSize', () => {
it('keeps the buffer\'s own pixels: a 16:9 crop, never upscaled', () => {
expect(postcardSize(1440, 900)).toEqual({ w: 1440, h: 810 });
expect(postcardSize(1920, 1080)).toEqual({ w: 1920, h: 1080 });
expect(postcardSize(1024, 1366)).toEqual({ w: 1024, h: 576 });
});
it('downsamples only past the cap, and survives an empty buffer', () => {
expect(postcardSize(2880, 1800)).toEqual({ w: 2560, h: 1440 });
expect(postcardSize(0, 0)).toEqual({ w: POSTCARD_WIDTH, h: POSTCARD_HEIGHT });
});
});
describe('coverCrop', () => { describe('coverCrop', () => {
it('crops a wider source to the 16:9 centre', () => { it('crops a wider source to the 16:9 centre', () => {
expect(coverCrop(2400, 900, 1600, 900)).toEqual({ sx: 400, sy: 0, sw: 1600, sh: 900 }); expect(coverCrop(2400, 900, 1600, 900)).toEqual({ sx: 400, sy: 0, sw: 1600, sh: 900 });
@@ -99,15 +113,15 @@ describe('drawPostcard', () => {
expect(images).toEqual([[0, 90, 2880, 1620, 0, 0, POSTCARD_WIDTH, POSTCARD_HEIGHT]]); expect(images).toEqual([[0, 90, 2880, 1620, 0, 0, POSTCARD_WIDTH, POSTCARD_HEIGHT]]);
expect(texts.map((call) => call.text)).toEqual([ expect(texts.map((call) => call.text)).toEqual([
'REC DASHCAM · PRE DAWN · EXIT 04', 'REC DASHCAM · EXIT 04',
'ROUTE 47% · INFRASTRUCTURE', 'ROUTE 45% · INFRASTRUCTURE',
'03:51', '03:51',
'LUCA PÎSLARU', 'LUCA PÎSLARU',
DASHCAM_COORDINATES, DASHCAM_COORDINATES,
'NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST', 'NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST',
]); ]);
const byText = Object.fromEntries(texts.map((call) => [call.text, call])); const byText = Object.fromEntries(texts.map((call) => [call.text, call]));
expect(byText['ROUTE 47% · INFRASTRUCTURE']).toMatchObject({ x: l.left, y: l.bottom, align: 'left' }); expect(byText['ROUTE 45% · INFRASTRUCTURE']).toMatchObject({ x: l.left, y: l.bottom, align: 'left' });
expect(byText['03:51']).toMatchObject({ x: l.right, y: l.top, align: 'right' }); expect(byText['03:51']).toMatchObject({ x: l.right, y: l.top, align: 'right' });
expect(byText[DASHCAM_COORDINATES]).toMatchObject({ x: l.right, y: l.bottom - l.lineGap, align: 'right' }); expect(byText[DASHCAM_COORDINATES]).toMatchObject({ x: l.right, y: l.bottom - l.lineGap, align: 'right' });
expect(byText['NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST']).toMatchObject({ x: 800, y: l.credit, align: 'center' }); expect(byText['NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST']).toMatchObject({ x: 800, y: l.credit, align: 'center' });

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources'; import { SceneResourceBag } from '@/scene/resources';
import { import {
MILESTONE_FLASH_MAX_RATE,
createMilestones, createMilestones,
MILESTONE_FAR_Z, MILESTONE_FAR_Z,
MILESTONE_NEAR_Z, MILESTONE_NEAR_Z,
@@ -100,6 +101,24 @@ describe('mile marker mesh', () => {
resources.dispose(); resources.dispose();
}); });
it('does not flash posts swept past by a navigator jump or a fling', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, true);
// A navigator long-scroll crosses the whole chapter in about 0.6 s.
let peak = 0;
for (let frame = 0; frame <= 36; frame += 1) {
markers.update('experience', frame / 36, 0, 1 / 60);
peak = Math.max(peak, ...markers.flashes());
}
expect(peak).toBe(0);
// Parked, then a single wheel tick at reading pace still flashes the post it crosses.
for (let i = 0; i < 90; i += 1) markers.update('experience', passPoint(3, 4) + 0.01, 0, 1 / 60);
markers.update('experience', passPoint(3, 4) - 0.01, 0, 1 / 60);
expect(markers.flashes()[3]).toBe(1);
expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5);
resources.dispose();
});
it('never flashes on the low tier or touch', () => { it('never flashes on the low tier or touch', () => {
const resources = new SceneResourceBag(); const resources = new SceneResourceBag();
const markers = createMilestones(resources, false); const markers = createMilestones(resources, false);

View File

@@ -1,23 +1,53 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
HERO_CAR_HALF_WIDTH, HERO_CAR_HALF_WIDTH,
TRAFFIC_BODY,
TRAFFIC_FADE_LENGTH, TRAFFIC_FADE_LENGTH,
TRAFFIC_LAMP_HALF_SPACING, TRAFFIC_LANE_JITTER,
TRAFFIC_LANES, TRAFFIC_LANES,
TRAFFIC_NEAR_Z, TRAFFIC_NEAR_Z,
trafficLamps,
trafficNearFade, trafficNearFade,
wrapTrafficZ, wrapTrafficZ,
} from '@/scene/world/traffic'; } from '@/scene/world/traffic';
describe('expressway traffic stays clear of the hero car', () => { describe('expressway traffic stays clear of the hero car', () => {
it('keeps every lamp outside the car body, with lane jitter', () => { it('keeps every body outside the hero car, with lane jitter', () => {
for (const lane of TRAFFIC_LANES) { for (const lane of TRAFFIC_LANES) {
const half = lane < 0 ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow; const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER;
const innerLamp = Math.abs(lane) - half - 0.25; // 0.25: max lane jitter expect(inner).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
expect(innerLamp).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
} }
}); });
it('never lets bodies in adjacent lanes overlap, and keeps them on the road', () => {
const spans = TRAFFIC_LANES.map((lane) => {
const half = TRAFFIC_BODY[lane.kind].halfWidth + TRAFFIC_LANE_JITTER;
return [lane.x - half, lane.x + half] as const;
}).sort((a, b) => a[0] - b[0]);
for (let i = 1; i < spans.length; i++) expect(spans[i]![0]).toBeGreaterThan(spans[i - 1]![1]);
for (const [left, right] of spans) {
expect(left).toBeGreaterThan(-8);
expect(right).toBeLessThan(8);
}
});
it('runs each lane at one speed, so no vehicle drives through another', () => {
const xs = TRAFFIC_LANES.map((lane) => lane.x);
expect(new Set(xs).size).toBe(xs.length);
});
it('puts every lamp on the vehicle body', () => {
for (const kind of ['car', 'truck'] as const) {
const lamps = trafficLamps(kind, 0.5);
expect(lamps.length).toBeGreaterThanOrEqual(3);
for (const lamp of lamps) expect(Math.abs(lamp.x) + lamp.w / 2).toBeLessThanOrEqual(TRAFFIC_BODY[kind].halfWidth + 0.01);
}
// A readable pair: two tail lamps mirrored at one height.
const [left, right] = trafficLamps('car', 0);
expect(left!.x).toBe(-right!.x);
expect(left!.y).toBe(right!.y);
});
it('is fully dark before the car near field and lit beyond the fade', () => { it('is fully dark before the car near field and lit beyond the fade', () => {
expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0); expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0);
expect(trafficNearFade(0)).toBe(0); expect(trafficNearFade(0)).toBe(0);