feat: dashcam photo mode with telemetry frame, lens drift and postcard capture
This commit is contained in:
@@ -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
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
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
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
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
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
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
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user