feat: dashcam photo mode with telemetry frame, lens drift and postcard capture

This commit is contained in:
2026-10-11 07:49:56 +03:00
parent d71b0ecfd4
commit 568b0802b4
29 changed files with 1472 additions and 7 deletions

View File

@@ -19,6 +19,7 @@ import './styles/print.css';
import './styles/headlight.css';
import './styles/route-log.css';
import './styles/billboard.css';
import './styles/dashcam.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();
</script>

View File

@@ -9,6 +9,7 @@ let audible = false;
export function attachMusicPulse(node: AnalyserNode | null): void {
analyser = node;
if (node === null) bus = null;
bins = node === null ? null : new Uint8Array(new ArrayBuffer(node.frequencyBinCount));
}
@@ -16,6 +17,13 @@ export function setMusicPulseAudible(value: boolean): void {
audible = value;
}
/**
* DASHCAM: the mix bus (the tap after the mixer's sum, before the output
* gain), so a shutter click obeys mute and volume. Null unless audible.
*/
let bus: AudioNode | null = null;
export const readSwitchboard = (): AudioNode | null => (audible ? bus : null);
/** Bass energy in [0, 1]: the mean of roughly 90-380 Hz. */
export function readMusicPulse(): number {
if (!audible || analyser === null || bins === null) return 0;
@@ -37,5 +45,6 @@ export function createPulseTap(context: BaseAudioContext, destination: AudioNode
} catch {
attachMusicPulse(null);
}
bus = tap;
return tap;
}

View File

@@ -0,0 +1,48 @@
import { readSwitchboard } from '@/audio/music-pulse';
/** Shutter click length, seconds. */
export const SHUTTER_SECONDS = 0.03;
/** The switchboard input's level on the mix bus. */
export const SWITCHBOARD_GAIN = 0.18;
/**
* DASHCAM's shutter: a ~30 ms burst of decaying noise with a fast gain
* envelope, through a switchboard input (gain 0.18) onto the mix bus, so
* mute and volume apply through the output gain. Lives in the lazy dashcam chunk. Plays only while
* the soundtrack is running and audible; never creates an AudioContext and
* never throws.
*/
export function playShutterClick(): boolean {
try {
const input = readSwitchboard();
if (input === null || input.context.state !== 'running') return false;
const context = input.context;
const length = Math.max(1, Math.round(context.sampleRate * SHUTTER_SECONDS));
const buffer = context.createBuffer(1, length, context.sampleRate);
const data = buffer.getChannelData(0);
// Two clicks a few ms apart (curtain open, curtain close) over decaying noise.
for (let i = 0; i < length; i += 1) {
const t = i / length;
const curtain = t > 0.55 && t < 0.62 ? 1.6 : 1;
data[i] = (Math.random() * 2 - 1) * (1 - t) ** 3 * curtain;
}
const source = context.createBufferSource();
source.buffer = buffer;
const envelope = context.createGain();
const start = context.currentTime + 0.005;
envelope.gain.setValueAtTime(0, start);
envelope.gain.linearRampToValueAtTime(SWITCHBOARD_GAIN, start + 0.002);
envelope.gain.exponentialRampToValueAtTime(0.0002, start + SHUTTER_SECONDS);
source.connect(envelope);
envelope.connect(input);
source.onended = () => {
source.disconnect();
envelope.disconnect();
};
source.start(start);
source.stop(start + SHUTTER_SECONDS + 0.01);
return true;
} catch {
return false;
}
}

View File

@@ -24,6 +24,7 @@ import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { useRouteLog } from '@/composables/useRouteLog';
import { useBillboard } from '@/composables/useBillboard';
import { useDashcam } from '@/composables/useDashcam';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
@@ -160,6 +161,16 @@ const headlight = useHeadlightBeam({
quality: actualQuality,
activeSectionId: () => journey.value.activeSectionId,
});
/* DASHCAM: a full-screen photo mode over the live scene (lazy frame). */
const dashcam = useDashcam({
allowed: computed(() => controlsReady.value
&& motion.policy.value.reason === 'full'
&& motion.policy.value.allowSceneRequest
&& actualQuality.value !== 'static'
&& pageLifecycle.value === 'active'),
sceneRunning: computed(() => sceneState.value === 'running'),
drift: () => actualQuality.value !== 'low' && matchMedia('(pointer: fine)').matches,
});
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
const serverDefaultPolicy: MotionPolicy = {
allowSceneRequest: false,
@@ -376,6 +387,7 @@ onUnmounted(() => removePageListeners());
:audio-state="audio.state.value"
:audio-muted="audio.muted.value"
:audio-volume="controlsReady ? motion.volume.value : 0.4"
:dashcam="dashcam.available.value"
@pause="pauseMotion"
@resume="resumeMotion"
@enable="enableMotion"
@@ -384,5 +396,17 @@ onUnmounted(() => removePageListeners());
@audio-resume="audio.resumeFromGesture"
@audio-mute-toggle="toggleAudioMuted"
@audio-volume-change="updateAudioVolume"
@dashcam="dashcam.show"
@dashcam-prefetch="dashcam.prefetch"
/>
<component
:is="dashcam.frame.value"
v-if="dashcam.open.value && dashcam.frame.value"
:journey="journey"
:section-label="activeSectionLabel"
:name="content.identity.name"
:city="content.identity.location.city"
:scene-ready="sceneState === 'running'"
@close="dashcam.close"
/>
</template>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import type { JourneySnapshot } from '@/journey/types';
import { readMusicPulse } from '@/audio/music-pulse';
import { playShutterClick } from '@/audio/switchboard-click';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { signRouteFor } from '@/journey/sign-routes';
import { isScrollIntentKey } from '@/composables/useKeyboardNavigation';
import {
cancelDashcamCapture,
readDashcam,
requestDashcamCapture,
setDashcamPointer,
} from '@/journey/dashcam-feed';
import {
DASHCAM_COORDINATES,
dashcamClock,
dashcamTitle,
frameBottomLeft,
frameTopLeft,
routePercent,
savePostcard,
type DashcamTelemetry,
} from '@/lib/dashcam-postcard';
/**
* DASHCAM's frame (lazy chunk): corner brackets, the REC dot on the bass,
* live telemetry and the two actions, over the live scene. Owns the title,
* the focus trap, scroll suppression, the pointer drift feed and the capture.
*/
const props = defineProps<{
journey: JourneySnapshot;
sectionLabel: string;
name: string;
city: string;
sceneReady: boolean;
}>();
const emit = defineEmits<{ close: [] }>();
/** REC dot sampling rate while open. */
const PULSE_HZ = 12;
/** A capture the scene has not answered in this long is dropped. */
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);
const telemetry = computed<DashcamTelemetry>(() => ({
sectionId: props.journey.activeSectionId,
sectionLabel: props.sectionLabel,
chapter: props.journey.chapter,
exit: signRouteFor(props.journey.activeSectionId).marker,
routePercent: routePercent(props.journey.routeProgress),
clock: dashcamClock(props.journey.activeSectionId, maintenanceProgress(props.journey), now.value),
name: props.name,
city: props.city,
}));
const title = computed(() => dashcamTitle(telemetry.value));
let previousTitle = '';
let ticker = 0;
let captureTimer = 0;
let shutterTimer = 0;
/** REC dot without music: a slow 0.8 Hz breath instead of a dead speck. */
const REC_BLINK_HZ = 0.8;
const sample = (): void => {
const pulse = readMusicPulse();
const rec = pulse > 0 ? pulse : 0.5 + 0.5 * Math.sin(performance.now() / 1000 * Math.PI * 2 * REC_BLINK_HZ);
dot.value?.style.setProperty('--rec', rec.toFixed(2));
const next = new Date();
if (next.getMinutes() !== now.value.getMinutes()) now.value = next;
};
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());
};
/* The page stays where it was: a scrollbar drag (the only input not
swallowed) snaps back to the locked offset. */
let lockedY = 0;
const onScroll = (): void => {
if (Math.round(window.scrollY) !== lockedY) window.scrollTo({ top: lockedY, behavior: 'instant' });
};
const shoot = (): void => {
if (busy.value || unavailable.value) return;
busy.value = true;
status.value = '';
const root = document.documentElement;
root.dataset.dashcamShutter = '';
window.clearTimeout(shutterTimer);
shutterTimer = window.setTimeout(() => { delete root.dataset.dashcamShutter; }, SHUTTER_MS);
playShutterClick();
const shot = { ...telemetry.value };
requestDashcamCapture((canvas) => {
window.clearTimeout(captureTimer);
void savePostcard(canvas, shot).then((outcome) => {
status.value = outcome === 'failed' ? 'Postcard could not be saved' : 'Postcard saved';
finish();
});
});
// Context loss mid-capture: the scene never answers; drop the request.
captureTimer = window.setTimeout(() => {
cancelDashcamCapture();
lost.value = true;
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(
(event.clientX / Math.max(1, window.innerWidth)) * 2 - 1,
(event.clientY / Math.max(1, window.innerHeight)) * 2 - 1,
performance.now(),
);
};
const suppress = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
};
const onKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Tab') {
const stops = [saveButton.value, closeButton.value].filter((el): el is HTMLButtonElement => el !== null && !el.disabled);
if (stops.length === 0) return;
const index = stops.indexOf(document.activeElement as HTMLButtonElement);
const next = index === -1 ? 0 : (index + (event.shiftKey ? -1 : 1) + stops.length) % stops.length;
event.preventDefault();
stops[next]?.focus();
return;
}
// Space and Enter still press a focused button; nothing scrolls the page.
if (isScrollIntentKey(event.key) && !(event.key === ' ' && event.target instanceof HTMLButtonElement)) suppress(event);
};
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;
document.title = title.value;
sample();
ticker = window.setInterval(sample, 1_000 / PULSE_HZ);
lockedY = Math.round(window.scrollY);
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('wheel', suppress, ACTIVE);
window.addEventListener('touchmove', suppress, ACTIVE);
window.addEventListener('keydown', onKeydown, CAPTURE);
saveButton.value?.focus();
});
onBeforeUnmount(() => {
window.clearInterval(ticker);
window.clearTimeout(captureTimer);
window.clearTimeout(shutterTimer);
delete document.documentElement.dataset.dashcamShutter;
cancelDashcamCapture();
window.removeEventListener('scroll', onScroll);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('wheel', suppress, ACTIVE);
window.removeEventListener('touchmove', suppress, ACTIVE);
window.removeEventListener('keydown', onKeydown, CAPTURE);
document.title = previousTitle;
});
</script>
<template>
<div
ref="surface"
class="dashcam-frame"
role="dialog"
aria-modal="true"
aria-label="Dashcam"
: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>
<span class="dashcam-frame__corner" data-corner="tl" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="tr" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="bl" aria-hidden="true"></span>
<span class="dashcam-frame__corner" data-corner="br" aria-hidden="true"></span>
<p class="dashcam-frame__plate" data-at="tl">
<span ref="dot" class="dashcam-frame__rec" aria-hidden="true"></span>REC {{ frameTopLeft(telemetry) }}
</p>
<p class="dashcam-frame__plate" data-at="tr"><time>{{ telemetry.clock }}</time></p>
<p class="dashcam-frame__plate" data-at="bl">{{ frameBottomLeft(telemetry) }}</p>
<p class="dashcam-frame__plate" data-at="br">{{ DASHCAM_COORDINATES }}<br>{{ name.toUpperCase() }}</p>
<p v-if="unavailable" class="dashcam-frame__lost" role="status">Scene unavailable</p>
<div class="dashcam-frame__actions">
<button ref="saveButton" type="button" :disabled="busy || unavailable" @click="shoot">Save postcard</button>
<button ref="closeButton" type="button" @click="emit('close')">Close (Esc)</button>
</div>
<p class="visually-hidden" role="status">{{ status }}</p>
</div>
</template>

View File

@@ -19,11 +19,14 @@ const props = withDefaults(
audioState?: AudioUiState;
audioMuted?: boolean;
audioVolume?: number;
/** DASHCAM: offer the shutter (live scene, motion allowed). */
dashcam?: boolean;
}>(),
{
audioState: () => ({ kind: "idle" }),
audioMuted: false,
audioVolume: 0.4,
dashcam: false,
},
);
@@ -36,6 +39,8 @@ const emit = defineEmits<{
audioResume: [];
audioMuteToggle: [];
audioVolumeChange: [volume: number];
dashcam: [];
dashcamPrefetch: [];
}>();
const controlRoot = ref<HTMLElement | null>(null);
@@ -282,6 +287,21 @@ onBeforeUnmount(() => {
<IconGlyph :name="action.icon" />
</button>
<button
v-if="dashcam"
class="drive-control-button drive-controls__dashcam"
type="button"
aria-label="Dashcam"
aria-keyshortcuts="c"
title="Dashcam (C)"
data-tooltip="Dashcam (C)"
@pointerenter="emit('dashcamPrefetch')"
@focus="emit('dashcamPrefetch')"
@click="emit('dashcam')"
>
<IconGlyph name="camera" />
</button>
<button
v-if="audioState.kind !== 'starting'"
ref="settingsButton"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, h, type FunctionalComponent } from 'vue';
import {
PhPause,
PhPlay,
@@ -9,7 +9,7 @@ import {
PhWaveform,
} from '@phosphor-icons/vue';
export type IconGlyphName = 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume';
export type IconGlyphName = 'camera' | 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume';
const props = withDefaults(defineProps<{
name: IconGlyphName;
@@ -18,7 +18,15 @@ 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,

View File

@@ -0,0 +1,77 @@
import { computed, markRaw, nextTick, onMounted, onUnmounted, shallowRef, watch, type Component, type Ref } from 'vue';
import { setDashcamOpen } from '@/journey/dashcam-feed';
import { isEditableTarget } from '@/composables/useKeyboardNavigation';
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)';
/**
* 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.
*/
export function useDashcam(sources: {
/** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */
allowed: Readonly<Ref<boolean>>;
sceneRunning: Readonly<Ref<boolean>>;
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
drift: () => boolean;
}) {
const open = shallowRef(false);
const frame = shallowRef<Component | null>(null);
const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
let loading: Promise<void> | null = null;
let riseTimer = 0;
const root = (): HTMLElement => document.documentElement;
const prefetch = (): Promise<void> => (loading ??= import('@/components/DashcamFrame.vue').then(
(module) => { frame.value = markRaw(module.default); },
() => { loading = null; },
));
const show = (): void => {
if (open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
if (window.matchMedia?.(NARROW).matches) return;
window.clearTimeout(riseTimer);
open.value = true;
setDashcamOpen(true, sources.drift());
root().dataset.dashcam = 'on';
void prefetch();
};
const close = (): void => {
if (!open.value) return;
open.value = false;
setDashcamOpen(false, false);
root().dataset.dashcam = 'rise';
riseTimer = window.setTimeout(() => { delete root().dataset.dashcam; }, RISE_MS);
void nextTick(() => document.querySelector<HTMLElement>('.drive-controls__dashcam')?.focus());
};
const onKeydown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return;
if (open.value && event.key === 'Escape') {
event.preventDefault();
close();
} else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') {
const wasOpen = open.value;
if (wasOpen) close();
else show();
if (wasOpen || open.value) event.preventDefault();
}
};
watch(sources.allowed, (allowed) => { if (!allowed) close(); });
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
window.clearTimeout(riseTimer);
if (open.value) setDashcamOpen(false, false);
delete root().dataset.dashcam;
});
return { open, frame, available, show, close, prefetch };
}

View File

@@ -24,7 +24,7 @@ const SCROLL_KEYS = new Set([
export const isScrollIntentKey = (key: string): boolean => SCROLL_KEYS.has(key);
const isEditableTarget = (target: EventTarget | null): boolean =>
export const isEditableTarget = (target: EventTarget | null): boolean =>
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||

View File

@@ -0,0 +1,57 @@
/**
* DASHCAM, the slot the DOM and the scene share (initial chunk, kept tiny).
* The DOM writes whether the dashcam is open, whether the lens may drift and
* the latest normalised pointer; the scene reads it inside its own frame and
* eases the lens itself (src/scene/world/dashcam-lens.ts). Nothing here
* schedules work.
*/
export interface DashcamFeed {
open: boolean;
/** Fine pointer on the high or medium tier: the lens follows the pointer. */
drift: boolean;
/** Pointer in [-1, 1], right and down positive. */
px: number;
py: number;
/** performance.now() of the last pointer move (the scene returns to centre after 3 s). */
movedAt: number;
}
const feed: DashcamFeed = { open: false, drift: false, px: 0, py: 0, movedAt: 0 };
export const readDashcam = (): Readonly<DashcamFeed> => feed;
export function setDashcamOpen(open: boolean, drift: boolean): void {
feed.open = open;
feed.drift = open && drift;
feed.px = 0;
feed.py = 0;
if (!open) capture = null;
}
export function setDashcamPointer(px: number, py: number, now: number): void {
feed.px = Math.max(-1, Math.min(1, px));
feed.py = Math.max(-1, Math.min(1, py));
feed.movedAt = now;
}
/**
* One queued capture. The scene calls it synchronously right after its final
* render pass with the WebGL canvas, the only moment the drawing buffer is
* valid (preserveDrawingBuffer is false).
*/
let capture: ((canvas: HTMLCanvasElement) => void) | null = null;
export function requestDashcamCapture(resolve: (canvas: HTMLCanvasElement) => void): void {
capture = resolve;
}
export function cancelDashcamCapture(): void {
capture = null;
}
/** The scene's side: take (and clear) the queued capture, if any. */
export function takeDashcamCapture(): ((canvas: HTMLCanvasElement) => void) | null {
const next = capture;
capture = null;
return next;
}

224
src/lib/dashcam-postcard.ts Normal file
View File

@@ -0,0 +1,224 @@
import { bucharestClock } from '@/lib/operations';
import { maintenanceClock } from '@/journey/maintenance-progress';
/**
* DASHCAM telemetry and the postcard (lazy dashcam chunk only). Every value
* is real: the route progress, the chapter, the exit, the Bucharest clock
* (the 03:12 window's own clock inside Infrastructure) and the identity.
*/
export interface DashcamTelemetry {
sectionId: string;
/** Navigation label, e.g. "Infrastructure". */
sectionLabel: string;
/** Scene chapter, e.g. "pre-dawn". */
chapter: string;
/** Exit marker, e.g. "04". */
exit: string;
/** Whole percent of the route travelled. */
routePercent: number;
/** HH:MM. */
clock: string;
name: string;
city: string;
}
/** Bucharest, as the dash plate shows it. */
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();
/** Live Bucharest time; inside Infrastructure the maintenance window's clock. */
export function dashcamClock(sectionId: string, maintenance: number | null, now: Date): string {
const live = (): string => bucharestClock(now);
return sectionId === 'infrastructure' && maintenance !== null ? maintenanceClock(maintenance, live) : live();
}
/** document.title while open, e.g. "DASHCAM · 47% · INFRASTRUCTURE · 03:51". */
export const dashcamTitle = (t: Pick<DashcamTelemetry, 'routePercent' | 'sectionLabel' | 'clock'>): string =>
`DASHCAM · ${t.routePercent}% · ${t.sectionLabel.toUpperCase()} · ${t.clock}`;
/** e.g. "night-shift-infrastructure-0351.png". */
export function postcardFilename(sectionId: string, clock: string): string {
const section = sectionId.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || 'route';
const digits = clock.replace(/\D/gu, '').slice(0, 4).padStart(4, '0');
return `night-shift-${section}-${digits}.png`;
}
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}`;
export const frameBottomLeft = (t: DashcamTelemetry): string =>
`ROUTE ${t.routePercent}% · ${t.sectionLabel.toUpperCase()}`;
/** The source rectangle that covers a dw x dh frame from a sw x sh image, centred. */
export function coverCrop(sw: number, sh: number, dw: number, dh: number): { sx: number; sy: number; sw: number; sh: number } {
if (!(sw > 0 && sh > 0 && dw > 0 && dh > 0)) return { sx: 0, sy: 0, sw: Math.max(0, sw), sh: Math.max(0, sh) };
const scale = Math.max(dw / sw, dh / sh);
const cropW = dw / scale;
const cropH = dh / scale;
return { sx: (sw - cropW) / 2, sy: (sh - cropH) / 2, sw: cropW, sh: cropH };
}
/** 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);
const inset = Math.round(44 * unit);
const pad = Math.round(22 * unit);
const font = Math.round(21 * unit);
return {
inset,
bracket: Math.round(70 * unit),
stroke: Math.max(1, Math.round(3 * unit)),
font,
small: Math.round(15 * unit),
left: inset + pad,
right: w - inset - pad,
top: inset + pad + font,
bottom: h - inset - pad,
lineGap: Math.round(font * 1.35),
dot: Math.round(7 * unit),
credit: h - Math.round(16 * unit),
};
}
/** The 2D context surface the postcard uses (a stub in tests). */
export type PostcardContext = Pick<
CanvasRenderingContext2D,
'drawImage' | 'fillRect' | 'fillText' | 'beginPath' | 'moveTo' | 'lineTo' | 'stroke' | 'arc' | 'fill'
| 'createLinearGradient' | 'save' | 'restore'
> & {
fillStyle: unknown;
strokeStyle: unknown;
lineWidth: number;
font: string;
textAlign: CanvasTextAlign;
textBaseline: CanvasTextBaseline;
shadowColor?: string;
shadowBlur?: number;
};
type Source = CanvasImageSource & { width: number; height: number };
const MONO = "'Geist Mono Variable', ui-monospace, monospace";
export function drawPostcard(ctx: PostcardContext, source: Source, t: DashcamTelemetry, w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT): void {
const crop = coverCrop(source.width, source.height, w, h);
ctx.fillStyle = '#05030a';
ctx.fillRect(0, 0, w, h);
if (crop.sw > 0 && crop.sh > 0) ctx.drawImage(source, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, w, h);
const l = postcardLayout(w, h);
// A soft fall-off at the top and bottom keeps the telemetry legible.
for (const [y0, y1] of [[0, h * 0.2], [h, h * 0.8]] as const) {
const shade = ctx.createLinearGradient(0, y0, 0, y1);
shade.addColorStop(0, 'rgba(4, 2, 10, 0.55)');
shade.addColorStop(1, 'rgba(4, 2, 10, 0)');
ctx.fillStyle = shade;
ctx.fillRect(0, Math.min(y0, y1), w, Math.abs(y1 - y0));
}
// Corner brackets.
ctx.strokeStyle = 'rgba(244, 238, 255, 0.92)';
ctx.lineWidth = l.stroke;
const a = l.inset;
const b = l.bracket;
for (const [x, y, dx, dy] of [[a, a, 1, 1], [w - a, a, -1, 1], [a, h - a, 1, -1], [w - a, h - a, -1, -1]] as const) {
ctx.beginPath();
ctx.moveTo(x, y + dy * b);
ctx.lineTo(x, y);
ctx.lineTo(x + dx * b, y);
ctx.stroke();
}
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.6)';
ctx.shadowBlur = Math.round(l.font * 0.4);
ctx.font = `500 ${l.font}px ${MONO}`;
ctx.textBaseline = 'alphabetic';
// REC dot, then the top-left plate.
ctx.fillStyle = '#ff3b4e';
ctx.beginPath();
ctx.arc(l.left + l.dot, l.top - l.font * 0.35, l.dot, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f4eeff';
ctx.textAlign = 'left';
ctx.fillText(`REC ${frameTopLeft(t)}`, l.left + l.dot * 3, l.top);
ctx.fillText(frameBottomLeft(t), l.left, l.bottom);
ctx.textAlign = 'right';
ctx.fillText(t.clock, l.right, l.top);
ctx.fillText(t.name.toUpperCase(), l.right, l.bottom);
ctx.fillText(DASHCAM_COORDINATES, l.right, l.bottom - l.lineGap);
ctx.font = `500 ${l.small}px ${MONO}`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255, 214, 147, 0.95)';
ctx.fillText(postcardCredit(t.name, t.city), w / 2, l.credit);
ctx.restore();
}
function openInTab(url: string): boolean {
// Browsers refuse top-level navigation to data: URLs; show it in a blank tab.
const tab = window.open('', '_blank');
if (tab === null) return false;
const image = tab.document.createElement('img');
image.src = url;
image.alt = 'Night Shift dashcam postcard';
tab.document.body.append(image);
return true;
}
export type PostcardOutcome = 'download' | 'tab' | 'failed';
/**
* Draws the postcard synchronously (the WebGL buffer is only valid in the
* capture task), then encodes and downloads it. Never throws.
*/
export function savePostcard(source: Source, t: DashcamTelemetry): Promise<PostcardOutcome> {
let canvas: HTMLCanvasElement;
try {
canvas = document.createElement('canvas');
canvas.width = POSTCARD_WIDTH;
canvas.height = POSTCARD_HEIGHT;
const ctx = canvas.getContext('2d');
if (ctx === null) return Promise.resolve('failed');
drawPostcard(ctx as unknown as PostcardContext, source, t);
} catch {
return Promise.resolve('failed');
}
const fallback = (): PostcardOutcome => {
try {
return openInTab(canvas.toDataURL('image/png')) ? 'tab' : 'failed';
} catch {
return 'failed';
}
};
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => {
if (blob === null) {
resolve(fallback());
return;
}
try {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = postcardFilename(t.sectionId, t.clock);
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
resolve('download');
} catch {
resolve(fallback());
}
}, 'image/png');
} catch {
resolve(fallback());
}
});
}

View File

@@ -0,0 +1,69 @@
import type { DashcamFeed } from '@/journey/dashcam-feed';
/** DASHCAM tunables (scene side). */
export const DASHCAM_LENS = Object.freeze({
/** Camera x travel at the viewport edge, world units. */
driftX: 1.6,
/** Look-at height travel at the viewport edge, world units. */
driftTargetY: 0.5,
/** Pointer smoothing time constant, seconds. */
driftSeconds: 0.35,
/** No pointer movement for this long returns the lens to centre. */
idleMs: 3_000,
/** Lens-pass letterbox bar height (each bar, fraction of the frame). */
letterbox: 0.16,
/** Bars close over this long. */
letterboxSeconds: 0.4,
});
/** Where the lens wants to be: the pointer, or centre when closed, idle or without drift. */
export function driftTarget(feed: Readonly<DashcamFeed>, now: number): { x: number; y: number } {
if (!feed.open || !feed.drift || now - feed.movedAt > DASHCAM_LENS.idleMs) return { x: 0, y: 0 };
return {
x: Math.max(-1, Math.min(1, feed.px)),
y: Math.max(-1, Math.min(1, feed.py)),
};
}
export interface DashcamLensState {
/** Added to the camera x. */
x: number;
/** Added to the look-at height. */
targetY: number;
/** Lens-pass letterbox term. */
letterbox: number;
}
/**
* Eases the pointer drift (~0.35 s) and the letterbox (400 ms, smoothstep)
* once per frame. Without drift (touch, low tier) the bars snap and the lens
* stays centred.
*/
export function createDashcamLens() {
let x = 0;
let y = 0;
let bars = 0;
let snap = false;
const state: DashcamLensState = { x: 0, targetY: 0, letterbox: 0 };
return {
update(feed: Readonly<DashcamFeed>, deltaSeconds: number, now: number): DashcamLensState {
const target = driftTarget(feed, now);
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) / DASHCAM_LENS.driftSeconds);
x += (target.x - x) * mix;
y += (target.y - y) * mix;
const goal = feed.open ? 1 : 0;
if (feed.open) snap = !feed.drift;
bars = snap
? goal
: goal > bars
? Math.min(goal, bars + deltaSeconds / DASHCAM_LENS.letterboxSeconds)
: Math.max(goal, bars - deltaSeconds / DASHCAM_LENS.letterboxSeconds);
// Settle exactly at rest so the closed dashcam costs nothing downstream.
if (!feed.open && Math.abs(x) < 1e-4 && Math.abs(y) < 1e-4) x = y = 0;
state.x = x * DASHCAM_LENS.driftX;
state.targetY = -y * DASHCAM_LENS.driftTargetY;
state.letterbox = DASHCAM_LENS.letterbox * bars * bars * (3 - 2 * bars);
return state;
},
};
}

View File

@@ -43,6 +43,8 @@ import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
import { createDashcamLens } from '@/scene/world/dashcam-lens';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
@@ -244,6 +246,8 @@ export async function createProceduralSceneGeneration(
let riseUntil = 0;
let gantryState = '';
let dawnState: DawnStep | '' = '';
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
const dashcamLens = createDashcamLens();
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -651,6 +655,7 @@ export async function createProceduralSceneGeneration(
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
) * (1 - outro);
const craneShift = cameraState.shift * crane;
const dashcam = dashcamLens.update(readDashcam(), deltaSeconds, now);
camera.setViewOffset(
width,
height,
@@ -660,13 +665,13 @@ export async function createProceduralSceneGeneration(
height,
);
camera.position.set(
THREE.MathUtils.lerp(0, cameraState.x, crane),
THREE.MathUtils.lerp(0, cameraState.x, crane) + dashcam.x,
THREE.MathUtils.lerp(46, cameraState.y, crane),
THREE.MathUtils.lerp(92, cameraState.z, crane),
);
camera.lookAt(
THREE.MathUtils.lerp(0, cameraState.targetX, crane),
THREE.MathUtils.lerp(64, cameraState.targetY, crane),
THREE.MathUtils.lerp(64, cameraState.targetY, crane) + dashcam.targetY,
THREE.MathUtils.lerp(-720, cameraState.targetZ, crane),
);
camera.rotation.z += cameraState.roll * crane;
@@ -737,7 +742,10 @@ export async function createProceduralSceneGeneration(
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
postProcessor.setLens({
time: elapsedSeconds,
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
letterbox: Math.max(
0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
dashcam.letterbox,
),
energy: overdrive,
rain: service.rain,
rainTime: elapsedSeconds,
@@ -747,6 +755,15 @@ export async function createProceduralSceneGeneration(
} else {
renderer.render(scene, camera);
}
// DASHCAM: the drawing buffer is only valid now, inside this task.
const capture = takeDashcamCapture();
if (capture !== null) {
try {
capture(canvas);
} catch {
// A failed postcard never takes the scene down.
}
}
renderedFrames += 1;
},
dispose() {

185
src/styles/dashcam.css Normal file
View File

@@ -0,0 +1,185 @@
/* ---------- DASHCAM ---------- */
/* The shutter needs the live scene behind the reading window: the button
only shows while the reading stage is live, and not on phones (no room
in the gantry bay). AppShell withholds it entirely without motion. */
.reading-stage:not([data-reading-stage='live']) ~ .drive-controls .drive-controls__dashcam { display: none; }
@media (max-width: 767px) {
html .drive-controls__dashcam { display: none; }
}
/* Open (html[data-dashcam='on']): the scroll is frozen by the frame itself
(wheel, touch and keys are swallowed, a stray scroll snaps back), never by
overflow: hidden, which would drop the scrollbar and resize the canvas
mid-fade. The scene keeps rendering underneath. */
/* The plates fold away like the interlude's (gantry.css), headings last;
the gantry, its HUD plate and the bay fade with them. */
html[data-dashcam='on'] .reading-stage .portfolio-main > * > * {
opacity: 0;
transition: opacity 130ms cubic-bezier(0.5, 0, 0.75, 0) 60ms;
}
html[data-dashcam='on'] .reading-stage .portfolio-main > * > :not(h1, h2, :has(h1, h2)) {
transform: translateY(-24px);
transition:
opacity 160ms cubic-bezier(0.2, 0.7, 0.3, 1),
transform 160ms cubic-bezier(0.5, 0, 0.75, 0);
}
html[data-dashcam='on'] .reading-stage .portfolio-section::before,
html[data-dashcam='on'] .reading-stage .portfolio-section::after,
html[data-dashcam='on'] .site-header,
html[data-dashcam='on'] .journey-hud,
html[data-dashcam='on'] .drive-controls,
html[data-dashcam='on'] .content-focus-cue {
opacity: 0;
visibility: hidden;
animation: none;
transition: opacity 200ms ease, visibility 0s linear 200ms;
}
/* Closing (data-dashcam='rise', 400 ms): the plates come back up. */
@media (prefers-reduced-motion: no-preference) {
html[data-dashcam='rise'] .reading-stage .portfolio-main > * > * {
animation: plate-fade 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
html[data-dashcam='rise'] .reading-stage .portfolio-main > * > :not(h1, h2, :has(h1, h2)) {
animation: plate-rise 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
html[data-dashcam='rise'] .site-header,
html[data-dashcam='rise'] .journey-hud,
html[data-dashcam='rise'] .drive-controls {
animation: plate-fade 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
/* ---------- The frame (DashcamFrame.vue, lazy) ----------
Kept in the initial stylesheet: the trusted artifact contract links every
reachable stylesheet in the prerendered HTML, so the lazy chunk carries
no CSS of its own. */
.dashcam-frame {
--dash-ink: #f4eeff;
--dash-inset: clamp(16px, 3vw, 44px);
/* Bar height: the lens pass's 0.16, or the outro's upper bar when that is
taller (world.css), so the arrival's aubergine never peeks above black. */
--dash-bar: max(16%, calc(var(--gantry-h, 72px) + min(5vh, 44px)));
position: fixed;
z-index: calc(var(--z-popover, 50) + 10);
inset: 0;
cursor: crosshair;
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;
}
/* 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. */
.dashcam-frame__bar {
position: absolute;
inset: 0 0 auto;
block-size: var(--dash-bar);
background: #000;
pointer-events: none;
transform-origin: top;
animation: dashcam-bar 400ms cubic-bezier(0.4, 0, 0.6, 1) both;
}
.dashcam-frame__bar[data-edge='bottom'] {
inset: auto 0 0;
transform-origin: bottom;
}
.dashcam-frame__corner {
position: absolute;
inline-size: clamp(28px, 4.4vw, 70px);
block-size: clamp(28px, 4.4vw, 70px);
border: 0 solid rgb(244 238 255 / 90%);
pointer-events: none;
}
.dashcam-frame__corner[data-corner='tl'] { inset: var(--dash-inset) auto auto var(--dash-inset); border-width: 3px 0 0 3px; }
.dashcam-frame__corner[data-corner='tr'] { inset: var(--dash-inset) var(--dash-inset) auto auto; border-width: 3px 3px 0 0; }
.dashcam-frame__corner[data-corner='bl'] { inset: auto auto var(--dash-inset) var(--dash-inset); border-width: 0 0 3px 3px; }
.dashcam-frame__corner[data-corner='br'] { inset: auto var(--dash-inset) var(--dash-inset) auto; border-width: 0 3px 3px 0; }
.dashcam-frame__plate {
position: absolute;
z-index: 1;
margin: 0;
pointer-events: none;
white-space: nowrap;
}
.dashcam-frame__plate[data-at='tl'] { inset: calc(var(--dash-inset) + 18px) auto auto calc(var(--dash-inset) + 20px); }
.dashcam-frame__plate[data-at='tr'] { inset: calc(var(--dash-inset) + 18px) calc(var(--dash-inset) + 20px) auto auto; text-align: right; font-size: 1.15em; }
.dashcam-frame__plate[data-at='bl'] { inset: auto auto calc(var(--dash-inset) + 18px) calc(var(--dash-inset) + 20px); }
.dashcam-frame__plate[data-at='br'] { inset: auto calc(var(--dash-inset) + 20px) calc(var(--dash-inset) + 18px) auto; text-align: right; }
.dashcam-frame__rec {
--rec: 0;
display: inline-block;
inline-size: 0.7em;
block-size: 0.7em;
margin-inline-end: 0.6em;
border-radius: 50%;
background: #ff3b4e;
vertical-align: -0.02em;
opacity: calc(0.55 + var(--rec) * 0.45);
transform: scale(calc(0.85 + var(--rec) * 0.45));
box-shadow: 0 0 calc(4px + var(--rec) * 14px) rgb(255 59 78 / 80%);
transition: transform 80ms linear, opacity 80ms linear;
}
.dashcam-frame__lost {
position: absolute;
inset: 50% auto auto 50%;
margin: 0;
padding: 0.6em 1.2em;
translate: -50% -50%;
border: 1px solid rgb(244 238 255 / 50%);
background: rgb(8 4 18 / 75%);
}
.dashcam-frame__actions {
position: absolute;
z-index: 2;
inset: auto 50% calc(var(--dash-inset) + 10px) auto;
display: flex;
gap: 8px;
translate: 50% 0;
}
.dashcam-frame__actions button {
min-block-size: 44px;
padding: 0 16px;
border: 1px solid rgb(244 238 255 / 45%);
border-radius: 0;
background: rgb(14 8 28 / 72%);
color: var(--dash-ink);
font: inherit;
letter-spacing: inherit;
text-transform: uppercase;
cursor: pointer;
}
.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. */
html[data-dashcam-shutter] .dashcam-frame,
html[data-dashcam-shutter] .dashcam-frame__bar { animation: none; }
html[data-dashcam-shutter] .dashcam-frame::after {
content: '';
position: absolute;
inset: 0;
block-size: auto;
background: #fff;
pointer-events: none;
animation: dashcam-shutter 120ms ease-out both;
}
@keyframes dashcam-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dashcam-bar {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@keyframes dashcam-shutter {
from { opacity: 0.85; }
to { opacity: 0; }
}
@media (forced-colors: active) {
.dashcam-frame__corner { border-color: CanvasText; }
.dashcam-frame__actions button { border-color: ButtonText; }
}