feat: dashcam photo mode with telemetry frame, lens drift and postcard capture
@@ -47,3 +47,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
||||
- **Known weaknesses:** the 92%-wide panel hides the board while reading (visible at chapter entry, in gaps between case studies and at exit); phone screenshots read dim; ~140 px on phones; test-only telemetry.
|
||||
- Screenshots: docs/overnight/round5/.
|
||||
|
||||
|
||||
## Round 6: Dashcam (photo mode + postcard)
|
||||
- **What:** a camera button in the drive controls (or key `c`) folds the reading plates away, closes letterbox bars, and shows a dashcam frame with live telemetry (REC dot on the bass, chapter + exit, Bucharest clock, route %, coordinates + name). The lens drifts with the pointer (+-1.6 units, re-centres after 3 s idle). "Save postcard" downloads a 1600x900 PNG of the live scene with credit line, a white shutter flash and a synthesized shutter click when sound is on. Tab title becomes "DASHCAM · 47% · INFRASTRUCTURE · 03:51" while open. Esc/c/Close exits and returns focus. Absent for reduced motion/static/visitor-pause and below 768 px.
|
||||
- **Why:** the recurring weakness of rounds 1-5: the reading panels hide the world. This gives a juror one deliberate full-screen moment, and a shareable artefact.
|
||||
- **Build:** journey/dashcam-feed.ts, scene/world/dashcam-lens.ts, composables/useDashcam.ts, lazy components/DashcamFrame.vue + lib/dashcam-postcard.ts + audio/switchboard-click.ts, styles/dashcam.css. 820 tests, initial JS 117.73 KB gz (2.3 KB left), lazy chunk 3.7 KB gz.
|
||||
- **Verdict: KEEP with fixes** (Fable, hardware GL): Contact bars were striped (aubergine outro bars under black), now frame draws its own bars; canvas resized on open due to overflow lock (removed); focus dropped after save; `c` worked on phones where the button is hidden; round 5's HUD exit tab had been dropped, restored; REC dot got a no-music blink.
|
||||
- **Known weaknesses:** postcard is the 1440x900 buffer scaled to 1600 (slightly soft) and keeps the lens bars; no phone version; shutter click only while soundtrack runs; two chapter-label vocabularies in the frame.
|
||||
- Screenshots: docs/overnight/round6/ (postcard.webp is the real downloaded file).
|
||||
|
||||
BIN
docs/overnight/round6/before-1440-infra-reading.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/overnight/round6/builds-open-billboard.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/overnight/round6/contact-before-fix-stripes.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/overnight/round6/contact-open-dawn.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/overnight/round6/infra-drift-left.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/overnight/round6/infra-open.webp
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/overnight/round6/open-1024x700.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/overnight/round6/postcard.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
@@ -145,7 +145,7 @@ const totals = {
|
||||
firstLiveScene: sum('first-live-scene'),
|
||||
deferredProjectVisuals: sum('project-still') + sum('chart'),
|
||||
highTierExtras: sum('high-tier-extras'),
|
||||
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart'),
|
||||
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart') + sum('deferred-non-video'),
|
||||
deferredAudio: sum('soundtrack-audio'),
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ export const classifyArtifact = (path, source = '') => {
|
||||
if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js';
|
||||
if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras';
|
||||
if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene';
|
||||
// DASHCAM: the photo-mode frame and postcard, loaded on first open.
|
||||
if (/^assets\/DashcamFrame-[^/]+\.(?:js|css)$/u.test(path)) return 'deferred-non-video';
|
||||
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
48
src/audio/switchboard-click.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
221
src/components/DashcamFrame.vue
Normal 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
src/composables/useDashcam.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
57
src/journey/dashcam-feed.ts
Normal 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
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
69
src/scene/world/dashcam-lens.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -70,6 +70,8 @@ describe('release budgets', () => {
|
||||
expect(classifyArtifact('assets/nightdrive-drive-lift.opus', '')).toBe('soundtrack-audio');
|
||||
expect(classifyArtifact('assets/nightdrive-drive-lift.mp3', '')).toBe('soundtrack-audio');
|
||||
expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
|
||||
expect(classifyArtifact('assets/DashcamFrame-c.js', 'postcard')).toBe('deferred-non-video');
|
||||
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
|
||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||
});
|
||||
|
||||
|
||||
169
tests/unit/dashcam-controls.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import DriveControls from '@/components/DriveControls.vue';
|
||||
import { useDashcam } from '@/composables/useDashcam';
|
||||
import { readDashcam } from '@/journey/dashcam-feed';
|
||||
import { validateProductionInputs } from '@/content/schemas';
|
||||
import { render } from '@/entry-server';
|
||||
import type { MotionPolicy } from '@/journey/motion-policy';
|
||||
|
||||
const fixture = (name: string): unknown =>
|
||||
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
||||
|
||||
const policy: MotionPolicy = {
|
||||
allowSceneRequest: true,
|
||||
allowAutomaticVideo: true,
|
||||
maximumTier: 'high',
|
||||
reason: 'full',
|
||||
};
|
||||
|
||||
describe('dashcam server HTML', () => {
|
||||
it('renders nothing of the dashcam on the server', async () => {
|
||||
const html = await render('/', validateProductionInputs(
|
||||
fixture('portfolio-content.valid.json'),
|
||||
fixture('asset-manifest.valid.json'),
|
||||
fixture('site-config.preview.json'),
|
||||
new URL('file:///prepared-assets/'),
|
||||
));
|
||||
expect(html).not.toMatch(/dashcam/iu);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashcam button', () => {
|
||||
const mountControls = (dashcam?: boolean) => mount(DriveControls, {
|
||||
props: {
|
||||
policy,
|
||||
maximumQuality: 'high',
|
||||
sceneStatus: 'running',
|
||||
audioState: { kind: 'idle' },
|
||||
...(dashcam === undefined ? {} : { dashcam }),
|
||||
},
|
||||
});
|
||||
|
||||
it('is absent unless the shell offers it', () => {
|
||||
expect(mountControls().find('.drive-controls__dashcam').exists()).toBe(false);
|
||||
expect(mountControls(false).find('.drive-controls__dashcam').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens on click and prefetches the frame on hover and focus', async () => {
|
||||
const wrapper = mountControls(true);
|
||||
const button = wrapper.get('.drive-controls__dashcam');
|
||||
expect(button.attributes('aria-label')).toBe('Dashcam');
|
||||
expect(button.attributes('aria-keyshortcuts')).toBe('c');
|
||||
expect(button.find('svg').exists()).toBe(true);
|
||||
await button.trigger('pointerenter');
|
||||
await button.trigger('focus');
|
||||
await button.trigger('click');
|
||||
expect(wrapper.emitted('dashcamPrefetch')).toHaveLength(2);
|
||||
expect(wrapper.emitted('dashcam')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashcam', () => {
|
||||
let stage: HTMLElement;
|
||||
const allowed = ref(true);
|
||||
const running = ref(true);
|
||||
|
||||
const host = () => mount(defineComponent({
|
||||
setup() {
|
||||
const dashcam = useDashcam({ allowed, sceneRunning: running, drift: () => true });
|
||||
return () => h('button', { class: 'drive-controls__dashcam', onClick: dashcam.show });
|
||||
},
|
||||
}), { attachTo: document.body });
|
||||
|
||||
const press = (key: string, init: KeyboardEventInit = {}): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', { key, cancelable: true, bubbles: true, ...init });
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
allowed.value = true;
|
||||
running.value = true;
|
||||
stage = document.createElement('div');
|
||||
stage.className = 'reading-stage';
|
||||
stage.dataset.readingStage = 'live';
|
||||
document.body.append(stage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stage.remove();
|
||||
vi.useRealTimers();
|
||||
delete document.documentElement.dataset.dashcam;
|
||||
});
|
||||
|
||||
it('opens and closes on c, then lifts the rise state after 400 ms', () => {
|
||||
const wrapper = host();
|
||||
expect(press('c').defaultPrevented).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');
|
||||
expect(document.documentElement.dataset.dashcam).toBe('rise');
|
||||
expect(readDashcam().open).toBe(false);
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const wrapper = host();
|
||||
press('c');
|
||||
expect(press('Escape').defaultPrevented).toBe(true);
|
||||
expect(readDashcam().open).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ignores c with modifiers or inside an editable field', () => {
|
||||
const wrapper = host();
|
||||
expect(press('c', { ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
const input = document.createElement('input');
|
||||
document.body.append(input);
|
||||
const event = new KeyboardEvent('keydown', { key: 'c', cancelable: true, bubbles: true });
|
||||
input.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(document.documentElement.dataset.dashcam).toBeUndefined();
|
||||
input.remove();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['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) => {
|
||||
const wrapper = host();
|
||||
arrange();
|
||||
expect(press('c').defaultPrevented).toBe(false);
|
||||
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 () => {
|
||||
const wrapper = host();
|
||||
press('c');
|
||||
allowed.value = false;
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readDashcam().open).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
197
tests/unit/dashcam-feed.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
cancelDashcamCapture,
|
||||
readDashcam,
|
||||
requestDashcamCapture,
|
||||
setDashcamOpen,
|
||||
setDashcamPointer,
|
||||
takeDashcamCapture,
|
||||
} from '@/journey/dashcam-feed';
|
||||
import { DASHCAM_LENS, createDashcamLens, driftTarget } from '@/scene/world/dashcam-lens';
|
||||
import {
|
||||
chapterLabel,
|
||||
dashcamClock,
|
||||
dashcamTitle,
|
||||
frameBottomLeft,
|
||||
frameTopLeft,
|
||||
postcardCredit,
|
||||
postcardFilename,
|
||||
routePercent,
|
||||
type DashcamTelemetry,
|
||||
} from '@/lib/dashcam-postcard';
|
||||
import { attachMusicPulse, createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse';
|
||||
import { playShutterClick } from '@/audio/switchboard-click';
|
||||
|
||||
afterEach(() => {
|
||||
setDashcamOpen(false, false);
|
||||
setMusicPulseAudible(false);
|
||||
attachMusicPulse(null);
|
||||
});
|
||||
|
||||
describe('dashcam feed', () => {
|
||||
it('opens with drift only when asked, and resets the pointer on every change', () => {
|
||||
setDashcamOpen(true, true);
|
||||
setDashcamPointer(0.5, -0.25, 100);
|
||||
expect(readDashcam()).toMatchObject({ open: true, drift: true, px: 0.5, py: -0.25, movedAt: 100 });
|
||||
setDashcamOpen(false, true);
|
||||
expect(readDashcam()).toMatchObject({ open: false, drift: false, px: 0, py: 0 });
|
||||
setDashcamOpen(true, false);
|
||||
expect(readDashcam().drift).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps the pointer to [-1, 1]', () => {
|
||||
setDashcamOpen(true, true);
|
||||
setDashcamPointer(4, -9, 0);
|
||||
expect(readDashcam()).toMatchObject({ px: 1, py: -1 });
|
||||
});
|
||||
|
||||
it('hands a capture to the scene exactly once', () => {
|
||||
const resolve = vi.fn();
|
||||
requestDashcamCapture(resolve);
|
||||
expect(takeDashcamCapture()).toBe(resolve);
|
||||
expect(takeDashcamCapture()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a queued capture on cancel and on close', () => {
|
||||
requestDashcamCapture(vi.fn());
|
||||
cancelDashcamCapture();
|
||||
expect(takeDashcamCapture()).toBeNull();
|
||||
setDashcamOpen(true, false);
|
||||
requestDashcamCapture(vi.fn());
|
||||
setDashcamOpen(false, false);
|
||||
expect(takeDashcamCapture()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashcam lens', () => {
|
||||
const feed = (overrides: Partial<ReturnType<typeof readDashcam>> = {}) => ({
|
||||
open: true, drift: true, px: 1, py: 1, movedAt: 0, ...overrides,
|
||||
});
|
||||
|
||||
it('targets the pointer while open with drift, centre otherwise', () => {
|
||||
expect(driftTarget(feed({ px: 0.4, py: -0.6 }), 10)).toEqual({ x: 0.4, y: -0.6 });
|
||||
expect(driftTarget(feed({ px: 7, py: -7 }), 10)).toEqual({ x: 1, y: -1 });
|
||||
expect(driftTarget(feed({ open: false }), 10)).toEqual({ x: 0, y: 0 });
|
||||
expect(driftTarget(feed({ drift: false }), 10)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('returns to centre after 3 s without pointer movement', () => {
|
||||
expect(driftTarget(feed(), DASHCAM_LENS.idleMs)).toEqual({ x: 1, y: 1 });
|
||||
expect(driftTarget(feed(), DASHCAM_LENS.idleMs + 1)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('eases the drift and never exceeds +-1.6 x and +-0.5 target height', () => {
|
||||
const lens = createDashcamLens();
|
||||
const first = { ...lens.update(feed(), 1 / 60, 0) };
|
||||
expect(first.x).toBeGreaterThan(0);
|
||||
expect(first.x).toBeLessThan(DASHCAM_LENS.driftX * 0.1);
|
||||
let state = first;
|
||||
for (let i = 0; i < 600; i += 1) state = lens.update(feed(), 1 / 60, 0);
|
||||
expect(state.x).toBeCloseTo(1.6, 3);
|
||||
expect(state.targetY).toBeCloseTo(-0.5, 3);
|
||||
for (let i = 0; i < 600; i += 1) state = lens.update(feed({ px: -1, py: -1 }), 1 / 60, 0);
|
||||
expect(state.x).toBeGreaterThanOrEqual(-1.6);
|
||||
expect(state.targetY).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
|
||||
it('closes the letterbox to 0.16 over 400 ms and opens it again', () => {
|
||||
const lens = createDashcamLens();
|
||||
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
|
||||
expect(lens.update(feed(), 0.2, 0).letterbox).toBeCloseTo(0.16, 5);
|
||||
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBeCloseTo(0.08, 5);
|
||||
expect(lens.update(feed({ open: false }), 0.2, 0).letterbox).toBe(0);
|
||||
});
|
||||
|
||||
it('snaps the bars and holds centre without drift (touch, low tier)', () => {
|
||||
const lens = createDashcamLens();
|
||||
const open = lens.update(feed({ drift: false }), 1 / 60, 0);
|
||||
expect(Math.abs(open.x) + Math.abs(open.targetY)).toBe(0);
|
||||
expect(open.letterbox).toBeCloseTo(0.16, 10);
|
||||
expect(lens.update(feed({ open: false, drift: false }), 1 / 60, 0).letterbox).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashcam telemetry', () => {
|
||||
const telemetry: DashcamTelemetry = {
|
||||
sectionId: 'infrastructure',
|
||||
sectionLabel: 'Infrastructure',
|
||||
chapter: 'pre-dawn',
|
||||
exit: '04',
|
||||
routePercent: 47,
|
||||
clock: '03:51',
|
||||
name: 'Luca Pîslaru',
|
||||
city: 'Bucharest',
|
||||
};
|
||||
|
||||
it('formats the document title', () => {
|
||||
expect(dashcamTitle(telemetry)).toBe('DASHCAM · 47% · 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(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);
|
||||
expect(routePercent(1.4)).toBe(100);
|
||||
expect(routePercent(-1)).toBe(0);
|
||||
expect(routePercent(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('names the postcard after the section and the clock', () => {
|
||||
expect(postcardFilename('infrastructure', '03:51')).toBe('night-shift-infrastructure-0351.png');
|
||||
expect(postcardFilename('selected-builds', '23:05')).toBe('night-shift-selected-builds-2305.png');
|
||||
expect(postcardFilename('', '7:5')).toBe('night-shift-route-0075.png');
|
||||
});
|
||||
|
||||
it('uses the live Bucharest clock outside the maintenance window', () => {
|
||||
const now = new Date('2026-01-15T01:51:00Z');
|
||||
expect(dashcamClock('profile', null, now)).toBe('03:51');
|
||||
expect(dashcamClock('infrastructure', null, now)).toBe('03:51');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchboard click', () => {
|
||||
it('stays silent and creates nothing without an audible soundtrack', () => {
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
expect(playShutterClick()).toBe(false);
|
||||
});
|
||||
|
||||
it('plays into the mix bus at the switchboard gain when audible', () => {
|
||||
const gains: { gain: { setValueAtTime: ReturnType<typeof vi.fn>; linearRampToValueAtTime: ReturnType<typeof vi.fn>; exponentialRampToValueAtTime: ReturnType<typeof vi.fn> }; connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
||||
const context = {
|
||||
state: 'running',
|
||||
sampleRate: 1000,
|
||||
currentTime: 2,
|
||||
createGain: () => {
|
||||
const node = {
|
||||
gain: { value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
context: undefined as unknown,
|
||||
};
|
||||
node.context = context;
|
||||
gains.push(node);
|
||||
return node;
|
||||
},
|
||||
createAnalyser: () => { throw new Error('no analyser'); },
|
||||
createBuffer: (_c: number, length: number) => ({ getChannelData: () => new Float32Array(length) }),
|
||||
createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null }),
|
||||
};
|
||||
const destination = {} as AudioNode;
|
||||
createPulseTap(context as unknown as BaseAudioContext, destination);
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
setMusicPulseAudible(true);
|
||||
const bus = readSwitchboard();
|
||||
expect(bus).toBe(gains[0]);
|
||||
expect(playShutterClick()).toBe(true);
|
||||
const envelope = gains[1]!;
|
||||
expect(envelope.connect).toHaveBeenCalledWith(bus);
|
||||
expect(envelope.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0.18, expect.any(Number));
|
||||
attachMusicPulse(null);
|
||||
expect(readSwitchboard()).toBeNull();
|
||||
});
|
||||
});
|
||||
127
tests/unit/dashcam-postcard.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DASHCAM_COORDINATES,
|
||||
POSTCARD_HEIGHT,
|
||||
POSTCARD_WIDTH,
|
||||
coverCrop,
|
||||
drawPostcard,
|
||||
postcardLayout,
|
||||
type DashcamTelemetry,
|
||||
type PostcardContext,
|
||||
} from '@/lib/dashcam-postcard';
|
||||
|
||||
const telemetry: DashcamTelemetry = {
|
||||
sectionId: 'infrastructure',
|
||||
sectionLabel: 'Infrastructure',
|
||||
chapter: 'pre-dawn',
|
||||
exit: '04',
|
||||
routePercent: 47,
|
||||
clock: '03:51',
|
||||
name: 'Luca Pîslaru',
|
||||
city: 'Bucharest',
|
||||
};
|
||||
|
||||
interface TextCall { text: string; x: number; y: number; align: CanvasTextAlign; font: string }
|
||||
|
||||
function stubContext() {
|
||||
const texts: TextCall[] = [];
|
||||
const images: number[][] = [];
|
||||
const ctx = {
|
||||
fillStyle: '' as unknown,
|
||||
strokeStyle: '' as unknown,
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
textAlign: 'start' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
||||
drawImage: vi.fn((_source: unknown, ...rest: number[]) => { images.push(rest); }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn((text: string, x: number, y: number) => {
|
||||
texts.push({ text, x, y, align: ctx.textAlign, font: ctx.font });
|
||||
}),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
};
|
||||
return { ctx: ctx as unknown as PostcardContext & typeof ctx, texts, images };
|
||||
}
|
||||
|
||||
const source = (width: number, height: number) => ({ width, height }) as unknown as HTMLCanvasElement;
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it('crops a taller source to the 16:9 centre', () => {
|
||||
const crop = coverCrop(1440, 1080, 1600, 900);
|
||||
expect(crop.sx).toBe(0);
|
||||
expect(crop.sw).toBe(1440);
|
||||
expect(crop.sh).toBeCloseTo(810, 6);
|
||||
expect(crop.sy).toBeCloseTo(135, 6);
|
||||
});
|
||||
|
||||
it('keeps an exact-ratio source whole and tolerates an empty one', () => {
|
||||
expect(coverCrop(3200, 1800, 1600, 900)).toEqual({ sx: 0, sy: 0, sw: 3200, sh: 1800 });
|
||||
expect(coverCrop(0, 0, 1600, 900)).toEqual({ sx: 0, sy: 0, sw: 0, sh: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('postcardLayout', () => {
|
||||
it('keeps every plate inside the frame at 1600 x 900', () => {
|
||||
const l = postcardLayout();
|
||||
expect(l.inset).toBe(44);
|
||||
expect(l.left).toBeGreaterThan(l.inset);
|
||||
expect(l.right).toBeLessThan(POSTCARD_WIDTH - l.inset);
|
||||
expect(l.top - l.font).toBeGreaterThan(l.inset);
|
||||
expect(l.bottom - l.lineGap - l.font).toBeGreaterThan(POSTCARD_HEIGHT / 2);
|
||||
expect(l.bottom).toBeLessThan(POSTCARD_HEIGHT - l.inset);
|
||||
expect(l.credit).toBeGreaterThan(l.bottom);
|
||||
expect(l.credit).toBeLessThan(POSTCARD_HEIGHT);
|
||||
});
|
||||
|
||||
it('scales with the smaller axis', () => {
|
||||
expect(postcardLayout(800, 450).inset).toBe(22);
|
||||
expect(postcardLayout(800, 900).inset).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawPostcard', () => {
|
||||
it('covers the frame with the scene and writes the telemetry and credit', () => {
|
||||
const { ctx, texts, images } = stubContext();
|
||||
drawPostcard(ctx, source(2880, 1800), telemetry);
|
||||
const l = postcardLayout();
|
||||
|
||||
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',
|
||||
'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['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' });
|
||||
expect(texts.every((call) => call.font.includes('Geist Mono'))).toBe(true);
|
||||
// Four corner brackets.
|
||||
expect(ctx.stroke).toHaveBeenCalledTimes(4);
|
||||
expect(ctx.save).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.restore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still draws the chrome over an empty source', () => {
|
||||
const { ctx, texts, images } = stubContext();
|
||||
drawPostcard(ctx, source(0, 0), telemetry);
|
||||
expect(images).toEqual([]);
|
||||
expect(texts).toHaveLength(6);
|
||||
});
|
||||
});
|
||||