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.
|
- **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/.
|
- 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'),
|
firstLiveScene: sum('first-live-scene'),
|
||||||
deferredProjectVisuals: sum('project-still') + sum('chart'),
|
deferredProjectVisuals: sum('project-still') + sum('chart'),
|
||||||
highTierExtras: sum('high-tier-extras'),
|
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'),
|
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\/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-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';
|
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 (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
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/headlight.css';
|
||||||
import './styles/route-log.css';
|
import './styles/route-log.css';
|
||||||
import './styles/billboard.css';
|
import './styles/billboard.css';
|
||||||
|
import './styles/dashcam.css';
|
||||||
|
|
||||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ let audible = false;
|
|||||||
|
|
||||||
export function attachMusicPulse(node: AnalyserNode | null): void {
|
export function attachMusicPulse(node: AnalyserNode | null): void {
|
||||||
analyser = node;
|
analyser = node;
|
||||||
|
if (node === null) bus = null;
|
||||||
bins = node === null ? null : new Uint8Array(new ArrayBuffer(node.frequencyBinCount));
|
bins = node === null ? null : new Uint8Array(new ArrayBuffer(node.frequencyBinCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ export function setMusicPulseAudible(value: boolean): void {
|
|||||||
audible = value;
|
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. */
|
/** Bass energy in [0, 1]: the mean of roughly 90-380 Hz. */
|
||||||
export function readMusicPulse(): number {
|
export function readMusicPulse(): number {
|
||||||
if (!audible || analyser === null || bins === null) return 0;
|
if (!audible || analyser === null || bins === null) return 0;
|
||||||
@@ -37,5 +45,6 @@ export function createPulseTap(context: BaseAudioContext, destination: AudioNode
|
|||||||
} catch {
|
} catch {
|
||||||
attachMusicPulse(null);
|
attachMusicPulse(null);
|
||||||
}
|
}
|
||||||
|
bus = tap;
|
||||||
return 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 { useRouteMemory } from '@/composables/useRouteMemory';
|
||||||
import { useRouteLog } from '@/composables/useRouteLog';
|
import { useRouteLog } from '@/composables/useRouteLog';
|
||||||
import { useBillboard } from '@/composables/useBillboard';
|
import { useBillboard } from '@/composables/useBillboard';
|
||||||
|
import { useDashcam } from '@/composables/useDashcam';
|
||||||
import { assetUrl } from '@/content/asset-url';
|
import { assetUrl } from '@/content/asset-url';
|
||||||
import { signRouteFor } from '@/journey/sign-routes';
|
import { signRouteFor } from '@/journey/sign-routes';
|
||||||
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
||||||
@@ -160,6 +161,16 @@ const headlight = useHeadlightBeam({
|
|||||||
quality: actualQuality,
|
quality: actualQuality,
|
||||||
activeSectionId: () => journey.value.activeSectionId,
|
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 activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
|
||||||
const serverDefaultPolicy: MotionPolicy = {
|
const serverDefaultPolicy: MotionPolicy = {
|
||||||
allowSceneRequest: false,
|
allowSceneRequest: false,
|
||||||
@@ -376,6 +387,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
:audio-state="audio.state.value"
|
:audio-state="audio.state.value"
|
||||||
:audio-muted="audio.muted.value"
|
:audio-muted="audio.muted.value"
|
||||||
:audio-volume="controlsReady ? motion.volume.value : 0.4"
|
:audio-volume="controlsReady ? motion.volume.value : 0.4"
|
||||||
|
:dashcam="dashcam.available.value"
|
||||||
@pause="pauseMotion"
|
@pause="pauseMotion"
|
||||||
@resume="resumeMotion"
|
@resume="resumeMotion"
|
||||||
@enable="enableMotion"
|
@enable="enableMotion"
|
||||||
@@ -384,5 +396,17 @@ onUnmounted(() => removePageListeners());
|
|||||||
@audio-resume="audio.resumeFromGesture"
|
@audio-resume="audio.resumeFromGesture"
|
||||||
@audio-mute-toggle="toggleAudioMuted"
|
@audio-mute-toggle="toggleAudioMuted"
|
||||||
@audio-volume-change="updateAudioVolume"
|
@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>
|
</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;
|
audioState?: AudioUiState;
|
||||||
audioMuted?: boolean;
|
audioMuted?: boolean;
|
||||||
audioVolume?: number;
|
audioVolume?: number;
|
||||||
|
/** DASHCAM: offer the shutter (live scene, motion allowed). */
|
||||||
|
dashcam?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
audioState: () => ({ kind: "idle" }),
|
audioState: () => ({ kind: "idle" }),
|
||||||
audioMuted: false,
|
audioMuted: false,
|
||||||
audioVolume: 0.4,
|
audioVolume: 0.4,
|
||||||
|
dashcam: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@ const emit = defineEmits<{
|
|||||||
audioResume: [];
|
audioResume: [];
|
||||||
audioMuteToggle: [];
|
audioMuteToggle: [];
|
||||||
audioVolumeChange: [volume: number];
|
audioVolumeChange: [volume: number];
|
||||||
|
dashcam: [];
|
||||||
|
dashcamPrefetch: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const controlRoot = ref<HTMLElement | null>(null);
|
const controlRoot = ref<HTMLElement | null>(null);
|
||||||
@@ -282,6 +287,21 @@ onBeforeUnmount(() => {
|
|||||||
<IconGlyph :name="action.icon" />
|
<IconGlyph :name="action.icon" />
|
||||||
</button>
|
</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
|
<button
|
||||||
v-if="audioState.kind !== 'starting'"
|
v-if="audioState.kind !== 'starting'"
|
||||||
ref="settingsButton"
|
ref="settingsButton"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, h, type FunctionalComponent } from 'vue';
|
||||||
import {
|
import {
|
||||||
PhPause,
|
PhPause,
|
||||||
PhPlay,
|
PhPlay,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
PhWaveform,
|
PhWaveform,
|
||||||
} from '@phosphor-icons/vue';
|
} 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<{
|
const props = withDefaults(defineProps<{
|
||||||
name: IconGlyphName;
|
name: IconGlyphName;
|
||||||
@@ -18,7 +18,15 @@ const props = withDefaults(defineProps<{
|
|||||||
size: 20,
|
size: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phosphor's camera (regular weight) redrawn as one stroked path: the package
|
||||||
|
// component ships all six weights' paths into the initial chunk.
|
||||||
|
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 = {
|
const icons = {
|
||||||
|
camera: CameraGlyph,
|
||||||
pause: PhPause,
|
pause: PhPause,
|
||||||
play: PhPlay,
|
play: PhPlay,
|
||||||
settings: PhSlidersHorizontal,
|
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);
|
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 instanceof HTMLElement &&
|
||||||
(target.isContentEditable ||
|
(target.isContentEditable ||
|
||||||
target instanceof HTMLInputElement ||
|
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 { dawnStep, type DawnStep } from '@/journey/dawn';
|
||||||
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
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;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD =
|
const TEST_BUILD =
|
||||||
@@ -244,6 +246,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
let riseUntil = 0;
|
let riseUntil = 0;
|
||||||
let gantryState = '';
|
let gantryState = '';
|
||||||
let dawnState: DawnStep | '' = '';
|
let dawnState: DawnStep | '' = '';
|
||||||
|
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
|
||||||
|
const dashcamLens = createDashcamLens();
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
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),
|
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
|
||||||
) * (1 - outro);
|
) * (1 - outro);
|
||||||
const craneShift = cameraState.shift * crane;
|
const craneShift = cameraState.shift * crane;
|
||||||
|
const dashcam = dashcamLens.update(readDashcam(), deltaSeconds, now);
|
||||||
camera.setViewOffset(
|
camera.setViewOffset(
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -660,13 +665,13 @@ export async function createProceduralSceneGeneration(
|
|||||||
height,
|
height,
|
||||||
);
|
);
|
||||||
camera.position.set(
|
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(46, cameraState.y, crane),
|
||||||
THREE.MathUtils.lerp(92, cameraState.z, crane),
|
THREE.MathUtils.lerp(92, cameraState.z, crane),
|
||||||
);
|
);
|
||||||
camera.lookAt(
|
camera.lookAt(
|
||||||
THREE.MathUtils.lerp(0, cameraState.targetX, crane),
|
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),
|
THREE.MathUtils.lerp(-720, cameraState.targetZ, crane),
|
||||||
);
|
);
|
||||||
camera.rotation.z += cameraState.roll * crane;
|
camera.rotation.z += cameraState.roll * crane;
|
||||||
@@ -737,7 +742,10 @@ export async function createProceduralSceneGeneration(
|
|||||||
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
||||||
postProcessor.setLens({
|
postProcessor.setLens({
|
||||||
time: elapsedSeconds,
|
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,
|
energy: overdrive,
|
||||||
rain: service.rain,
|
rain: service.rain,
|
||||||
rainTime: elapsedSeconds,
|
rainTime: elapsedSeconds,
|
||||||
@@ -747,6 +755,15 @@ export async function createProceduralSceneGeneration(
|
|||||||
} else {
|
} else {
|
||||||
renderer.render(scene, camera);
|
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;
|
renderedFrames += 1;
|
||||||
},
|
},
|
||||||
dispose() {
|
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.opus', '')).toBe('soundtrack-audio');
|
||||||
expect(classifyArtifact('assets/nightdrive-drive-lift.mp3', '')).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/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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||