fix: polish pass for traffic, nav, icons, contact email reveal and dashcam robustness
This commit is contained in:
@@ -414,12 +414,13 @@ onUnmounted(() => removePageListeners());
|
||||
/>
|
||||
<component
|
||||
:is="dashcam.frame.value"
|
||||
v-if="dashcam.open.value && dashcam.frame.value"
|
||||
v-if="dashcam.engaged.value && dashcam.frame.value"
|
||||
:journey="journey"
|
||||
:section-label="activeSectionLabel"
|
||||
:name="content.identity.name"
|
||||
:city="content.identity.location.city"
|
||||
:scene-ready="sceneState === 'running'"
|
||||
@ready="dashcam.ready"
|
||||
@close="dashcam.close"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -35,7 +35,7 @@ const props = defineProps<{
|
||||
city: string;
|
||||
sceneReady: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const emit = defineEmits<{ close: []; ready: [] }>();
|
||||
|
||||
/** REC dot sampling rate while open. */
|
||||
const PULSE_HZ = 12;
|
||||
@@ -43,15 +43,15 @@ const PULSE_HZ = 12;
|
||||
const CAPTURE_TIMEOUT_MS = 1_200;
|
||||
const SHUTTER_MS = 120;
|
||||
|
||||
const surface = ref<HTMLElement | null>(null);
|
||||
const saveButton = ref<HTMLButtonElement | null>(null);
|
||||
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||
const dot = ref<HTMLElement | null>(null);
|
||||
const now = shallowRef(new Date());
|
||||
const busy = shallowRef(false);
|
||||
const lost = shallowRef(false);
|
||||
const status = shallowRef('');
|
||||
const unavailable = computed(() => lost.value || !props.sceneReady);
|
||||
/* The shell closes the frame when the scene leaves `running`; this only
|
||||
covers the frames in between. A capture timeout is not a lost scene. */
|
||||
const unavailable = computed(() => !props.sceneReady);
|
||||
|
||||
const telemetry = computed<DashcamTelemetry>(() => ({
|
||||
sectionId: props.journey.activeSectionId,
|
||||
@@ -85,8 +85,8 @@ const finish = (): void => {
|
||||
window.clearTimeout(captureTimer);
|
||||
busy.value = false;
|
||||
// The button was disabled while busy, which drops focus to the body.
|
||||
const lost = document.activeElement === null || document.activeElement === document.body;
|
||||
if (lost) void nextTick(() => saveButton.value?.focus());
|
||||
const focusDropped = document.activeElement === null || document.activeElement === document.body;
|
||||
if (focusDropped) void nextTick(() => saveButton.value?.focus());
|
||||
};
|
||||
|
||||
/* The page stays where it was: a scrollbar drag (the only input not
|
||||
@@ -113,18 +113,15 @@ const shoot = (): void => {
|
||||
finish();
|
||||
});
|
||||
});
|
||||
// Context loss mid-capture: the scene never answers; drop the request.
|
||||
// The scene did not answer (a stalled or lost frame): drop the request and
|
||||
// let the visitor try again; the button comes straight back.
|
||||
captureTimer = window.setTimeout(() => {
|
||||
cancelDashcamCapture();
|
||||
lost.value = true;
|
||||
status.value = 'Postcard not taken, try again';
|
||||
finish();
|
||||
}, CAPTURE_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const onSurfaceClick = (event: MouseEvent): void => {
|
||||
if (event.target === surface.value) shoot();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (event.pointerType !== 'mouse' || !readDashcam().drift) return;
|
||||
setDashcamPointer(
|
||||
@@ -157,7 +154,6 @@ const CAPTURE = { capture: true } as const;
|
||||
const ACTIVE = { capture: true, passive: false } as const;
|
||||
|
||||
watch(title, (value) => { document.title = value; });
|
||||
watch(() => props.sceneReady, (ready) => { if (ready) lost.value = false; });
|
||||
|
||||
onMounted(() => {
|
||||
previousTitle = document.title;
|
||||
@@ -170,7 +166,8 @@ onMounted(() => {
|
||||
window.addEventListener('wheel', suppress, ACTIVE);
|
||||
window.addEventListener('touchmove', suppress, ACTIVE);
|
||||
window.addEventListener('keydown', onKeydown, CAPTURE);
|
||||
saveButton.value?.focus();
|
||||
(saveButton.value?.disabled ? closeButton.value : saveButton.value)?.focus();
|
||||
emit('ready');
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -190,13 +187,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="surface"
|
||||
class="dashcam-frame"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Dashcam"
|
||||
tabindex="-1"
|
||||
:data-unavailable="unavailable ? '' : undefined"
|
||||
@click="onSurfaceClick"
|
||||
>
|
||||
<span class="dashcam-frame__bar" data-edge="top" aria-hidden="true"></span>
|
||||
<span class="dashcam-frame__bar" data-edge="bottom" aria-hidden="true"></span>
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, type FunctionalComponent } from 'vue';
|
||||
import {
|
||||
PhPause,
|
||||
PhPlay,
|
||||
PhSlidersHorizontal,
|
||||
PhSpeakerHigh,
|
||||
PhSpeakerX,
|
||||
PhWaveform,
|
||||
} from '@phosphor-icons/vue';
|
||||
|
||||
export type IconGlyphName = 'camera' | 'pause' | 'play' | 'settings' | 'sound' | 'sound-muted' | 'volume';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -18,33 +8,34 @@ const props = withDefaults(defineProps<{
|
||||
size: 20,
|
||||
});
|
||||
|
||||
// Phosphor's camera (regular weight) redrawn as one stroked path: the package
|
||||
// component ships all six weights' paths into the initial chunk.
|
||||
const CameraGlyph: FunctionalComponent<{ size?: number }> = ({ size }) => h('svg', {
|
||||
viewBox: '0 0 256 256', width: size, height: size, fill: 'none', stroke: 'currentColor', 'stroke-width': 16, 'stroke-linejoin': 'round',
|
||||
}, [h('path', { d: 'M48 64h32l16-24h64l16 24h32a16 16 0 0 1 16 16v112a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V80a16 16 0 0 1 16-16ZM128 96a36 36 0 1 0 0 72a36 36 0 1 0 0-72Z' })]);
|
||||
CameraGlyph.props = ['size'];
|
||||
|
||||
const icons = {
|
||||
camera: CameraGlyph,
|
||||
pause: PhPause,
|
||||
play: PhPlay,
|
||||
settings: PhSlidersHorizontal,
|
||||
sound: PhSpeakerHigh,
|
||||
'sound-muted': PhSpeakerX,
|
||||
volume: PhWaveform,
|
||||
} as const;
|
||||
|
||||
const icon = computed(() => icons[props.name]);
|
||||
/*
|
||||
* Regular-weight paths from @phosphor-icons/vue 2.2.1 (MIT, recorded in
|
||||
* docs/third-party-notices.md), filled. The package components ship all six
|
||||
* weights of every glyph into the initial chunk; one path each is all the
|
||||
* controls draw.
|
||||
*/
|
||||
const FILLED: Readonly<Record<IconGlyphName, string>> = {
|
||||
camera: 'M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z',
|
||||
pause: 'M200,32H160a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm0,176H160V48h40ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Zm0,176H56V48H96Z',
|
||||
play: 'M232.4,114.49,88.32,26.35a16,16,0,0,0-16.2-.3A15.86,15.86,0,0,0,64,39.87V216.13A15.94,15.94,0,0,0,80,232a16.07,16.07,0,0,0,8.36-2.35L232.4,141.51a15.81,15.81,0,0,0,0-27ZM80,215.94V40l143.83,88Z',
|
||||
settings: 'M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H199a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h97a32,32,0,0,0,62,0h17a8,8,0,0,0,0-16Zm-48,24a16,16,0,1,1,16-16A16,16,0,0,1,168,192Z',
|
||||
sound: 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm54-106.08a40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.58,24,24,0,0,0,0-31.72,8,8,0,0,1,12-10.58ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z',
|
||||
'sound-muted': 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm101.66-61.3a8,8,0,0,1-11.32,11.32L216,139.31l-18.34,18.35a8,8,0,0,1-11.32-11.32L204.69,128l-18.35-18.34a8,8,0,0,1,11.32-11.32L216,116.69l18.34-18.35a8,8,0,0,1,11.32,11.32L227.31,128Z',
|
||||
volume: 'M56,96v64a8,8,0,0,1-16,0V96a8,8,0,0,1,16,0ZM88,24a8,8,0,0,0-8,8V224a8,8,0,0,0,16,0V32A8,8,0,0,0,88,24Zm40,32a8,8,0,0,0-8,8V192a8,8,0,0,0,16,0V64A8,8,0,0,0,128,56Zm40,32a8,8,0,0,0-8,8v64a8,8,0,0,0,16,0V96A8,8,0,0,0,168,88Zm40-16a8,8,0,0,0-8,8v96a8,8,0,0,0,16,0V80A8,8,0,0,0,208,72Z',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="icon"
|
||||
<svg
|
||||
class="icon-glyph"
|
||||
:size="size"
|
||||
weight="regular"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
:width="props.size"
|
||||
:height="props.size"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
fill="currentColor"
|
||||
>
|
||||
<path :d="FILLED[props.name]" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -53,7 +53,11 @@ onMounted(() => { clientReady.value = true; });
|
||||
watch(
|
||||
() => [clientReady.value && !showCanvas.value, props.journey.arrived, sceneState.value] as const,
|
||||
([cssOnly, arrived]) => {
|
||||
if (cssOnly) document.documentElement.dataset.dawn = arrived ? 'day' : 'night';
|
||||
// The journey snapshot is replaced on every scroll frame, so this runs
|
||||
// per frame: write only on a change (a write is a mutation the living
|
||||
// tab observes).
|
||||
const next = arrived ? 'day' : 'night';
|
||||
if (cssOnly && document.documentElement.dataset.dawn !== next) document.documentElement.dataset.dawn = next;
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
@@ -6,12 +6,27 @@ const LIVE_STAGE = ".reading-stage[data-reading-stage='live']";
|
||||
const RISE_MS = 400;
|
||||
/** Phones: no shutter in the gantry bay (dashcam.css), and no `c` key either. */
|
||||
const NARROW = '(max-width: 767px)';
|
||||
/** One retry for a failed frame chunk, then the open is abandoned. */
|
||||
export const DASHCAM_RETRY_MS = 350;
|
||||
/** A frame that has not mounted in this long is abandoned (nothing changed). */
|
||||
export const DASHCAM_LOAD_TIMEOUT_MS = 4_000;
|
||||
|
||||
type FrameModule = { default: Component };
|
||||
const importFrame = (): Promise<FrameModule> => import('@/components/DashcamFrame.vue');
|
||||
|
||||
/**
|
||||
* DASHCAM, DOM side (initial chunk, kept minimal): availability, the `c` key,
|
||||
* html[data-dashcam] and the lazy frame. The frame itself (telemetry, focus
|
||||
* trap, capture, the shutter) loads on first open or button hover. Starts
|
||||
* after mount, so the server HTML carries none of it.
|
||||
*
|
||||
* Opening is two-phase so the page can never fold away without a frame on
|
||||
* top of it: show() asks for the frame (rendered once the chunk is in), and
|
||||
* only the frame's `ready` (its mount) folds the page, closes the lens and
|
||||
* hands it the title. A chunk that fails twice or never mounts abandons the
|
||||
* open with nothing changed. Anything that would leave the frame stranded
|
||||
* (motion withdrawn, the scene dropping out of `running`, a resize to phone
|
||||
* width) closes it.
|
||||
*/
|
||||
export function useDashcam(sources: {
|
||||
/** Motion allowed in full and the scene not withheld: closes the dashcam when it drops. */
|
||||
@@ -19,31 +34,70 @@ export function useDashcam(sources: {
|
||||
sceneRunning: Readonly<Ref<boolean>>;
|
||||
/** Fine pointer on high/medium: the lens drifts and the bars ease. */
|
||||
drift: () => boolean;
|
||||
/** Test seam: the frame chunk loader. */
|
||||
load?: () => Promise<FrameModule>;
|
||||
}) {
|
||||
const load = sources.load ?? importFrame;
|
||||
/** The page is folded and the frame is mounted. */
|
||||
const open = shallowRef(false);
|
||||
/** The frame should render (requested and loaded); `open` follows on its mount. */
|
||||
const engaged = shallowRef(false);
|
||||
const frame = shallowRef<Component | null>(null);
|
||||
const available = computed(() => sources.allowed.value && sources.sceneRunning.value);
|
||||
let loading: Promise<void> | null = null;
|
||||
let loading: Promise<boolean> | null = null;
|
||||
let requested = false;
|
||||
let riseTimer = 0;
|
||||
let loadTimer = 0;
|
||||
const root = (): HTMLElement => document.documentElement;
|
||||
const narrow = (): boolean => window.matchMedia?.(NARROW).matches ?? false;
|
||||
|
||||
const prefetch = (): Promise<void> => (loading ??= import('@/components/DashcamFrame.vue').then(
|
||||
(module) => { frame.value = markRaw(module.default); },
|
||||
() => { loading = null; },
|
||||
));
|
||||
const attempt = (): Promise<boolean> => load().then(
|
||||
(module) => { frame.value = markRaw(module.default); return true; },
|
||||
() => false,
|
||||
);
|
||||
const prefetch = (): Promise<boolean> => {
|
||||
if (frame.value) return Promise.resolve(true);
|
||||
return (loading ??= attempt()
|
||||
.then((ok) => ok || new Promise<boolean>((resolve) => { window.setTimeout(() => { void attempt().then(resolve); }, DASHCAM_RETRY_MS); }))
|
||||
.then((ok) => { if (!ok) loading = null; return ok; }));
|
||||
};
|
||||
|
||||
/** Drops a pending or mounting open; nothing on the page has changed yet. */
|
||||
const abandon = (): void => {
|
||||
requested = false;
|
||||
engaged.value = false;
|
||||
window.clearTimeout(loadTimer);
|
||||
};
|
||||
|
||||
const show = (): void => {
|
||||
if (open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
|
||||
if (window.matchMedia?.(NARROW).matches) return;
|
||||
if (requested || open.value || !available.value || document.querySelector(LIVE_STAGE) === null) return;
|
||||
if (narrow()) return;
|
||||
requested = true;
|
||||
window.clearTimeout(loadTimer);
|
||||
loadTimer = window.setTimeout(() => { if (!open.value) abandon(); }, DASHCAM_LOAD_TIMEOUT_MS);
|
||||
void prefetch().then((ok) => {
|
||||
if (!requested) return;
|
||||
if (!ok || !available.value || narrow()) abandon();
|
||||
else engaged.value = true;
|
||||
});
|
||||
};
|
||||
|
||||
/** The frame has mounted: fold the page and close the lens. */
|
||||
const ready = (): void => {
|
||||
if (!requested || open.value) return;
|
||||
window.clearTimeout(loadTimer);
|
||||
window.clearTimeout(riseTimer);
|
||||
open.value = true;
|
||||
setDashcamOpen(true, sources.drift());
|
||||
root().dataset.dashcam = 'on';
|
||||
void prefetch();
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
if (!open.value) return;
|
||||
if (!open.value) {
|
||||
if (requested) abandon();
|
||||
return;
|
||||
}
|
||||
abandon();
|
||||
open.value = false;
|
||||
setDashcamOpen(false, false);
|
||||
root().dataset.dashcam = 'rise';
|
||||
@@ -53,25 +107,31 @@ export function useDashcam(sources: {
|
||||
|
||||
const onKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return;
|
||||
if (open.value && event.key === 'Escape') {
|
||||
const active = open.value || requested;
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else if (!event.shiftKey && !event.repeat && event.key.toLowerCase() === 'c') {
|
||||
const wasOpen = open.value;
|
||||
if (wasOpen) close();
|
||||
if (active) close();
|
||||
else show();
|
||||
if (wasOpen || open.value) event.preventDefault();
|
||||
if (active || requested) event.preventDefault();
|
||||
}
|
||||
};
|
||||
const onResize = (): void => { if ((open.value || requested) && narrow()) close(); };
|
||||
|
||||
watch(sources.allowed, (allowed) => { if (!allowed) close(); });
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown));
|
||||
watch(available, (ok) => { if (!ok) close(); });
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.clearTimeout(riseTimer);
|
||||
window.clearTimeout(loadTimer);
|
||||
if (open.value) setDashcamOpen(false, false);
|
||||
delete root().dataset.dashcam;
|
||||
});
|
||||
|
||||
return { open, frame, available, show, close, prefetch };
|
||||
return { open, engaged, frame, available, show, ready, close, prefetch };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bucharestClock } from '@/lib/operations';
|
||||
import { maintenanceClock } from '@/journey/maintenance-progress';
|
||||
import { quantiseRoute } from '@/journey/tab-feed';
|
||||
|
||||
/**
|
||||
* DASHCAM telemetry and the postcard (lazy dashcam chunk only). Every value
|
||||
@@ -27,10 +28,9 @@ export const DASHCAM_COORDINATES = "N44°26' E26°06'";
|
||||
export const POSTCARD_WIDTH = 1600;
|
||||
export const POSTCARD_HEIGHT = 900;
|
||||
|
||||
export const routePercent = (progress: number): number =>
|
||||
Math.round(Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)) * 100);
|
||||
|
||||
export const chapterLabel = (chapter: string): string => chapter.replaceAll('-', ' ').toUpperCase();
|
||||
/** Whole percent on the tab title's 5% step, so the frame, the dashcam's
|
||||
* title and the route title never disagree (47% vs 45%). */
|
||||
export const routePercent = (progress: number): number => Math.round(quantiseRoute(progress) * 100);
|
||||
|
||||
/** Live Bucharest time; inside Infrastructure the maintenance window's clock. */
|
||||
export function dashcamClock(sectionId: string, maintenance: number | null, now: Date): string {
|
||||
@@ -52,8 +52,9 @@ export function postcardFilename(sectionId: string, clock: string): string {
|
||||
export const postcardCredit = (name: string, city: string): string =>
|
||||
`NIGHT SHIFT · ${name.toUpperCase()} · ${city.toUpperCase()}`;
|
||||
|
||||
export const frameTopLeft = (t: DashcamTelemetry): string =>
|
||||
`DASHCAM · ${chapterLabel(t.chapter)} · EXIT ${t.exit}`;
|
||||
/* One vocabulary in the frame: the navigation's exit and chapter name (the
|
||||
scene's internal chapter ids, e.g. "pre-dawn", are not shown). */
|
||||
export const frameTopLeft = (t: DashcamTelemetry): string => `DASHCAM · EXIT ${t.exit}`;
|
||||
export const frameBottomLeft = (t: DashcamTelemetry): string =>
|
||||
`ROUTE ${t.routePercent}% · ${t.sectionLabel.toUpperCase()}`;
|
||||
|
||||
@@ -66,6 +67,20 @@ export function coverCrop(sw: number, sh: number, dw: number, dh: number): { sx:
|
||||
return { sx: (sw - cropW) / 2, sy: (sh - cropH) / 2, sw: cropW, sh: cropH };
|
||||
}
|
||||
|
||||
/** Widest postcard written: a 2x buffer at 1440 is downsampled to this. */
|
||||
export const POSTCARD_MAX_WIDTH = 2560;
|
||||
|
||||
/**
|
||||
* The postcard's size for a sw x sh drawing buffer: the largest 16:9 crop of
|
||||
* the buffer at its own pixels, never upscaled (a 1440 x 900 buffer stretched
|
||||
* to 1600 read soft), downsampled only beyond POSTCARD_MAX_WIDTH.
|
||||
*/
|
||||
export function postcardSize(sw: number, sh: number): { w: number; h: number } {
|
||||
if (!(sw > 0 && sh > 0)) return { w: POSTCARD_WIDTH, h: POSTCARD_HEIGHT };
|
||||
const w = Math.min(POSTCARD_MAX_WIDTH, sw, (sh * 16) / 9);
|
||||
return { w: Math.round(w), h: Math.round((w * 9) / 16) };
|
||||
}
|
||||
|
||||
/** Frame chrome geometry for a w x h postcard (proportional to 1600 x 900). */
|
||||
export function postcardLayout(w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT) {
|
||||
const unit = Math.min(w / POSTCARD_WIDTH, h / POSTCARD_HEIGHT);
|
||||
@@ -180,11 +195,13 @@ export function savePostcard(source: Source, t: DashcamTelemetry): Promise<Postc
|
||||
let canvas: HTMLCanvasElement;
|
||||
try {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = POSTCARD_WIDTH;
|
||||
canvas.height = POSTCARD_HEIGHT;
|
||||
const { w, h } = postcardSize(source.width, source.height);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx === null) return Promise.resolve('failed');
|
||||
drawPostcard(ctx as unknown as PostcardContext, source, t);
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
drawPostcard(ctx as unknown as PostcardContext, source, t, w, h);
|
||||
} catch {
|
||||
return Promise.resolve('failed');
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ export interface BillboardWorld {
|
||||
}
|
||||
|
||||
export interface BillboardOptions {
|
||||
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): one static flood. */
|
||||
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): both floods steady. */
|
||||
mode: 'full' | 'lite';
|
||||
textureSize: number;
|
||||
io?: BillboardIO;
|
||||
@@ -531,8 +531,9 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
|
||||
const p = frame.p ?? (presence === 1 && frame.section === 'infrastructure' ? 0 : null);
|
||||
progress = billboardProgress(standing ? p : null, feed.stops, feed.projects.map((project) => project.id), frame.direction);
|
||||
|
||||
// Floods: off while rising; lite keeps one steady lamp.
|
||||
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, 0];
|
||||
// Floods: off while rising; lite keeps both lamps steady (one lamp left
|
||||
// the right half of the face unlit, and phone captures read dim).
|
||||
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, standing ? 1 : 0];
|
||||
floods[0] = toward(floods[0], left, dt / BILLBOARD_TIMING.flood);
|
||||
floods[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood);
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function createProceduralSceneGeneration(
|
||||
const sun = createSun(context.resources);
|
||||
const horizonAir = createHorizonAir(context.resources);
|
||||
const skyline = createSkyline(context.resources);
|
||||
const traffic = createTraffic(context.resources, quality);
|
||||
const traffic = createTraffic(context.resources, quality, scene.fog.color);
|
||||
// Phones and the low tier get the decimated car; it is about 200 px wide
|
||||
// there and seen from behind.
|
||||
const liteCar =
|
||||
@@ -214,6 +214,7 @@ export async function createProceduralSceneGeneration(
|
||||
let underpassShade = 0;
|
||||
let underpassKey = '';
|
||||
let underpassAttr = false;
|
||||
let underpassSince = -1;
|
||||
scene.add(
|
||||
underpass.group,
|
||||
relay.group,
|
||||
@@ -786,7 +787,13 @@ export async function createProceduralSceneGeneration(
|
||||
},
|
||||
);
|
||||
underpassShade = passage.shade;
|
||||
const under = shadeDepth(passage.shade) > 0.2;
|
||||
// The HUD tint follows the shade, but only once it has held for
|
||||
// UNDERPASS_ATTR_HOLD_MS: an approach walk can graze the threshold for a
|
||||
// frame or three before the real pass. Leaving is immediate.
|
||||
const shaded = shadeDepth(passage.shade) > 0.2;
|
||||
if (!shaded) underpassSince = -1;
|
||||
else if (underpassSince < 0) underpassSince = now;
|
||||
const under = shaded && now - underpassSince >= UNDERPASS_ATTR_HOLD_MS;
|
||||
if (under !== underpassAttr) {
|
||||
underpassAttr = under;
|
||||
if (under) document.documentElement.dataset.underpass = 'under';
|
||||
@@ -934,6 +941,8 @@ export async function createProceduralSceneGeneration(
|
||||
};
|
||||
}
|
||||
|
||||
/** html[data-underpass] waits for the shade to hold this long (ms). */
|
||||
const UNDERPASS_ATTR_HOLD_MS = 120;
|
||||
const INTRO_SECONDS = 4.2;
|
||||
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
|
||||
const OUTRO_SECONDS = 7.0;
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createExpressway(
|
||||
gl_Position = projectionMatrix * view;
|
||||
}
|
||||
`;
|
||||
function material(luminous: boolean, coral = false) {
|
||||
function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660]) {
|
||||
return resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
@@ -49,7 +49,7 @@ export function createExpressway(
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
void main() {
|
||||
float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth);
|
||||
float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
|
||||
float nearFade = smoothstep(0.0, 9.0, vDepth);
|
||||
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
|
||||
vec3 coral = vec3(1.0, 0.18, 0.095);
|
||||
@@ -71,7 +71,9 @@ export function createExpressway(
|
||||
}
|
||||
const graphite = material(false);
|
||||
const cyan = material(true);
|
||||
const coral = material(true, true);
|
||||
// Delineator reflectors fade out by ~240 m: beyond that a 0.4 m plate is a
|
||||
// sub-pixel speck, and the specks read as random orange dots (owner).
|
||||
const coral = material(true, true, [110, 240]);
|
||||
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
const dummy = new THREE.Object3D();
|
||||
type Part = {
|
||||
@@ -113,12 +115,14 @@ export function createExpressway(
|
||||
for (const side of [-1, 1]) {
|
||||
const z = 30 - (i * WORLD_DEPTH) / pairs;
|
||||
posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
|
||||
// A tall reflector plate on the post's face: guard-rail delineators,
|
||||
// the post and its plate one readable unit.
|
||||
reflectors.push({
|
||||
x: side * 9.48,
|
||||
y: 1.1,
|
||||
z: z + 0.18,
|
||||
sx: 0.14,
|
||||
sy: 0.22,
|
||||
x: side * 9.5,
|
||||
y: 1.02,
|
||||
z: z + 0.17,
|
||||
sx: 0.16,
|
||||
sy: 0.4,
|
||||
sz: 0.03,
|
||||
});
|
||||
rails.push({
|
||||
|
||||
@@ -44,6 +44,14 @@ const PLATE_YAW = 0.3;
|
||||
const FLASH_SECONDS = 0.3;
|
||||
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */
|
||||
const FLASH_GAP_SECONDS = 0.6;
|
||||
/** Smoothed section-local progress per second above which a crossing does not
|
||||
* flash: a navigator jump or a fling sweeps several posts past in well under
|
||||
* a second, and their flashes stacked into one bright bloom frame on arrival.
|
||||
* A wheel tick lands in one frame, so the rate is smoothed: it rises over
|
||||
* ~80 ms (a sweep is caught within three frames) and falls over ~400 ms. */
|
||||
export const MILESTONE_FLASH_MAX_RATE = 0.8;
|
||||
const RATE_RISE_SECONDS = 0.08;
|
||||
const RATE_FALL_SECONDS = 0.4;
|
||||
const PULSE_GAIN = 0.08;
|
||||
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
|
||||
const PLATE_GLOW = 0.42;
|
||||
@@ -198,10 +206,14 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
|
||||
} else if (vPart < 1.5) {
|
||||
// Lit retro-reflective plate: brightest facing the camera, and in the beam.
|
||||
// Tinted by the chapter accent so it never reads as one more street lamp.
|
||||
color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing);
|
||||
color += uAccent * 0.35;
|
||||
${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''}
|
||||
color += vec3(1.0, 0.96, 0.9) * flash * 1.4;
|
||||
// Kept below the tonemapper's shoulder, so the plate stays the
|
||||
// chapter colour instead of clipping to white; the beam and the
|
||||
// flash lift it in the accent's own hue.
|
||||
vec3 sheet = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.82);
|
||||
color = sheet * (${PLATE_GLOW.toFixed(2)} + 0.1 * key + 0.32 * facing * facing);
|
||||
color += uAccent * 0.18;
|
||||
${full ? 'color += sheet * headlightBeam(vWorld) * (0.3 + 0.8 * facing);' : ''}
|
||||
color += mix(uAccent, vec3(1.0, 0.96, 0.9), 0.35) * flash * 0.9;
|
||||
} else {
|
||||
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
|
||||
}
|
||||
@@ -267,6 +279,8 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
|
||||
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
|
||||
const side = new Float32Array(INSTANCES);
|
||||
let lastSection: SectionId | null = null;
|
||||
let lastProgress: number | null = null;
|
||||
let rate = 0;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const rotation = new THREE.Quaternion();
|
||||
const position = new THREE.Vector3();
|
||||
@@ -281,12 +295,17 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
|
||||
const posts = milestoneLayout(section, p);
|
||||
const changed = section !== lastSection;
|
||||
lastSection = section;
|
||||
const dt = Math.max(deltaSeconds, 1 / 240);
|
||||
const instant = p !== null && lastProgress !== null && !changed ? Math.abs(p - lastProgress) / dt : 0;
|
||||
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
|
||||
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
|
||||
lastProgress = p;
|
||||
let any = false;
|
||||
posts.forEach((post, i) => {
|
||||
// The flash fires when the plate crosses the line, either way.
|
||||
const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section]));
|
||||
sinceFlash[i]! += deltaSeconds;
|
||||
if (full && !changed && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
|
||||
if (full && !changed && !sweeping && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
|
||||
flash[i] = 1;
|
||||
sinceFlash[i] = 0;
|
||||
} else {
|
||||
|
||||
@@ -176,9 +176,8 @@ export function createRoad(
|
||||
color += vec3(0.11, 0.065, 0.17) * shoulderRibs;
|
||||
float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0));
|
||||
color *= 1.0 - joint * 0.5;
|
||||
float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53)))
|
||||
* (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0)));
|
||||
color += uLaneColor * studs * 0.7;
|
||||
// (Amber edge studs removed in the polish round: sub-pixel beyond
|
||||
// ~60 m, they aliased into random orange specks along the edge.)
|
||||
|
||||
// Rear lamp footprint: grows towards the camera from the actual rear axle.
|
||||
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
|
||||
|
||||
@@ -3,30 +3,54 @@ import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
|
||||
/**
|
||||
* Other cars on the expressway, seen only as their lamps: taillight pairs,
|
||||
* long-exposure trails whose length follows the speed difference, and red
|
||||
* Other vehicles on the night shift: dark low-poly cars and box trucks with
|
||||
* readable rear lamps (a tail-light pair, a high centre brake light on cars,
|
||||
* amber clearance lamps on trucks), their roofs catching the horizon glow,
|
||||
* their rear faces our headlights, plus short long-exposure trails and red
|
||||
* pools on the wet asphalt. Scrolling faster than the traffic overtakes it;
|
||||
* resting lets the faster lanes pull away. Instances wrap through a hidden band
|
||||
* behind the camera, so nothing pops in view.
|
||||
* resting lets the faster lanes pull away. Instances wrap through a hidden
|
||||
* band behind the camera, so nothing pops in view.
|
||||
*
|
||||
* Owner feedback (polish round): bodiless lamp pairs read as random red dots
|
||||
* on the road. Each lane now runs at one speed, so vehicles in a lane never
|
||||
* drive through each other, and adjacent lanes never overlap.
|
||||
*/
|
||||
/**
|
||||
* The hero car renders at 2.78x scale: its body spans |x| < 2.6 and its nose
|
||||
* reaches z = -5.5. Traffic never enters that envelope or the near field
|
||||
* around it. Lamps fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z and
|
||||
* wrap inside the hidden band, so no light ever slides past the car (round 5:
|
||||
* bodiless lamp pairs passing the flank read as the car's own taillights
|
||||
* coming loose).
|
||||
* around it. Vehicles fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z
|
||||
* and wrap inside the hidden band, so nothing ever slides past the car.
|
||||
*/
|
||||
export const HERO_CAR_HALF_WIDTH = 2.6;
|
||||
export const TRAFFIC_NEAR_Z = -24;
|
||||
export const TRAFFIC_FADE_LENGTH = 22;
|
||||
const FAR_Z = -660;
|
||||
const SPAN = TRAFFIC_NEAR_Z - FAR_Z;
|
||||
/** Lane centres. Fast lanes on the left, slower trucks on the right. */
|
||||
export const TRAFFIC_LANES = [-5.7, -5.7, -4.3, 5.4, 5.7] as const;
|
||||
export const TRAFFIC_LAMP_HALF_SPACING = { fast: 0.9, slow: 1.0 } as const;
|
||||
/** Max lateral jitter of a vehicle inside its lane. */
|
||||
export const TRAFFIC_LANE_JITTER = 0.1;
|
||||
|
||||
/** Visibility of a lamp, trail or pool at world z (1 = fully lit). */
|
||||
export type TrafficKind = 'car' | 'truck';
|
||||
export interface TrafficLane {
|
||||
x: number;
|
||||
/** World units per second; one speed per lane, so no one drives through anyone. */
|
||||
speed: number;
|
||||
kind: TrafficKind;
|
||||
}
|
||||
/** Fast cars on the left, slower cars and box trucks on the right. */
|
||||
export const TRAFFIC_LANES: readonly TrafficLane[] = [
|
||||
{ x: -6.5, speed: 46, kind: 'car' },
|
||||
{ x: 4.0, speed: 16, kind: 'car' },
|
||||
{ x: -4.0, speed: 33, kind: 'car' },
|
||||
{ x: 6.6, speed: 11, kind: 'truck' },
|
||||
];
|
||||
|
||||
/** Body half widths and lengths (metres, world scale). */
|
||||
export const TRAFFIC_BODY = {
|
||||
car: { halfWidth: 1.1, length: 4.8 },
|
||||
truck: { halfWidth: 1.25, length: 15 },
|
||||
} as const;
|
||||
|
||||
/** Visibility of a lamp, trail, pool or body at world z (1 = fully lit). */
|
||||
export function trafficNearFade(z: number): number {
|
||||
const t = Math.min(1, Math.max(0, (TRAFFIC_NEAR_Z - z) / TRAFFIC_FADE_LENGTH));
|
||||
return t * t * (3 - 2 * t);
|
||||
@@ -40,44 +64,114 @@ export function wrapTrafficZ(z: number): number {
|
||||
return z;
|
||||
}
|
||||
|
||||
interface Lamp {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
/** 0..0.6 red to red-orange, 2 amber. */
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** Rear lamps in body space (origin at the rear face, ground level). */
|
||||
export function trafficLamps(kind: TrafficKind, tint: number): Lamp[] {
|
||||
const red = tint * 0.6;
|
||||
if (kind === 'car')
|
||||
return [
|
||||
{ x: -0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
|
||||
{ x: 0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
|
||||
// Centre high-mounted brake light on the rear window's top edge.
|
||||
{ x: 0, y: 1.55, z: -0.9, w: 0.62, h: 0.12, level: red },
|
||||
];
|
||||
return [
|
||||
{ x: -0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red },
|
||||
{ x: 0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red },
|
||||
// Amber clearance lamps at the top corners give the box its outline.
|
||||
{ x: -1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 },
|
||||
{ x: 1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 },
|
||||
];
|
||||
}
|
||||
const LAMPS_PER_VEHICLE = 4;
|
||||
|
||||
/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in body space. */
|
||||
type Box = readonly [number, number, number, number, number, number];
|
||||
const CAR_BOXES: readonly Box[] = [
|
||||
[-1.1, 1.1, 0.34, 1.1, -4.8, 0], // body
|
||||
[-0.92, 0.92, 1.1, 1.6, -3.5, -0.95], // cabin (the rear face is the window)
|
||||
[-1.08, -0.72, 0, 0.6, -1.25, -0.45], // wheels
|
||||
[0.72, 1.08, 0, 0.6, -1.25, -0.45],
|
||||
[-1.08, -0.72, 0, 0.6, -4.25, -3.45],
|
||||
[0.72, 1.08, 0, 0.6, -4.25, -3.45],
|
||||
];
|
||||
const TRUCK_BOXES: readonly Box[] = [
|
||||
[-1.25, 1.25, 1.05, 4.05, -12.4, 0], // box body
|
||||
[-1.18, 1.18, 0.55, 3.3, -15, -12.6], // cab
|
||||
[-1.1, 1.1, 0.42, 0.56, -0.12, 0.02], // under-run bar
|
||||
[-1.2, -0.7, 0, 1.0, -3.5, -1.5], // rear tandem
|
||||
[0.7, 1.2, 0, 1.0, -3.5, -1.5],
|
||||
[-1.2, -0.7, 0, 1.0, -14.4, -13.2], // front axle
|
||||
[0.7, 1.2, 0, 1.0, -14.4, -13.2],
|
||||
];
|
||||
|
||||
function boxGeometry(boxes: readonly Box[]) {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
for (const [x0, x1, y0, y1, z0, z1] of boxes) {
|
||||
const part = new THREE.BoxGeometry(x1 - x0, y1 - y0, z1 - z0).toNonIndexed();
|
||||
part.translate((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
|
||||
positions.push(...(part.getAttribute('position').array as Float32Array));
|
||||
normals.push(...(part.getAttribute('normal').array as Float32Array));
|
||||
part.dispose();
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
interface Vehicle {
|
||||
lane: TrafficLane;
|
||||
x: number;
|
||||
z: number;
|
||||
speed: number;
|
||||
width: number;
|
||||
height: number;
|
||||
tint: number;
|
||||
}
|
||||
|
||||
export function createTraffic(
|
||||
resources: SceneResourceBag,
|
||||
quality: RenderQualitySettings,
|
||||
/** The horizon fog colour: roofs and upper edges catch it as sheen. */
|
||||
sheen: unknown = new THREE.Color(0x8a3d7a),
|
||||
) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'expressway-traffic';
|
||||
const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7;
|
||||
const lanes = TRAFFIC_LANES;
|
||||
const vehicles: Vehicle[] = [];
|
||||
let seed = 7;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed - 1) / 2147483646;
|
||||
};
|
||||
const lanes = TRAFFIC_LANES.length;
|
||||
const perLane = Array.from({ length: lanes }, (_, lane) => Math.ceil((count - lane) / lanes));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const lane = lanes[i % lanes.length]!;
|
||||
// Left lanes run faster than the cruise, the right lane is slower trucks.
|
||||
const fast = lane < 0;
|
||||
const laneIndex = i % lanes;
|
||||
const lane = TRAFFIC_LANES[laneIndex]!;
|
||||
// Evenly spread through the span with jitter smaller than the gap, so
|
||||
// vehicles in one lane (one speed) keep their spacing forever.
|
||||
const slot = Math.floor(i / lanes);
|
||||
const gap = SPAN / perLane[laneIndex]!;
|
||||
const jitter = (random() - 0.5) * Math.max(0, gap - TRAFFIC_BODY[lane.kind].length - 30);
|
||||
vehicles.push({
|
||||
x: lane + (random() - 0.5) * 0.5,
|
||||
z: FAR_Z + 20 + random() * (SPAN - 40),
|
||||
speed: fast ? 26 + random() * 22 : 9 + random() * 6,
|
||||
width: fast ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow,
|
||||
height: fast ? 0.95 : 1.55 + random() * 0.4,
|
||||
lane,
|
||||
x: lane.x + (random() - 0.5) * 2 * TRAFFIC_LANE_JITTER,
|
||||
z: wrapTrafficZ(FAR_Z + gap * (slot + 0.5) + laneIndex * 37 + jitter),
|
||||
tint: random(),
|
||||
});
|
||||
}
|
||||
|
||||
const uniforms = { uTime: { value: 0 } };
|
||||
const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen } };
|
||||
const nearFade = /* glsl */ `smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0))`;
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute float aLevel;
|
||||
varying vec2 vUv;
|
||||
@@ -89,7 +183,7 @@ export function createTraffic(
|
||||
vLevel = aLevel;
|
||||
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
// Matches trafficNearFade(): dark before the hero car's near field.
|
||||
vNear = smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0));
|
||||
vNear = ${nearFade};
|
||||
vec4 view = viewMatrix * world;
|
||||
vDepth = -view.z;
|
||||
gl_Position = projectionMatrix * view;
|
||||
@@ -103,7 +197,8 @@ export function createTraffic(
|
||||
float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
|
||||
float near = vNear;
|
||||
${shape}
|
||||
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), vLevel);
|
||||
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0));
|
||||
color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel));
|
||||
gl_FragColor = vec4(color * glow * far * near, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
@@ -122,40 +217,122 @@ export function createTraffic(
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
// Lamps: a hot core in a soft disc, bright enough to bloom.
|
||||
// Lamps: a hot lamp-shaped core in a soft disc, bright enough to bloom.
|
||||
const lampMaterial = additive(`
|
||||
vec2 c = vUv - 0.5;
|
||||
float core = exp(-pow(abs(c.x) * 2.3, 6.0) - pow(abs(c.y) * 2.4, 6.0));
|
||||
float r = length(c * vec2(1.0, 1.6));
|
||||
float glow = exp(-r * r * 26.0) * 3.2 + exp(-r * r * 5.0) * 0.5;`);
|
||||
float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
|
||||
// Trails: thin, brightest at the lamp, fading over their length.
|
||||
const trailMaterial = additive(`
|
||||
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
|
||||
float glow = across * pow(vUv.y, 1.6) * 1.1;`);
|
||||
// Wet-road pools: long and soft under each car.
|
||||
float glow = across * pow(vUv.y, 1.6) * 0.8;`);
|
||||
// Wet-road pools: long and soft under each vehicle.
|
||||
const poolMaterial = additive(`
|
||||
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
|
||||
float glow = across * pow(vUv.y, 2.2) * 0.2;`);
|
||||
|
||||
// Bodies: near-black coachwork that reads as a silhouette against the
|
||||
// horizon, with the fog glow on the roof, our headlights on the rear face,
|
||||
// a glass rear window, door seams on the trucks and red lamp spill.
|
||||
const bodyMaterial = (kind: TrafficKind) => resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: /* glsl */ `
|
||||
varying vec3 vLocal;
|
||||
varying vec3 vNormal;
|
||||
varying float vDepth;
|
||||
varying float vNear;
|
||||
void main() {
|
||||
vLocal = position;
|
||||
vNormal = normal;
|
||||
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
vNear = ${nearFade};
|
||||
vec4 view = viewMatrix * world;
|
||||
vDepth = -view.z;
|
||||
gl_Position = projectionMatrix * view;
|
||||
}`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 uSheen;
|
||||
varying vec3 vLocal;
|
||||
varying vec3 vNormal;
|
||||
varying float vDepth;
|
||||
varying float vNear;
|
||||
void main() {
|
||||
float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear;
|
||||
if (alpha < 0.02) discard;
|
||||
vec3 n = normalize(vNormal);
|
||||
float up = max(0.0, n.y);
|
||||
float rear = max(0.0, n.z);
|
||||
float side = abs(n.x);
|
||||
float wheel = ${kind === 'car' ? 'step(vLocal.y, 0.6) * step(0.7, abs(vLocal.x))' : 'step(vLocal.y, 1.0) * step(0.68, abs(vLocal.x))'};
|
||||
vec3 color = vec3(0.034, 0.02, 0.058);
|
||||
// Roof and shoulders: the horizon glow as a satin sheen.
|
||||
color += uSheen * (up * 0.42 + side * 0.07 * smoothstep(0.5, 1.4, vLocal.y));
|
||||
// Our headlights on the rear face, falling off down the road.
|
||||
float beam = exp(-max(0.0, vDepth - 40.0) / 90.0);
|
||||
color += vec3(0.19, 0.15, 0.27) * rear * beam * 0.55;
|
||||
${kind === 'car'
|
||||
? `// Rear window: glass reflects a lavender band of sky.
|
||||
float glass = rear * step(1.12, vLocal.y) * step(vLocal.z, -0.9);
|
||||
color = mix(color, vec3(0.05, 0.05, 0.12) + uSheen * 0.22 * smoothstep(1.2, 1.55, vLocal.y), glass);
|
||||
float lampBand = exp(-pow((vLocal.y - 0.92) / 0.18, 2.0));`
|
||||
: `// Box doors: a centre seam and two lock bars.
|
||||
float seam = 1.0 - smoothstep(0.02, 0.05, abs(vLocal.x));
|
||||
float bars = 1.0 - smoothstep(0.03, 0.06, abs(abs(vLocal.x) - 0.42));
|
||||
color *= 1.0 - rear * max(seam * 0.6, bars * 0.35);
|
||||
float lampBand = exp(-pow((vLocal.y - 1.3) / 0.2, 2.0));`}
|
||||
// The tail lamps spill red on the bumper around them.
|
||||
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35;
|
||||
color *= 1.0 - wheel * 0.6;
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
||||
const lampCount = count * 2;
|
||||
const mesh = (name: string, material: unknown, instances: number) => {
|
||||
const instanced = new THREE.InstancedMesh(plane, material, instances);
|
||||
const lampCount = count * LAMPS_PER_VEHICLE;
|
||||
const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => {
|
||||
const geometry = resources.own('geometry', plane.clone());
|
||||
const levels = new Float32Array(instances);
|
||||
for (let i = 0; i < instances; i++) levels[i] = level(i);
|
||||
geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
|
||||
const instanced = new THREE.InstancedMesh(geometry, material, instances);
|
||||
instanced.name = name;
|
||||
instanced.frustumCulled = false;
|
||||
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
const levels = new Float32Array(instances);
|
||||
for (let i = 0; i < instances; i++) levels[i] = vehicles[Math.floor(i / 2) % count]!.tint * 0.6;
|
||||
instanced.geometry = plane.clone();
|
||||
resources.own('geometry', instanced.geometry);
|
||||
instanced.geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
|
||||
group.add(instanced);
|
||||
return instanced;
|
||||
};
|
||||
const lamps = mesh('traffic-taillights', lampMaterial, lampCount);
|
||||
const trails = mesh('traffic-light-trails', trailMaterial, lampCount);
|
||||
const pools = mesh('traffic-road-pools', poolMaterial, count);
|
||||
const lampsFor = vehicles.map((v) => trafficLamps(v.lane.kind, v.tint));
|
||||
const lamps = quad('traffic-lamps', lampMaterial, lampCount, (i) => lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]?.level ?? 0);
|
||||
// Two trails per vehicle, from the tail-lamp pair.
|
||||
const trails = quad('traffic-light-trails', trailMaterial, count * 2, (i) => vehicles[Math.floor(i / 2)]!.tint * 0.6);
|
||||
// One pool per vehicle, tinted like that vehicle's own lamps.
|
||||
const pools = quad('traffic-road-pools', poolMaterial, count, (i) => vehicles[i]!.tint * 0.6);
|
||||
|
||||
const bodies = (['car', 'truck'] as const).map((kind) => {
|
||||
const members = vehicles.filter((v) => v.lane.kind === kind);
|
||||
const geometry = resources.own('geometry', boxGeometry(kind === 'car' ? CAR_BOXES : TRUCK_BOXES));
|
||||
const mesh = new THREE.InstancedMesh(geometry, bodyMaterial(kind), Math.max(1, members.length));
|
||||
mesh.name = `traffic-${kind}-bodies`;
|
||||
mesh.frustumCulled = false;
|
||||
mesh.count = members.length;
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
// Bodies draw before the additive lamps so the lamps sit on them.
|
||||
mesh.renderOrder = -1;
|
||||
group.add(mesh);
|
||||
return { mesh, members };
|
||||
});
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
|
||||
return {
|
||||
group,
|
||||
/**
|
||||
@@ -166,33 +343,49 @@ export function createTraffic(
|
||||
const dt = Math.min(0.1, Math.max(0, deltaSeconds));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = vehicles[i]!;
|
||||
const relative = travelSpeed - v.speed; // > 0: we are overtaking
|
||||
const relative = travelSpeed - v.lane.speed; // > 0: we are overtaking
|
||||
v.z = wrapTrafficZ(v.z + relative * dt);
|
||||
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14);
|
||||
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12);
|
||||
// Trails stream away from the direction of relative travel.
|
||||
const trailDirection = relative >= 0 ? -1 : 1;
|
||||
for (let side = 0; side < 2; side++) {
|
||||
const index = i * 2 + side;
|
||||
const x = v.x + (side === 0 ? -v.width : v.width);
|
||||
dummy.position.set(x, v.height, v.z);
|
||||
const own = lampsFor[i]!;
|
||||
for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
|
||||
const lamp = own[k];
|
||||
if (lamp === undefined) {
|
||||
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, hidden);
|
||||
continue;
|
||||
}
|
||||
dummy.position.set(v.x + lamp.x, lamp.y, v.z + lamp.z);
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.scale.set(0.62, 0.36, 1);
|
||||
dummy.scale.set(lamp.w, lamp.h, 1);
|
||||
dummy.updateMatrix();
|
||||
lamps.setMatrixAt(index, dummy.matrix);
|
||||
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, dummy.matrix);
|
||||
if (k > 1) continue;
|
||||
// Horizontal ribbon at lamp height, uv.y = 1 at the lamp.
|
||||
dummy.position.set(x, v.height - 0.02, v.z + (trailDirection * trailLength) / 2);
|
||||
dummy.position.set(v.x + lamp.x, lamp.y - 0.02, v.z + (trailDirection * trailLength) / 2);
|
||||
dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0);
|
||||
dummy.scale.set(0.2, trailLength, 1);
|
||||
dummy.updateMatrix();
|
||||
trails.setMatrixAt(index, dummy.matrix);
|
||||
trails.setMatrixAt(i * 2 + k, dummy.matrix);
|
||||
}
|
||||
const halfWidth = TRAFFIC_BODY[v.lane.kind].halfWidth;
|
||||
const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08);
|
||||
dummy.position.set(v.x, 0.04, v.z + poolLength / 2);
|
||||
dummy.rotation.set(-Math.PI / 2, 0, 0);
|
||||
dummy.scale.set(v.width * 2.3, poolLength, 1);
|
||||
dummy.scale.set(halfWidth * 1.7, poolLength, 1);
|
||||
dummy.updateMatrix();
|
||||
pools.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
for (const { mesh, members } of bodies) {
|
||||
members.forEach((v, j) => {
|
||||
dummy.position.set(v.x, 0, v.z);
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.scale.set(1, 1, 1);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(j, dummy.matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
lamps.instanceMatrix.needsUpdate = true;
|
||||
trails.instanceMatrix.needsUpdate = true;
|
||||
pools.instanceMatrix.needsUpdate = true;
|
||||
|
||||
@@ -64,13 +64,15 @@ html[data-dashcam='on'] .content-focus-cue {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-popover, 50) + 10);
|
||||
inset: 0;
|
||||
cursor: crosshair;
|
||||
cursor: default;
|
||||
color: var(--dash-ink);
|
||||
font: 500 clamp(0.68rem, 0.9vw, 0.82rem) / 1.35 var(--mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-shadow: 0 1px 3px rgb(0 0 0 / 65%);
|
||||
animation: dashcam-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
/* tabindex -1: a click on the empty frame keeps focus inside the dialog. */
|
||||
.dashcam-frame:focus { outline: none; }
|
||||
/* The bars are drawn here on every tier: they close with the lens pass's
|
||||
(same 400 ms, same height) and also cover the outro's own letterbox and
|
||||
its typed credit at the destination. Transform only, so nothing lays out. */
|
||||
@@ -155,7 +157,8 @@ html[data-dashcam='on'] .content-focus-cue {
|
||||
.dashcam-frame__actions button:hover:not(:disabled) { border-color: var(--focus, #ffd693); }
|
||||
.dashcam-frame__actions button:focus-visible { outline: 2px solid var(--focus, #ffd693); outline-offset: 2px; }
|
||||
.dashcam-frame__actions button:disabled { cursor: default; opacity: 0.55; }
|
||||
/* The shutter: a 120 ms white veil. */
|
||||
/* The shutter: a 120 ms white veil at half strength (one user-initiated
|
||||
frame; kept well below a full-field white flash). */
|
||||
html[data-dashcam-shutter] .dashcam-frame,
|
||||
html[data-dashcam-shutter] .dashcam-frame__bar { animation: none; }
|
||||
html[data-dashcam-shutter] .dashcam-frame::after {
|
||||
@@ -176,7 +179,7 @@ html[data-dashcam-shutter] .dashcam-frame::after {
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
@keyframes dashcam-shutter {
|
||||
from { opacity: 0.85; }
|
||||
from { opacity: 0.5; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
|
||||
@@ -98,24 +98,8 @@ html .site-navigation[data-enhanced='true'][data-docked='true']::before {
|
||||
mask-composite: exclude, add;
|
||||
}
|
||||
|
||||
/* Brand plate in the left bay (decorative; the h1 carries the name). */
|
||||
html .site-navigation::after {
|
||||
content: 'LUCA P\00CESLARU' / '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #f4e6ff;
|
||||
font: 680 0.74rem/1 var(--mono);
|
||||
letter-spacing: 0.24em;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
html .site-navigation::after { display: none; }
|
||||
}
|
||||
/* No brand plate in the left bay: the hero carries the name (owner's call:
|
||||
a name in the nav bar read as self-promotion). */
|
||||
|
||||
/* ---------- Exits on the beam ---------- */
|
||||
@media (min-width: 768px) {
|
||||
@@ -529,6 +513,10 @@ html .drive-control-button,
|
||||
html .drive-controls__settings-trigger {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
/* .drive-start was a flex pill (world.css: space-between, 1rem gap); in a
|
||||
one-track grid that pushed the equaliser against the tile's left edge. */
|
||||
place-content: center;
|
||||
gap: 0;
|
||||
inline-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
block-size: 44px;
|
||||
@@ -833,7 +821,6 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
html .site-navigation::before { background: Canvas; }
|
||||
html .site-navigation::after { color: CanvasText; }
|
||||
.gantry-window { border: 1px solid ButtonText; box-shadow: none; }
|
||||
.gantry-window::before,
|
||||
.gantry-window::after { border-color: Highlight; }
|
||||
|
||||
@@ -61,3 +61,25 @@ html[data-maintenance-step='backup'] .journey-hud__subline { color: #ffc27a; box
|
||||
color 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* END OF SHIFT: the plate's arrival word is visually hidden in the gantry
|
||||
(gantry.css), so the SHIFT OVER swap happens on the sub-line instead: at
|
||||
full daylight "END OF SHIFT · 05:48" turns over to SHIFT OVER, in gold. */
|
||||
html[data-dawn='day'] .journey-hud[data-arrived='true'] .journey-hud__subline::before {
|
||||
content: 'SHIFT OVER' / '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: inherit;
|
||||
background: #0b0918;
|
||||
box-shadow: inset 0 1px rgb(255 214 147 / 40%);
|
||||
color: #ffd693;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html[data-dawn='day'] .journey-hud[data-arrived='true'] .journey-hud__subline::before {
|
||||
animation: shift-over 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
}
|
||||
@keyframes shift-over {
|
||||
from { opacity: 0; transform: translateY(-40%); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@@ -59,16 +59,20 @@
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* The address is a flex item after the link, so it always clears the
|
||||
button's end whatever the button's width or font; it slides out from
|
||||
underneath the link (z-index 1 above). */
|
||||
.contact-email__preview {
|
||||
position: absolute;
|
||||
inset-inline-start: 116px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 16px;
|
||||
white-space: nowrap;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-24px);
|
||||
transition: opacity 200ms ease, transform 260ms ease, visibility 260ms;
|
||||
transform: translateX(-32px);
|
||||
transition: opacity 220ms ease, transform 280ms cubic-bezier(0.22, 1, 0.36, 1), visibility 280ms;
|
||||
}
|
||||
@media (hover: hover) and (min-width: 768px) {
|
||||
.contact-email__action:is(:hover, :focus-within) .contact-email__preview {
|
||||
|
||||
@@ -223,6 +223,11 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) #contact .section-eyebrow {
|
||||
color: #3b1a33;
|
||||
text-shadow: 0 1px 12px rgb(255 226 196 / 45%);
|
||||
}
|
||||
/* Without the scene the eyebrow sits on the CSS daybreak's blue zenith. */
|
||||
html:is([data-dawn='sunrise'], [data-dawn='day']):has(.journey-world[data-static-fallback='true']) #contact .section-eyebrow {
|
||||
color: #ffe3c4;
|
||||
text-shadow: 0 1px 8px rgb(20 16 48 / 60%);
|
||||
}
|
||||
|
||||
/* END OF SHIFT: at full daylight the label reads SHIFT OVER (a string swap). */
|
||||
html[data-dawn='day'] .journey-hud__arrival { font-size: 0; }
|
||||
@@ -608,11 +613,19 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
|
||||
}
|
||||
.journey-poster { transition: filter 1.6s ease, opacity 1.6s ease; }
|
||||
/* END OF SHIFT without the scene: the world crossfades to the daybreak sky
|
||||
(indigo, dusty rose, pale amber) under the dimmed night poster. */
|
||||
under the dimmed night poster.
|
||||
The poster's sky and its magenta sunset sun are masked out (they read as
|
||||
dusk); the daybreak sky shows above the horizon with a low gold sun, a
|
||||
cool clear zenith, and the night terrain dimmed below. */
|
||||
.journey-world[data-static-fallback='true'][data-arrived='true'] {
|
||||
background: linear-gradient(180deg, #6b4a7a 0%, #b07a8c 44%, #ffb98a 58%, #ffe1c2 61%, #1a0f14 100%);
|
||||
background:
|
||||
radial-gradient(circle at 50% 44%, #fff6df 0 3.2%, rgb(255 222 160 / 75%) 6%, rgb(255 190 130 / 28%) 16%, transparent 34%),
|
||||
linear-gradient(180deg, #2f3a6e 0%, #565a92 26%, #a77890 38%, #f2a676 42.5%, #ffe3b8 44.5%, #2a1820 52%, #1a0f14 100%);
|
||||
}
|
||||
.journey-world[data-static-fallback='true'][data-arrived='true'] .journey-poster {
|
||||
opacity: 0.5;
|
||||
mask-image: linear-gradient(180deg, transparent 0 43%, #000 54%);
|
||||
}
|
||||
.journey-world[data-static-fallback='true'][data-arrived='true'] .journey-poster { opacity: 0.45; }
|
||||
.journey-world[data-arrived='true'] .journey-poster { filter: brightness(0.9); }
|
||||
.journey-world::after {
|
||||
inset-block-end: 0;
|
||||
|
||||
Reference in New Issue
Block a user