feat: floodlit roadside billboard carries the selected-builds case studies

This commit is contained in:
2026-10-11 07:09:31 +03:00
parent 19f3ce79e6
commit d71b0ecfd4
31 changed files with 1216 additions and 6 deletions

View File

@@ -0,0 +1,38 @@
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
import type { PublicAssetManifest, PublicProject } from '@/content/types';
import type { JourneySnapshot } from '@/journey/types';
import { assetUrl } from '@/content/asset-url';
import { routePlate } from '@/lib/route-plate';
import { sectionLocalProgress } from '@/journey/route-log';
import { activeStop, billboardStops, publishBillboard, readBillboard } from '@/journey/billboard-feed';
/** DRIVE-IN, DOM side: html[data-billboard-active] and (fine pointer) html[data-billboard-focus]. After mount only. */
export function useBillboard(journey: Readonly<Ref<JourneySnapshot>>, list: () => readonly PublicProject[], manifest: PublicAssetManifest, exit: string): void {
const stops = shallowRef<number[]>([]);
const html = (): DOMStringMap => document.documentElement.dataset;
const top = (el: Element | null | undefined): number => (el?.getBoundingClientRect().top ?? 0) + scrollY;
const cases = (): HTMLElement[] => [...document.querySelectorAll<HTMLElement>('#selected-builds [data-project-id]')];
const measure = (): void => {
stops.value = billboardStops(top(document.getElementById('selected-builds-heading')), top(document.getElementById('additional-work-heading')), cases().map((el) => top(el.firstElementChild)));
publishBillboard(list().map((p, i) => ({ id: p.id, title: p.title, plate: routePlate(exit, i, p),
shots: (p.media ?? []).flatMap((m) => (m.kind === 'video' ? [] : [{ id: m.assetId, url: assetUrl(manifest, m.assetId), kind: m.kind }])) })), stops.value);
};
const active = computed(() => list()[activeStop(sectionLocalProgress(journey.value, 'selected-builds'), stops.value, list().length)]?.id ?? null);
watch(() => journey.value.sectionActivation.layoutRevision, measure);
watch(active, (id) => {
if (id === null) delete html().billboardActive; else html().billboardActive = id;
for (const el of cases()) el.toggleAttribute('data-billboard-lit', el.dataset.projectId === id);
});
let idle = 0;
const hover = (event: Event): void => {
const src = (event.target as Element).closest?.('.project-media-gallery img')?.getAttribute('src');
const shot = readBillboard().projects.flatMap((p) => p.shots).find((s) => s.url === src);
clearTimeout(idle);
if (shot !== undefined && event.type === 'pointerover') html().billboardFocus = shot.id;
else if (shot !== undefined) idle = window.setTimeout(() => delete html().billboardFocus, 1200);
};
const listen = (on: boolean): void => ['pointerover', 'pointerout'].forEach((type) =>
document.getElementById('selected-builds')?.[on ? 'addEventListener' : 'removeEventListener'](type, hover));
onMounted(() => { measure(); if (window.matchMedia?.('(hover: hover) and (pointer: fine)').matches) listen(true); });
onUnmounted(() => { listen(false); clearTimeout(idle); delete html().billboardActive; delete html().billboardFocus; });
}