diff --git a/src/components/IconGlyph.vue b/src/components/IconGlyph.vue
index 1c5600d..8623e21 100644
--- a/src/components/IconGlyph.vue
+++ b/src/components/IconGlyph.vue
@@ -1,14 +1,4 @@
-
+ fill="currentColor"
+ >
+
+
diff --git a/src/components/SceneCanvas.vue b/src/components/SceneCanvas.vue
index 7133e03..513a65e 100644
--- a/src/components/SceneCanvas.vue
+++ b/src/components/SceneCanvas.vue
@@ -53,7 +53,11 @@ onMounted(() => { clientReady.value = true; });
watch(
() => [clientReady.value && !showCanvas.value, props.journey.arrived, sceneState.value] as const,
([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' },
);
diff --git a/src/composables/useDashcam.ts b/src/composables/useDashcam.ts
index 54d56a4..b5338eb 100644
--- a/src/composables/useDashcam.ts
+++ b/src/composables/useDashcam.ts
@@ -6,12 +6,27 @@ const LIVE_STAGE = ".reading-stage[data-reading-stage='live']";
const RISE_MS = 400;
/** Phones: no shutter in the gantry bay (dashcam.css), and no `c` key either. */
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
=> import('@/components/DashcamFrame.vue');
/**
* DASHCAM, DOM side (initial chunk, kept minimal): availability, the `c` key,
* html[data-dashcam] and the lazy frame. The frame itself (telemetry, focus
* trap, capture, the shutter) loads on first open or button hover. Starts
* 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: {
/** 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[>;
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
drift: () => boolean;
+ /** Test seam: the frame chunk loader. */
+ load?: () => Promise;
}) {
+ const load = sources.load ?? importFrame;
+ /** The page is folded and the frame is mounted. */
const open = shallowRef(false);
+ /** The frame should render (requested and loaded); `open` follows on its mount. */
+ const engaged = shallowRef(false);
const frame = shallowRef(null);
const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
- let loading: Promise | null = null;
+ let loading: Promise | null = null;
+ let requested = false;
let riseTimer = 0;
+ let loadTimer = 0;
const root = (): HTMLElement => document.documentElement;
+ const narrow = (): boolean => window.matchMedia?.(NARROW).matches ?? false;
- const prefetch = (): Promise => (loading ??= import('@/components/DashcamFrame.vue').then(
- (module) => { frame.value = markRaw(module.default); },
- () => { loading = null; },
- ));
+ const attempt = (): Promise => load().then(
+ (module) => { frame.value = markRaw(module.default); return true; },
+ () => false,
+ );
+ const prefetch = (): Promise => {
+ if (frame.value) return Promise.resolve(true);
+ return (loading ??= attempt()
+ .then((ok) => ok || new Promise((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 => {
- if (open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
- if (window.matchMedia?.(NARROW).matches) return;
+ if (requested || open.value || !available.value || document.querySelector(LIVE_STAGE) === null) 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);
open.value = true;
setDashcamOpen(true, sources.drift());
root().dataset.dashcam = 'on';
- void prefetch();
};
const close = (): void => {
- if (!open.value) return;
+ if (!open.value) {
+ if (requested) abandon();
+ return;
+ }
+ abandon();
open.value = false;
setDashcamOpen(false, false);
root().dataset.dashcam = 'rise';
@@ -53,25 +107,31 @@ export function useDashcam(sources: {
const onKeydown = (event: KeyboardEvent): void => {
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();
close();
} else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') {
- const wasOpen = open.value;
- if (wasOpen) close();
+ if (active) close();
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(); });
- onMounted(() => window.addEventListener('keydown', onKeydown));
+ watch(available, (ok) => { if (!ok) close(); });
+ onMounted(() => {
+ window.addEventListener('keydown', onKeydown);
+ window.addEventListener('resize', onResize, { passive: true });
+ });
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
+ window.removeEventListener('resize', onResize);
window.clearTimeout(riseTimer);
+ window.clearTimeout(loadTimer);
if (open.value) setDashcamOpen(false, false);
delete root().dataset.dashcam;
});
- return { open, frame, available, show, close, prefetch };
+ return { open, engaged, frame, available, show, ready, close, prefetch };
}
diff --git a/src/lib/dashcam-postcard.ts b/src/lib/dashcam-postcard.ts
index 9960b83..9381e28 100644
--- a/src/lib/dashcam-postcard.ts
+++ b/src/lib/dashcam-postcard.ts
@@ -1,5 +1,6 @@
import { bucharestClock } from '@/lib/operations';
import { maintenanceClock } from '@/journey/maintenance-progress';
+import { quantiseRoute } from '@/journey/tab-feed';
/**
* 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_HEIGHT = 900;
-export const routePercent = (progress: number): number =>
- Math.round(Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)) * 100);
-
-export const chapterLabel = (chapter: string): string => chapter.replaceAll('-', ' ').toUpperCase();
+/** Whole percent on the tab title's 5% step, so the frame, the dashcam's
+ * title and the route title never disagree (47% vs 45%). */
+export const routePercent = (progress: number): number => Math.round(quantiseRoute(progress) * 100);
/** Live Bucharest time; inside Infrastructure the maintenance window's clock. */
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 =>
`NIGHT SHIFT · ${name.toUpperCase()} · ${city.toUpperCase()}`;
-export const frameTopLeft = (t: DashcamTelemetry): string =>
- `DASHCAM · ${chapterLabel(t.chapter)} · EXIT ${t.exit}`;
+/* One vocabulary in the frame: the navigation's exit and chapter name (the
+ 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 =>
`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 };
}
+/** 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). */
export function postcardLayout(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 project.id), frame.direction);
- // Floods: off while rising; lite keeps one steady lamp.
- const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, 0];
+ // Floods: off while rising; lite keeps both lamps steady (one lamp left
+ // 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[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood);
diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts
index a44fc76..3f23efa 100644
--- a/src/scene/world/environment.ts
+++ b/src/scene/world/environment.ts
@@ -149,7 +149,7 @@ export async function createProceduralSceneGeneration(
const sun = createSun(context.resources);
const horizonAir = createHorizonAir(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
// there and seen from behind.
const liteCar =
@@ -214,6 +214,7 @@ export async function createProceduralSceneGeneration(
let underpassShade = 0;
let underpassKey = '';
let underpassAttr = false;
+ let underpassSince = -1;
scene.add(
underpass.group,
relay.group,
@@ -786,7 +787,13 @@ export async function createProceduralSceneGeneration(
},
);
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) {
underpassAttr = 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;
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
const OUTRO_SECONDS = 7.0;
diff --git a/src/scene/world/expressway.ts b/src/scene/world/expressway.ts
index adb9ef6..e7660cd 100644
--- a/src/scene/world/expressway.ts
+++ b/src/scene/world/expressway.ts
@@ -36,7 +36,7 @@ export function createExpressway(
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(
'material',
new THREE.ShaderMaterial({
@@ -49,7 +49,7 @@ export function createExpressway(
varying vec3 vWorld;
varying float vDepth;
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);
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);
@@ -71,7 +71,9 @@ export function createExpressway(
}
const graphite = material(false);
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 dummy = new THREE.Object3D();
type Part = {
@@ -113,12 +115,14 @@ export function createExpressway(
for (const side of [-1, 1]) {
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 });
+ // A tall reflector plate on the post's face: guard-rail delineators,
+ // the post and its plate one readable unit.
reflectors.push({
- x: side * 9.48,
- y: 1.1,
- z: z + 0.18,
- sx: 0.14,
- sy: 0.22,
+ x: side * 9.5,
+ y: 1.02,
+ z: z + 0.17,
+ sx: 0.16,
+ sy: 0.4,
sz: 0.03,
});
rails.push({
diff --git a/src/scene/world/milestones.ts b/src/scene/world/milestones.ts
index 8e05be6..cf12a91 100644
--- a/src/scene/world/milestones.ts
+++ b/src/scene/world/milestones.ts
@@ -44,6 +44,14 @@ const PLATE_YAW = 0.3;
const FLASH_SECONDS = 0.3;
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */
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;
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
const PLATE_GLOW = 0.42;
@@ -198,10 +206,14 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
} else if (vPart < 1.5) {
// 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.
- color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing);
- color += uAccent * 0.35;
- ${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''}
- color += vec3(1.0, 0.96, 0.9) * flash * 1.4;
+ // Kept below the tonemapper's shoulder, so the plate stays the
+ // chapter colour instead of clipping to white; the beam and the
+ // flash lift it in the accent's own hue.
+ 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 {
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 side = new Float32Array(INSTANCES);
let lastSection: SectionId | null = null;
+ let lastProgress: number | null = null;
+ let rate = 0;
const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion();
const position = new THREE.Vector3();
@@ -281,12 +295,17 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
const posts = milestoneLayout(section, p);
const changed = section !== lastSection;
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;
posts.forEach((post, i) => {
// 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]));
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;
sinceFlash[i] = 0;
} else {
diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts
index 34181b6..cd7389d 100644
--- a/src/scene/world/road.ts
+++ b/src/scene/world/road.ts
@@ -176,9 +176,8 @@ export function createRoad(
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));
color *= 1.0 - joint * 0.5;
- float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53)))
- * (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0)));
- color += uLaneColor * studs * 0.7;
+ // (Amber edge studs removed in the polish round: sub-pixel beyond
+ // ~60 m, they aliased into random orange specks along the edge.)
// Rear lamp footprint: grows towards the camera from the actual rear axle.
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
diff --git a/src/scene/world/traffic.ts b/src/scene/world/traffic.ts
index f45e005..79ea255 100644
--- a/src/scene/world/traffic.ts
+++ b/src/scene/world/traffic.ts
@@ -3,30 +3,54 @@ import type { SceneResourceBag } from '@/scene/resources';
import type { RenderQualitySettings } from '@/scene/render-quality';
/**
- * Other cars on the expressway, seen only as their lamps: taillight pairs,
- * long-exposure trails whose length follows the speed difference, and red
+ * Other vehicles on the night shift: dark low-poly cars and box trucks with
+ * 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;
- * resting lets the faster lanes pull away. Instances wrap through a hidden band
- * behind the camera, so nothing pops in view.
+ * resting lets the faster lanes pull away. Instances wrap through a hidden
+ * 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
* 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
- * wrap inside the hidden band, so no light ever slides past the car (round 5:
- * bodiless lamp pairs passing the flank read as the car's own taillights
- * coming loose).
+ * around it. Vehicles fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z
+ * and wrap inside the hidden band, so nothing ever slides past the car.
*/
export const HERO_CAR_HALF_WIDTH = 2.6;
export const TRAFFIC_NEAR_Z = -24;
export const TRAFFIC_FADE_LENGTH = 22;
const FAR_Z = -660;
const SPAN = TRAFFIC_NEAR_Z - FAR_Z;
-/** Lane centres. Fast lanes on the left, slower trucks on the right. */
-export const TRAFFIC_LANES = [-5.7, -5.7, -4.3, 5.4, 5.7] as const;
-export const TRAFFIC_LAMP_HALF_SPACING = { fast: 0.9, slow: 1.0 } as const;
+/** Max lateral jitter of a vehicle inside its lane. */
+export const TRAFFIC_LANE_JITTER = 0.1;
-/** 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 {
const t = Math.min(1, Math.max(0, (TRAFFIC_NEAR_Z - z) / TRAFFIC_FADE_LENGTH));
return t * t * (3 - 2 * t);
@@ -40,44 +64,114 @@ export function wrapTrafficZ(z: number): number {
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 {
+ lane: TrafficLane;
x: number;
z: number;
- speed: number;
- width: number;
- height: number;
tint: number;
}
export function createTraffic(
resources: SceneResourceBag,
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();
group.name = 'expressway-traffic';
const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7;
- const lanes = TRAFFIC_LANES;
const vehicles: Vehicle[] = [];
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
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++) {
- const lane = lanes[i % lanes.length]!;
- // Left lanes run faster than the cruise, the right lane is slower trucks.
- const fast = lane < 0;
+ const laneIndex = i % lanes;
+ const lane = TRAFFIC_LANES[laneIndex]!;
+ // 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({
- x: lane + (random() - 0.5) * 0.5,
- z: FAR_Z + 20 + random() * (SPAN - 40),
- speed: fast ? 26 + random() * 22 : 9 + random() * 6,
- width: fast ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow,
- height: fast ? 0.95 : 1.55 + random() * 0.4,
+ lane,
+ x: lane.x + (random() - 0.5) * 2 * TRAFFIC_LANE_JITTER,
+ z: wrapTrafficZ(FAR_Z + gap * (slot + 0.5) + laneIndex * 37 + jitter),
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 */ `
attribute float aLevel;
varying vec2 vUv;
@@ -89,7 +183,7 @@ export function createTraffic(
vLevel = aLevel;
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
// 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;
vDepth = -view.z;
gl_Position = projectionMatrix * view;
@@ -103,7 +197,8 @@ export function createTraffic(
float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
float near = vNear;
${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);
#include
#include
@@ -122,40 +217,122 @@ export function createTraffic(
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(`
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 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.
const trailMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
- float glow = across * pow(vUv.y, 1.6) * 1.1;`);
- // Wet-road pools: long and soft under each car.
+ float glow = across * pow(vUv.y, 1.6) * 0.8;`);
+ // Wet-road pools: long and soft under each vehicle.
const poolMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
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
+ #include
+ }`,
+ transparent: true,
+ depthWrite: true,
+ depthTest: true,
+ }),
+ );
+
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
- const lampCount = count * 2;
- const mesh = (name: string, material: unknown, instances: number) => {
- const instanced = new THREE.InstancedMesh(plane, material, instances);
+ const lampCount = count * LAMPS_PER_VEHICLE;
+ const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => {
+ 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.frustumCulled = false;
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);
return instanced;
};
- const lamps = mesh('traffic-taillights', lampMaterial, lampCount);
- const trails = mesh('traffic-light-trails', trailMaterial, lampCount);
- const pools = mesh('traffic-road-pools', poolMaterial, count);
+ const lampsFor = vehicles.map((v) => trafficLamps(v.lane.kind, v.tint));
+ const lamps = quad('traffic-lamps', lampMaterial, lampCount, (i) => lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]?.level ?? 0);
+ // 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 hidden = new THREE.Matrix4().makeScale(0, 0, 0);
return {
group,
/**
@@ -166,33 +343,49 @@ export function createTraffic(
const dt = Math.min(0.1, Math.max(0, deltaSeconds));
for (let i = 0; i < count; 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);
- 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.
const trailDirection = relative >= 0 ? -1 : 1;
- for (let side = 0; side < 2; side++) {
- const index = i * 2 + side;
- const x = v.x + (side === 0 ? -v.width : v.width);
- dummy.position.set(x, v.height, v.z);
+ const own = lampsFor[i]!;
+ for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
+ const lamp = own[k];
+ 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.scale.set(0.62, 0.36, 1);
+ dummy.scale.set(lamp.w, lamp.h, 1);
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.
- 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.scale.set(0.2, trailLength, 1);
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);
dummy.position.set(v.x, 0.04, v.z + poolLength / 2);
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();
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;
trails.instanceMatrix.needsUpdate = true;
pools.instanceMatrix.needsUpdate = true;
diff --git a/src/styles/dashcam.css b/src/styles/dashcam.css
index 3a5f41b..45e0220 100644
--- a/src/styles/dashcam.css
+++ b/src/styles/dashcam.css
@@ -64,13 +64,15 @@ html[data-dashcam='on'] .content-focus-cue {
position: fixed;
z-index: calc(var(--z-popover, 50) + 10);
inset: 0;
- cursor: crosshair;
+ cursor: default;
color: var(--dash-ink);
font: 500 clamp(0.68rem, 0.9vw, 0.82rem) / 1.35 var(--mono);
letter-spacing: 0.08em;
text-shadow: 0 1px 3px rgb(0 0 0 / 65%);
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
(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. */
@@ -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:focus-visible { outline: 2px solid var(--focus, #ffd693); outline-offset: 2px; }
.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__bar { animation: none; }
html[data-dashcam-shutter] .dashcam-frame::after {
@@ -176,7 +179,7 @@ html[data-dashcam-shutter] .dashcam-frame::after {
to { transform: scaleY(1); }
}
@keyframes dashcam-shutter {
- from { opacity: 0.85; }
+ from { opacity: 0.5; }
to { opacity: 0; }
}
@media (forced-colors: active) {
diff --git a/src/styles/gantry.css b/src/styles/gantry.css
index 7d9388c..7076bf3 100644
--- a/src/styles/gantry.css
+++ b/src/styles/gantry.css
@@ -98,24 +98,8 @@ html .site-navigation[data-enhanced='true'][data-docked='true']::before {
mask-composite: exclude, add;
}
-/* Brand plate in the left bay (decorative; the h1 carries the name). */
-html .site-navigation::after {
- content: 'LUCA P\00CESLARU' / '';
- position: absolute;
- inset-block: 0;
- inset-inline-start: 22px;
- display: flex;
- align-items: center;
- gap: 10px;
- color: #f4e6ff;
- font: 680 0.74rem/1 var(--mono);
- letter-spacing: 0.24em;
- pointer-events: none;
- white-space: nowrap;
-}
-@media (max-width: 1279px) {
- html .site-navigation::after { display: none; }
-}
+/* No brand plate in the left bay: the hero carries the name (owner's call:
+ a name in the nav bar read as self-promotion). */
/* ---------- Exits on the beam ---------- */
@media (min-width: 768px) {
@@ -529,6 +513,10 @@ html .drive-control-button,
html .drive-controls__settings-trigger {
display: inline-grid;
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;
min-inline-size: 44px;
block-size: 44px;
@@ -833,7 +821,6 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
}
@media (forced-colors: active) {
html .site-navigation::before { background: Canvas; }
- html .site-navigation::after { color: CanvasText; }
.gantry-window { border: 1px solid ButtonText; box-shadow: none; }
.gantry-window::before,
.gantry-window::after { border-color: Highlight; }
diff --git a/src/styles/maintenance.css b/src/styles/maintenance.css
index 33f511e..34dade1 100644
--- a/src/styles/maintenance.css
+++ b/src/styles/maintenance.css
@@ -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);
}
}
+
+/* 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; }
+}
diff --git a/src/styles/refinements.css b/src/styles/refinements.css
index 3c142ec..2fc52ba 100644
--- a/src/styles/refinements.css
+++ b/src/styles/refinements.css
@@ -59,16 +59,20 @@
z-index: 1;
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 {
- position: absolute;
- inset-inline-start: 116px;
+ position: relative;
+ flex-shrink: 0;
+ margin-inline-start: 16px;
white-space: nowrap;
font-size: 0.84rem;
color: var(--text);
opacity: 0;
visibility: hidden;
- transform: translateX(-24px);
- transition: opacity 200ms ease, transform 260ms ease, visibility 260ms;
+ transform: translateX(-32px);
+ transition: opacity 220ms ease, transform 280ms cubic-bezier(0.22, 1, 0.36, 1), visibility 280ms;
}
@media (hover: hover) and (min-width: 768px) {
.contact-email__action:is(:hover, :focus-within) .contact-email__preview {
diff --git a/src/styles/world.css b/src/styles/world.css
index 881032c..f8416da 100644
--- a/src/styles/world.css
+++ b/src/styles/world.css
@@ -223,6 +223,11 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) #contact .section-eyebrow {
color: #3b1a33;
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). */
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; }
/* 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'] {
- 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::after {
inset-block-end: 0;
diff --git a/tests/unit/SceneCanvas.test.ts b/tests/unit/SceneCanvas.test.ts
index 84fcbe3..1782040 100644
--- a/tests/unit/SceneCanvas.test.ts
+++ b/tests/unit/SceneCanvas.test.ts
@@ -187,6 +187,35 @@ describe("SceneCanvas", () => {
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 () => {
const wrapper = mount(SceneCanvas, {
props: {
diff --git a/tests/unit/billboard.test.ts b/tests/unit/billboard.test.ts
index d6d770b..7c54db1 100644
--- a/tests/unit/billboard.test.ts
+++ b/tests/unit/billboard.test.ts
@@ -204,8 +204,8 @@ describe('billboard world', () => {
expect(full.inspect().flickers).toBeLessThanOrEqual(Math.ceil(3 / BILLBOARD_TIMING.flickerGap));
expect(full.inspect().shimmers).toBe(1);
expect(full.inspect().zoom).toBe(1);
- // Lite: one steady flood, no pulse, flicker, shimmer or hover zoom.
- expect(lite.inspect()).toMatchObject({ floods: [1, 0], flickers: 0, shimmers: 0, zoom: 0 });
+ // Lite: both floods steady (the whole face lit), no pulse, flicker, shimmer or hover zoom.
+ expect(lite.inspect()).toMatchObject({ floods: [1, 1], flickers: 0, shimmers: 0, zoom: 0 });
});
it('never throws into the render loop', () => {
diff --git a/tests/unit/dashcam-controls.test.ts b/tests/unit/dashcam-controls.test.ts
index 49d30b3..f5efb03 100644
--- a/tests/unit/dashcam-controls.test.ts
+++ b/tests/unit/dashcam-controls.test.ts
@@ -1,11 +1,13 @@
import { readFileSync } from 'node:fs';
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 { 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 { useDashcam } from '@/composables/useDashcam';
-import { readDashcam } from '@/journey/dashcam-feed';
+import { DASHCAM_LOAD_TIMEOUT_MS, DASHCAM_RETRY_MS, useDashcam } from '@/composables/useDashcam';
+import { cancelDashcamCapture, readDashcam } from '@/journey/dashcam-feed';
+import type { JourneySnapshot } from '@/journey/types';
import { validateProductionInputs } from '@/content/schemas';
import { render } from '@/entry-server';
import type { MotionPolicy } from '@/journey/motion-policy';
@@ -66,11 +68,25 @@ describe('useDashcam', () => {
let stage: HTMLElement;
const allowed = 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 Promise<{ default: Component }>>>;
const host = () => mount(defineComponent({
setup() {
- const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true });
- return () => h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show });
+ const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true, load });
+ 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 });
@@ -79,11 +95,16 @@ describe('useDashcam', () => {
window.dispatchEvent(event);
return event;
};
+ /** Lets the import promise settle and the frame mount. */
+ const settle = async (): Promise => {
+ for (let i = 0; i < 4; i++) await flushPromises();
+ };
beforeEach(() => {
vi.useFakeTimers();
allowed.value = true;
running.value = true;
+ load = vi.fn(() => Promise.resolve({ default: FrameStub as Component }));
stage = document.createElement('div');
stage.className = 'reading-stage';
stage.dataset.readingStage = 'live';
@@ -93,16 +114,24 @@ describe('useDashcam', () => {
afterEach(() => {
stage.remove();
vi.useRealTimers();
+ vi.unstubAllGlobals();
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();
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(readDashcam()).toMatchObject({ open: true, drift: true });
expect(press('C', { shiftKey: true }).defaultPrevented).toBe(false);
press('c');
+ await settle();
+ expect(wrapper.find('.dashcam-frame').exists()).toBe(false);
expect(document.documentElement.dataset.dashcam).toBe('rise');
expect(readDashcam().open).toBe(false);
vi.advanceTimersByTime(400);
@@ -110,11 +139,75 @@ describe('useDashcam', () => {
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();
press('c');
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();
});
@@ -135,35 +228,104 @@ describe('useDashcam', () => {
['motion is withheld', () => { allowed.value = false; }],
['the scene is not running', () => { running.value = false; }],
['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();
arrange();
expect(press('c').defaultPrevented).toBe(false);
+ await settle();
expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false);
wrapper.unmount();
});
- it('does nothing on a phone-width viewport, where the button is hidden too', () => {
- const matchMedia = vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList);
- vi.stubGlobal('matchMedia', matchMedia);
- try {
- const wrapper = host();
- expect(press('c').defaultPrevented).toBe(false);
- expect(document.documentElement.dataset.dashcam).toBeUndefined();
- expect(readDashcam().open).toBe(false);
- wrapper.unmount();
- } finally {
- vi.unstubAllGlobals();
- }
- });
-
- it('closes when motion is withdrawn while open', async () => {
+ it('does nothing on a phone-width viewport, where the button is hidden too', async () => {
+ vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(max-width: 767px)' }) as MediaQueryList));
const wrapper = host();
- press('c');
- allowed.value = false;
- await vi.runOnlyPendingTimersAsync();
+ expect(press('c').defaultPrevented).toBe(false);
+ await settle();
+ expect(document.documentElement.dataset.dashcam).toBeUndefined();
expect(readDashcam().open).toBe(false);
wrapper.unmount();
});
+
+ 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();
+ press('c');
+ await settle();
+ expect(readDashcam().open).toBe(true);
+ arrange();
+ await settle();
+ 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();
+ });
});
diff --git a/tests/unit/dashcam-feed.test.ts b/tests/unit/dashcam-feed.test.ts
index 7efe16e..a049790 100644
--- a/tests/unit/dashcam-feed.test.ts
+++ b/tests/unit/dashcam-feed.test.ts
@@ -9,7 +9,6 @@ import {
} from '@/journey/dashcam-feed';
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
import {
- chapterLabel,
dashcamClock,
dashcamTitle,
frameBottomLeft,
@@ -117,25 +116,25 @@ describe('dashcam telemetry', () => {
sectionLabel: 'Infrastructure',
chapter: 'pre-dawn',
exit: '04',
- routePercent: 47,
+ routePercent: 45,
clock: '03:51',
name: 'Luca Pîslaru',
city: 'Bucharest',
};
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', () => {
- expect(frameTopLeft(telemetry)).toBe('DASHCAM · PRE DAWN · EXIT 04');
- expect(frameBottomLeft(telemetry)).toBe('ROUTE 47% · INFRASTRUCTURE');
+ expect(frameTopLeft(telemetry)).toBe('DASHCAM · EXIT 04');
+ expect(frameBottomLeft(telemetry)).toBe('ROUTE 45% · INFRASTRUCTURE');
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', () => {
- expect(routePercent(0.474)).toBe(47);
+ it('rounds and clamps route progress on the tab title\'s 5% step', () => {
+ expect(routePercent(0.474)).toBe(45);
+ expect(routePercent(0.476)).toBe(50);
expect(routePercent(1.4)).toBe(100);
expect(routePercent(-1)).toBe(0);
expect(routePercent(Number.NaN)).toBe(0);
diff --git a/tests/unit/dashcam-postcard.test.ts b/tests/unit/dashcam-postcard.test.ts
index 20031bb..79692c8 100644
--- a/tests/unit/dashcam-postcard.test.ts
+++ b/tests/unit/dashcam-postcard.test.ts
@@ -6,6 +6,7 @@ import {
coverCrop,
drawPostcard,
postcardLayout,
+ postcardSize,
type DashcamTelemetry,
type PostcardContext,
} from '@/lib/dashcam-postcard';
@@ -15,7 +16,7 @@ const telemetry: DashcamTelemetry = {
sectionLabel: 'Infrastructure',
chapter: 'pre-dawn',
exit: '04',
- routePercent: 47,
+ routePercent: 45,
clock: '03:51',
name: 'Luca Pîslaru',
city: 'Bucharest',
@@ -53,6 +54,19 @@ function stubContext() {
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', () => {
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 });
@@ -99,15 +113,15 @@ describe('drawPostcard', () => {
expect(images).toEqual([[0, 90, 2880, 1620, 0, 0, POSTCARD_WIDTH, POSTCARD_HEIGHT]]);
expect(texts.map((call) => call.text)).toEqual([
- 'REC DASHCAM · PRE DAWN · EXIT 04',
- 'ROUTE 47% · INFRASTRUCTURE',
+ 'REC DASHCAM · EXIT 04',
+ 'ROUTE 45% · INFRASTRUCTURE',
'03:51',
'LUCA PÎSLARU',
DASHCAM_COORDINATES,
'NIGHT SHIFT · LUCA PÎSLARU · BUCHAREST',
]);
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[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' });
diff --git a/tests/unit/milestones.test.ts b/tests/unit/milestones.test.ts
index 24beaf1..20b2998 100644
--- a/tests/unit/milestones.test.ts
+++ b/tests/unit/milestones.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import {
+ MILESTONE_FLASH_MAX_RATE,
createMilestones,
MILESTONE_FAR_Z,
MILESTONE_NEAR_Z,
@@ -100,6 +101,24 @@ describe('mile marker mesh', () => {
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', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, false);
diff --git a/tests/unit/traffic.test.ts b/tests/unit/traffic.test.ts
index 5b22ed6..a12adb0 100644
--- a/tests/unit/traffic.test.ts
+++ b/tests/unit/traffic.test.ts
@@ -1,23 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
HERO_CAR_HALF_WIDTH,
+ TRAFFIC_BODY,
TRAFFIC_FADE_LENGTH,
- TRAFFIC_LAMP_HALF_SPACING,
+ TRAFFIC_LANE_JITTER,
TRAFFIC_LANES,
TRAFFIC_NEAR_Z,
+ trafficLamps,
trafficNearFade,
wrapTrafficZ,
} from '@/scene/world/traffic';
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) {
- const half = lane < 0 ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow;
- const innerLamp = Math.abs(lane) - half - 0.25; // 0.25: max lane jitter
- expect(innerLamp).toBeGreaterThan(HERO_CAR_HALF_WIDTH);
+ const inner = Math.abs(lane.x) - TRAFFIC_BODY[lane.kind].halfWidth - TRAFFIC_LANE_JITTER;
+ expect(inner).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', () => {
expect(trafficNearFade(TRAFFIC_NEAR_Z)).toBe(0);
expect(trafficNearFade(0)).toBe(0);
]