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

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

View File

@@ -35,7 +35,7 @@ const props = defineProps<{
city: string;
sceneReady: boolean;
}>();
const emit = defineEmits<{ close: [] }>();
const emit = defineEmits<{ close: []; ready: [] }>();
/** REC dot sampling rate while open. */
const PULSE_HZ = 12;
@@ -43,15 +43,15 @@ const PULSE_HZ = 12;
const CAPTURE_TIMEOUT_MS = 1_200;
const SHUTTER_MS = 120;
const surface = ref<HTMLElement | null>(null);
const saveButton = ref<HTMLButtonElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
const dot = ref<HTMLElement | null>(null);
const now = shallowRef(new Date());
const busy = shallowRef(false);
const lost = shallowRef(false);
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>(() => ({
sectionId: props.journey.activeSectionId,
@@ -85,8 +85,8 @@ const finish = (): void => {
window.clearTimeout(captureTimer);
busy.value = false;
// The button was disabled while busy, which drops focus to the body.
const lost = document.activeElement === null || document.activeElement === document.body;
if (lost) void nextTick(() => saveButton.value?.focus());
const focusDropped = document.activeElement === null || document.activeElement === document.body;
if (focusDropped) void nextTick(() => saveButton.value?.focus());
};
/* The page stays where it was: a scrollbar drag (the only input not
@@ -113,18 +113,15 @@ const shoot = (): void => {
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(() => {
cancelDashcamCapture();
lost.value = true;
status.value = 'Postcard not taken, try again';
finish();
}, CAPTURE_TIMEOUT_MS);
};
const onSurfaceClick = (event: MouseEvent): void => {
if (event.target === surface.value) shoot();
};
const onPointerMove = (event: PointerEvent): void => {
if (event.pointerType !== 'mouse' || !readDashcam().drift) return;
setDashcamPointer(
@@ -157,7 +154,6 @@ const CAPTURE = { capture: true } as const;
const ACTIVE = { capture: true, passive: false } as const;
watch(title, (value) => { document.title = value; });
watch(() => props.sceneReady, (ready) => { if (ready) lost.value = false; });
onMounted(() => {
previousTitle = document.title;
@@ -170,7 +166,8 @@ onMounted(() => {
window.addEventListener('wheel', suppress, ACTIVE);
window.addEventListener('touchmove', suppress, ACTIVE);
window.addEventListener('keydown', onKeydown, CAPTURE);
saveButton.value?.focus();
(saveButton.value?.disabled ? closeButton.value : saveButton.value)?.focus();
emit('ready');
});
onBeforeUnmount(() => {
@@ -190,13 +187,12 @@ onBeforeUnmount(() => {
<template>
<div
ref="surface"
class="dashcam-frame"
role="dialog"
aria-modal="true"
aria-label="Dashcam"
tabindex="-1"
: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="bottom" aria-hidden="true"></span>

View File

@@ -1,14 +1,4 @@
<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';
const props = withDefaults(defineProps<{
@@ -18,33 +8,34 @@ const props = withDefaults(defineProps<{
size: 20,
});
// Phosphor's camera (regular weight) redrawn as one stroked path: the package
// component ships all six weights' paths into the initial chunk.
const CameraGlyph: FunctionalComponent<{ size?: number }> = ({ size }) => h('svg', {
viewBox: '0 0 256 256', width: size, height: size, fill: 'none', stroke: 'currentColor', 'stroke-width': 16, 'stroke-linejoin': 'round',
}, [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' })]);
CameraGlyph.props = ['size'];
const icons = {
camera: CameraGlyph,
pause: PhPause,
play: PhPlay,
settings: PhSlidersHorizontal,
sound: PhSpeakerHigh,
'sound-muted': PhSpeakerX,
volume: PhWaveform,
} as const;
const icon = computed(() => icons[props.name]);
/*
* Regular-weight paths from @phosphor-icons/vue 2.2.1 (MIT, recorded in
* docs/third-party-notices.md), filled. The package components ship all six
* weights of every glyph into the initial chunk; one path each is all the
* controls draw.
*/
const FILLED: Readonly<Record<IconGlyphName, string>> = {
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',
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',
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',
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',
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-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',
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',
};
</script>
<template>
<component
:is="icon"
<svg
class="icon-glyph"
:size="size"
weight="regular"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
:width="props.size"
:height="props.size"
aria-hidden="true"
focusable="false"
/>
fill="currentColor"
>
<path :d="FILLED[props.name]" />
</svg>
</template>

View File

@@ -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' },
);

View File

@@ -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<FrameModule> => 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<Ref<boolean>>;
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
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);
/** The frame should render (requested and loaded); `open` follows on its mount. */
const engaged = shallowRef(false);
const frame = shallowRef<Component | null>(null);
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 loadTimer = 0;
const root = (): HTMLElement => document.documentElement;
const narrow = (): boolean => window.matchMedia?.(NARROW).matches ?? false;
const prefetch = (): Promise<void> => (loading ??= import('@/components/DashcamFrame.vue').then(
(module) => { frame.value = markRaw(module.default); },
() => { loading = null; },
));
const attempt = (): Promise<boolean> => load().then(
(module) => { frame.value = markRaw(module.default); return true; },
() => 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 => {
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 };
}

View File

@@ -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<Postc
let canvas: HTMLCanvasElement;
try {
canvas = document.createElement('canvas');
canvas.width = POSTCARD_WIDTH;
canvas.height = POSTCARD_HEIGHT;
const { w, h } = postcardSize(source.width, source.height);
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
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 {
return Promise.resolve('failed');
}

View File

@@ -351,7 +351,7 @@ export interface BillboardWorld {
}
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';
textureSize: number;
io?: BillboardIO;
@@ -531,8 +531,9 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
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);
// 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);

View File

@@ -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;

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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 <tonemapping_fragment>
#include <colorspace_fragment>
@@ -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 <tonemapping_fragment>
#include <colorspace_fragment>
}`,
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;

View File

@@ -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) {

View File

@@ -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; }

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);
}
}
/* 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;
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 {

View File

@@ -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;