diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index 2e72dc0..ec65c77 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -55,3 +55,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page). - **Verdict: KEEP with fixes** (Fable, hardware GL): Contact bars were striped (aubergine outro bars under black), now frame draws its own bars; canvas resized on open due to overflow lock (removed); focus dropped after save; `c` worked on phones where the button is hidden; round 5's HUD exit tab had been dropped, restored; REC dot got a no-music blink. - **Known weaknesses:** postcard is the 1440x900 buffer scaled to 1600 (slightly soft) and keeps the lens bars; no phone version; shutter click only while soundtrack runs; two chapter-label vocabularies in the frame. - Screenshots: docs/overnight/round6/ (postcard.webp is the real downloaded file). + +## Round 7: Relay Station (Toolkit transmitter masts) +- **What:** the Toolkit chapter is a pre-dawn relay station. Four lattice masts (one per toolkit group, rung count = technology count: 11/8/4/3) stand in the side margins; as scroll passes 0.04/0.22/0.40/0.58 each comes online: violet lamp rungs ignite bottom-up, a red beacon blinks at 0.5 Hz, a violet sky beam sweeps. The reading plates get an accent "· ONLINE" state in the same order. Hovering a technology flashes its rung. Reverse scroll takes it offline. A short synthesized chirp plays per mast when sound is on. Lite tier: static lit masts; reduced motion: CSS-only ONLINE plates. +- **Why:** Toolkit was the last chapter with no world beat and the dullest content in any portfolio; lists become a lit skyline with honest counts, placed in the margins the panel does not cover. +- **Build:** journey/relay-progress.ts, composables/useRelay.ts, scene/world/relay-masts.ts, audio/relay-tone.ts (lazy), styles/relay.css. 839 tests, initial JS 118.08 KB gz (**1.9 KB left**), scene chunk 214 KB. +- **Verdict: KEEP with fixes** (Fable, hardware GL): near/far pairs overlapped and the near-right mast clipped, so layout is now aspect-aware with the far mast closer to centre; lamps were white and rungs grey, now hot violet above the bloom threshold; beams brightened and widened with a eased base; hover flash made visible. +- **Known weaknesses:** lamp cores still tonemap to lavender-white (violet lives in the halo); masts are slivers on phones and modest at 1024 wide; hover flash is a peripheral beat; chirp verified by unit test only. +- Screenshots: docs/overnight/round7/. diff --git a/docs/overnight/round7/before-p05-1440.webp b/docs/overnight/round7/before-p05-1440.webp new file mode 100644 index 0000000..73bcc6f Binary files /dev/null and b/docs/overnight/round7/before-p05-1440.webp differ diff --git a/docs/overnight/round7/hover-flash-strip-1440.webp b/docs/overnight/round7/hover-flash-strip-1440.webp new file mode 100644 index 0000000..29438d2 Binary files /dev/null and b/docs/overnight/round7/hover-flash-strip-1440.webp differ diff --git a/docs/overnight/round7/p01-1440-mast1-online-entry.webp b/docs/overnight/round7/p01-1440-mast1-online-entry.webp new file mode 100644 index 0000000..a1b1831 Binary files /dev/null and b/docs/overnight/round7/p01-1440-mast1-online-entry.webp differ diff --git a/docs/overnight/round7/p05-1440-three-online-reading.webp b/docs/overnight/round7/p05-1440-three-online-reading.webp new file mode 100644 index 0000000..3a7902b Binary files /dev/null and b/docs/overnight/round7/p05-1440-three-online-reading.webp differ diff --git a/docs/overnight/round7/p05-390-and-reduced-motion.webp b/docs/overnight/round7/p05-390-and-reduced-motion.webp new file mode 100644 index 0000000..59776f6 Binary files /dev/null and b/docs/overnight/round7/p05-390-and-reduced-motion.webp differ diff --git a/docs/overnight/round7/p09-1024x700.webp b/docs/overnight/round7/p09-1024x700.webp new file mode 100644 index 0000000..dab00e9 Binary files /dev/null and b/docs/overnight/round7/p09-1024x700.webp differ diff --git a/docs/overnight/round7/p09-1440-all-online-beams.webp b/docs/overnight/round7/p09-1440-all-online-beams.webp new file mode 100644 index 0000000..66f9c1d Binary files /dev/null and b/docs/overnight/round7/p09-1440-all-online-beams.webp differ diff --git a/docs/overnight/round7/reverse-p002-1440-offline.webp b/docs/overnight/round7/reverse-p002-1440-offline.webp new file mode 100644 index 0000000..2b3782d Binary files /dev/null and b/docs/overnight/round7/reverse-p002-1440-offline.webp differ diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index 17c4839..b1ea366 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -40,6 +40,8 @@ export const classifyArtifact = (path, source = '') => { if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene'; // DASHCAM: the photo-mode frame and postcard, loaded on first open. if (/^assets\/DashcamFrame-[^/]+\.(?:js|css)$/u.test(path)) return 'deferred-non-video'; + // RELAY STATION: the mast chirp, loaded when the first mast comes online with sound on. + if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene'; if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts'; if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster'; diff --git a/src/App.vue b/src/App.vue index c1a8ed9..c673b9f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,6 +20,7 @@ import './styles/headlight.css'; import './styles/route-log.css'; import './styles/billboard.css'; import './styles/dashcam.css'; +import './styles/relay.css'; defineProps<{ inputs: Readonly }>(); diff --git a/src/audio/relay-tone.ts b/src/audio/relay-tone.ts new file mode 100644 index 0000000..c64d3c0 --- /dev/null +++ b/src/audio/relay-tone.ts @@ -0,0 +1,45 @@ +import { readSwitchboard } from '@/audio/music-pulse'; + +/** Tone length, seconds. */ +export const RELAY_TONE_SECONDS = 0.12; +export const RELAY_TONE_GAIN = 0.12; +/** At most one tone every 0.6 s, however fast the scroll. */ +export const RELAY_TONE_GAP_SECONDS = 0.6; + +let lastStart = Number.NEGATIVE_INFINITY; + +/** + * RELAY STATION: a mast coming online chirps once, a 120 ms sine rising + * 880 -> 1320 Hz, through the switchboard (mix bus) so mute and volume + * apply. Lazy-loaded by the scene; plays only while the soundtrack is + * running and audible; never creates an AudioContext and never throws. + */ +export function playRelayTone(): boolean { + try { + const input = readSwitchboard(); + if (input === null || input.context.state !== 'running') return false; + const context = input.context; + const start = context.currentTime + 0.005; + if (start - lastStart < RELAY_TONE_GAP_SECONDS) return false; + lastStart = start; + const osc = context.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, start); + osc.frequency.exponentialRampToValueAtTime(1320, start + RELAY_TONE_SECONDS); + const envelope = context.createGain(); + envelope.gain.setValueAtTime(0, start); + envelope.gain.linearRampToValueAtTime(RELAY_TONE_GAIN, start + 0.01); + envelope.gain.exponentialRampToValueAtTime(0.0002, start + RELAY_TONE_SECONDS); + osc.connect(envelope); + envelope.connect(input); + osc.onended = () => { + osc.disconnect(); + envelope.disconnect(); + }; + osc.start(start); + osc.stop(start + RELAY_TONE_SECONDS + 0.01); + return true; + } catch { + return false; + } +} diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 0ee394e..8714dff 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -25,6 +25,7 @@ import { useRouteMemory } from '@/composables/useRouteMemory'; import { useRouteLog } from '@/composables/useRouteLog'; import { useBillboard } from '@/composables/useBillboard'; import { useDashcam } from '@/composables/useDashcam'; +import { useRelay } from '@/composables/useRelay'; import { assetUrl } from '@/content/asset-url'; import { signRouteFor } from '@/journey/sign-routes'; import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn'; @@ -93,6 +94,7 @@ const maintenanceSubline = useMaintenanceWindow(journey); const routeLogSubline = useRouteLog(journey, () => props.content); /* DRIVE-IN (Selected builds): the floodlit billboard follows the case studies. */ useBillboard(journey, () => props.content.projects.selected, props.manifest, exitFor('selected-builds')); +useRelay(journey, () => props.content.toolkit); useCardEntrances(); const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId); const resume = computed(() => { diff --git a/src/composables/useRelay.ts b/src/composables/useRelay.ts new file mode 100644 index 0000000..f9790aa --- /dev/null +++ b/src/composables/useRelay.ts @@ -0,0 +1,35 @@ +import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue'; +import type { ToolkitGroup } from '@/content/types'; +import type { JourneySnapshot } from '@/journey/types'; +import { sectionLocalProgress } from '@/journey/route-log'; +import { publishRelay, relayOnline } from '@/journey/relay-progress'; + +/** + * RELAY STATION, DOM side: html[data-relay-online] ("1".."4") through + * Toolkit and (fine pointer) html[data-relay-focus] ("group:item") while a + * technology is hovered. After mount only, so the server HTML is unchanged; + * works without the scene (CSS keys the ONLINE plates off the attribute). + */ +export function useRelay(journey: Readonly>, groups: () => readonly ToolkitGroup[]): void { + const mounted = shallowRef(false); + const html = (): DOMStringMap => document.documentElement.dataset; + const online = computed(() => (mounted.value ? relayOnline(sectionLocalProgress(journey.value, 'toolkit')) : 0)); + watch(online, (n) => { if (n === 0) delete html().relayOnline; else html().relayOnline = String(n); }); + let idle = 0; + const hover = (event: Event): void => { + const item = (event.target as Element).closest?.('.toolkit-item'); + const group = item?.closest('.toolkit-group'); + if (!item || !group?.parentElement) return; + clearTimeout(idle); + if (event.type === 'pointerover') html().relayFocus = `${[...group.parentElement.children].indexOf(group)}:${[...item.parentElement!.children].indexOf(item)}`; + else idle = window.setTimeout(() => delete html().relayFocus, 1200); + }; + const listen = (on: boolean): void => ['pointerover', 'pointerout'].forEach((type) => + document.getElementById('toolkit')?.[on ? 'addEventListener' : 'removeEventListener'](type, hover)); + onMounted(() => { + publishRelay(groups().map((group) => group.technologies.length)); + mounted.value = true; + if (window.matchMedia?.('(hover: hover) and (pointer: fine)').matches) listen(true); + }); + onUnmounted(() => { mounted.value = false; listen(false); clearTimeout(idle); delete html().relayOnline; delete html().relayFocus; }); +} diff --git a/src/journey/relay-progress.ts b/src/journey/relay-progress.ts new file mode 100644 index 0000000..b4628fd --- /dev/null +++ b/src/journey/relay-progress.ts @@ -0,0 +1,35 @@ +/** + * RELAY STATION: the Toolkit chapter is a pre-dawn relay station. One mast + * per toolkit group comes online in turn as the chapter scrolls, its lamp + * rungs igniting bottom-up, one per technology. Shared by the DOM + * (html[data-relay-online]) and the lazy scene chunk, so it stays tiny and + * pure. Scroll-driven, so scrolling back takes the masts offline again. + */ + +/** Chapter-local progress at which each mast comes online. */ +export const RELAY_STOPS = [0.04, 0.22, 0.4, 0.58] as const; +/** A mast's rungs light bottom-up over this much chapter progress after its stop. */ +export const RELAY_RAMP = 0.12; +/** Beacon blink rate (photosensitivity: well under 3 Hz). */ +export const BEACON_HZ = 0.5; + +/** How many masts are online at chapter progress p (0 outside the chapter). */ +export const relayOnline = (p: number | null): number => + p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length; + +/** 0..1: rung `rung` of `count` on mast `mast` at progress p. Each rung takes its own slice of the ramp. */ +export function rungLit(p: number | null, mast: number, rung: number, count: number): number { + const stop = RELAY_STOPS[mast]; + if (p === null || stop === undefined || count <= 0) return 0; + const t = Math.min(1, Math.max(0, (p - stop - (RELAY_RAMP * rung) / count) / (RELAY_RAMP / count))); + return t * t * (3 - 2 * t); +} + +/** The DOM publishes rung counts (technologies per group) here; the scene reads them each frame. */ +let counts: readonly number[] = []; + +export function publishRelay(next: readonly number[]): void { + counts = next.slice(0, RELAY_STOPS.length); +} + +export const readRelay = (): { counts: readonly number[] } => ({ counts }); diff --git a/src/scene/world/composition.ts b/src/scene/world/composition.ts index 11a5a69..b8851ce 100644 --- a/src/scene/world/composition.ts +++ b/src/scene/world/composition.ts @@ -94,3 +94,9 @@ export const CHAPTER_COMPOSITIONS: Readonly< * out when the board goes dark, so it is fully reversible. */ export const BILLBOARD_NUDGE = { targetZ: -6, shift: 0.03, seconds: 0.9 } as const; + +/** + * RELAY STATION: while a mast is online the lens lifts toward the mast tops + * and the sky beams. Eased in over 0.9 s, back out when the station goes dark. + */ +export const RELAY_NUDGE = { targetY: 3, seconds: 0.9 } as const; diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index fb528be..7be55c1 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -38,7 +38,8 @@ import { createHeadlights } from '@/scene/world/headlights'; import { maintenanceProgress } from '@/journey/maintenance-progress'; import { createMilestones } from '@/scene/world/milestones'; import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard'; -import { BILLBOARD_NUDGE } from '@/scene/world/composition'; +import { BILLBOARD_NUDGE, RELAY_NUDGE } from '@/scene/world/composition'; +import { createRelayMasts } from '@/scene/world/relay-masts'; import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log'; import { dawnStep, type DawnStep } from '@/journey/dawn'; import { dawnLightUniforms } from '@/scene/world/dawn-light'; @@ -185,7 +186,15 @@ export async function createProceduralSceneGeneration( textureSize: textureSize(context.quality), }); let billboardKey = ''; + // RELAY STATION: transmitter masts come online through Toolkit. Low tier, + // touch and data saver: all rungs lit, steady beacons, no beams, hover or tone. + const relay = createRelayMasts( + context.resources, + maintenanceTier === 'full' + && (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true, + ); scene.add( + relay.group, billboard.mesh, maintenance.group, milestones.mesh, @@ -380,6 +389,15 @@ export async function createProceduralSceneGeneration( musicPulse, deltaSeconds, ); + const relayNudge = relay.update( + snapshot.activeSectionId, + snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null, + musicPulse, + document.documentElement.dataset.relayFocus, + deltaSeconds, + now, + camera.aspect, + ); const billboardNudge = billboard.update({ section: snapshot.activeSectionId, p: snapshot.activeSectionId === 'selected-builds' @@ -533,7 +551,7 @@ export async function createProceduralSceneGeneration( baseComposition.targetY, upcoming.targetY, anticipation, - ), + ) + RELAY_NUDGE.targetY * relayNudge, targetZ: THREE.MathUtils.lerp( baseComposition.targetZ, upcoming.targetZ, diff --git a/src/scene/world/relay-masts.ts b/src/scene/world/relay-masts.ts new file mode 100644 index 0000000..9c9d7f7 --- /dev/null +++ b/src/scene/world/relay-masts.ts @@ -0,0 +1,469 @@ +import * as THREE from 'three'; +import type { SectionId } from '@/content/types'; +import { SIGN_ACCENTS } from '@/journey/sign-routes'; +import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit } from '@/journey/relay-progress'; +import type { SceneResourceBag } from '@/scene/resources'; +import { CHAPTER_COMPOSITIONS, RELAY_NUDGE } from './composition'; + +type ThreeVector3 = InstanceType; +type ThreeBufferGeometry = InstanceType; +type ThreeColor = InstanceType; +type ThreeShaderMaterial = InstanceType; +type ThreeGroup = InstanceType; +type ThreeMaterial = InstanceType; +type ThreeInstancedBufferAttribute = InstanceType; +type ThreeInstancedMesh = InstanceType; + +/** + * RELAY STATION, scene side. Four lattice transmitter masts, one per toolkit + * group, stand on the far shoulders, alternating sides (first on the right). + * As the chapter scrolls each comes online in turn: its lamp rungs ignite + * bottom-up (one per technology), a red beacon blinks at 0.5 Hz and a thin + * additive beam sweeps the sky every 6 s. Every beat is a pure function of + * section-local progress, so scrolling back takes the masts offline again. + * One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a + * technology flashes its rung. Lite (low tier, touch, data saver): all rungs + * lit from chapter entry, steady beacons, no beams, hover or tone. + */ +/** Lattice mast at scale 1 (the world is ~2.3x life size). */ +export const MAST_HEIGHT = 44; +const BASE_HALF = 2.4; +const TOP_HALF = 0.7; +const ANTENNA = 6; +const LEG = 0.36; +const BRACE = 0.16; +const SECTIONS = 9; +/** Rungs fill this band of the mast's height, bottom-up. */ +const RUNG_FROM = 0.08; +const RUNG_SPAN = 0.8; +const MAX_RUNGS = 64; +const FLASH_SECONDS = 0.45; +/** A flashing rung swells (width, height factors): the lit lamps are already hot, so colour alone would not read. */ +const FLASH_SWELL = { width: 0.5, height: 2.2 } as const; +/** One hover flash per rung at most every 0.35 s (photosensitivity). */ +export const RUNG_FLASH_GAP_SECONDS = 0.35; +const BEAM_LENGTH = 170; +const BEAM_WIDTH = 7; +/** Beam brightness cap (additive, so bloom does not blow out). */ +export const BEAM_INTENSITY = 0.65; +const SWEEP_SECONDS = 6; +const PULSE_GAIN = 0.08; + +/** + * Where the masts stand, placed by where they land in the frame (toolkit + * lens: camera y 8.6, z 25, fov 62). `fx` is the fraction of the half-width + * from the centre: the 84%-wide reading panel covers up to 0.84, so every + * mast sits in the side margin. The near mast of each pair stands at the + * frame edge, the far one just inside it, closer to the centre (perspective: + * a row receding toward the vanishing point), so the pair reads as two + * towers along the shoulder and never overlaps. `fy` is where the beacon + * lands as a fraction of the half-height above the centre: in the upper + * third, clear of the nav bar. x and the scale follow from those and the + * viewport aspect, so the masts keep their place on narrow lenses too. + * Portrait (phones): the margins are slivers, so the masts tuck under the + * panel edge instead of leaving the frame. + */ +const LENS = CHAPTER_COMPOSITIONS.toolkit; +export const DESIGN_ASPECT = 1.6; +const TAN_HALF_HEIGHT = Math.tan(THREE.MathUtils.degToRad(LENS.fov / 2)); +const SLOT_FRAMES = [ + { z: -55, fx: 0.94, fy: 0.72 }, + { z: -55, fx: 0.94, fy: 0.72 }, + { z: -130, fx: 0.875, fy: 0.56 }, + { z: -130, fx: 0.875, fy: 0.56 }, +] as const; + +interface RelaySlot { + z: number; + side: number; + x: number; + scale: number; +} + +let slotAspect = Number.NaN; +let slots: RelaySlot[] = []; + +/** The four stands for a viewport aspect (memoised: the aspect changes only on resize). */ +export function relaySlots(aspect: number = DESIGN_ASPECT): RelaySlot[] { + if (aspect === slotAspect) return slots; + slotAspect = aspect; + const portrait = aspect < 1.18; + slots = SLOT_FRAMES.map((slot, i) => { + const side = i % 2 === 0 ? 1 : -1; + const distance = LENS.z - slot.z; + const fx = portrait ? slot.fx * 0.9 : slot.fx; + return { + z: slot.z, + side, + x: side * fx * TAN_HALF_HEIGHT * aspect * distance, + scale: (LENS.y + slot.fy * TAN_HALF_HEIGHT * distance) / (MAST_HEIGHT + ANTENNA), + }; + }); + return slots; +} + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); +const smooth = (from: number, to: number, value: number): number => { + const t = clamp01((value - from) / (to - from)); + return t * t * (3 - 2 * t); +}; +const halfWidthAt = (y: number): number => THREE.MathUtils.lerp(BASE_HALF, TOP_HALF, y / MAST_HEIGHT); + +/** The chapter fades the station in and out at its edges. */ +export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p)); + +export interface RelayRung { + /** World height of the rung's centre. */ + y: number; + halfWidth: number; + lit: number; +} + +export interface RelayMast { + x: number; + z: number; + scale: number; + side: number; + height: number; + presence: number; + /** 0..1: the mast is online (beacon and beam). */ + level: number; + rungs: RelayRung[]; +} + +/** Every mast's frame for chapter progress p (null outside Toolkit: all absent). */ +export function relayLayout( + p: number | null, + counts: readonly number[] = readRelay().counts, + full = true, + aspect: number = DESIGN_ASPECT, +): RelayMast[] { + return relaySlots(aspect).slice(0, counts.length).map((slot, mast) => { + const count = counts[mast] ?? 0; + const presence = p === null ? 0 : relayPresence(p); + const live = presence > 0; + return { + x: slot.x, + z: slot.z, + scale: slot.scale, + side: slot.side, + height: (MAST_HEIGHT + ANTENNA) * slot.scale, + presence, + level: !live ? 0 : full ? smooth(RELAY_STOPS[mast]!, RELAY_STOPS[mast]! + 0.03, p!) : 1, + rungs: Array.from({ length: count }, (_, rung) => { + const y = MAST_HEIGHT * (RUNG_FROM + (RUNG_SPAN * (rung + 0.5)) / count); + return { y: y * slot.scale, halfWidth: halfWidthAt(y) * slot.scale, lit: !live ? 0 : full ? rungLit(p, mast, rung, count) : 1 }; + }), + }; + }); +} + +/** One strut from a to b, square section t. */ +function strut(a: ThreeVector3, b: ThreeVector3, t: number): ThreeBufferGeometry { + const dir = b.clone().sub(a); + const geometry = new THREE.BoxGeometry(t, dir.length(), t).translate(0, dir.length() / 2, 0); + geometry.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize())); + return geometry.translate(a.x, a.y, a.z); +} + +function merge(pieces: ThreeBufferGeometry[]): ThreeBufferGeometry { + const positions: number[] = []; + const normals: number[] = []; + const indices: number[] = []; + for (const piece of pieces) { + const offset = positions.length / 3; + positions.push(...piece.getAttribute('position').array); + normals.push(...piece.getAttribute('normal').array); + indices.push(...Array.from(piece.getIndex()!.array as ArrayLike, (i) => i + offset)); + piece.dispose(); + } + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + merged.setIndex(indices); + return merged; +} + +/** Four tapered legs, X braces and rings on every face, and an antenna. */ +function latticeGeometry(): ThreeBufferGeometry { + const corner = (y: number, sx: number, sz: number) => new THREE.Vector3(sx * halfWidthAt(y), y, sz * halfWidthAt(y)); + const corners = [[1, 1], [-1, 1], [-1, -1], [1, -1]] as const; + const pieces: ThreeBufferGeometry[] = []; + corners.forEach(([sx, sz]) => pieces.push(strut(corner(0, sx, sz), corner(MAST_HEIGHT, sx, sz), LEG))); + for (let k = 0; k <= SECTIONS; k += 1) { + const y0 = (MAST_HEIGHT * k) / SECTIONS; + const y1 = (MAST_HEIGHT * (k + 1)) / SECTIONS; + corners.forEach(([sx, sz], c) => { + const [nx, nz] = corners[(c + 1) % 4]!; + pieces.push(strut(corner(y0, sx, sz), corner(y0, nx, nz), BRACE)); + if (k < SECTIONS) { + pieces.push(strut(corner(y0, sx, sz), corner(y1, nx, nz), BRACE)); + pieces.push(strut(corner(y0, nx, nz), corner(y1, sx, sz), BRACE)); + } + }); + } + pieces.push(strut(new THREE.Vector3(0, MAST_HEIGHT - 1, 0), new THREE.Vector3(0, MAST_HEIGHT + ANTENNA, 0), 0.3)); + return merge(pieces); +} + +const SCREEN_DOOR = /* glsl */ ` + vec2 cell = mod(floor(gl_FragCoord.xy), 4.0); + float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0; + if (vState.z < threshold) discard; +`; + +/** x: lit/level, y: flash, z: presence. */ +function solidMaterial(accent: ThreeColor, pulse: { value: number }, body: string): ThreeShaderMaterial { + return new THREE.ShaderMaterial({ + uniforms: { uAccent: { value: accent }, uPulse: pulse }, + vertexShader: /* glsl */ ` + attribute vec3 aState; + varying vec3 vState; + varying vec3 vNormal; + void main() { + vState = aState; + vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal); + gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uAccent; + uniform float uPulse; + varying vec3 vState; + varying vec3 vNormal; + void main() { + ${SCREEN_DOOR} + float key = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.45, 0.85, 0.35)))); + vec3 color; + ${body} + gl_FragColor = vec4(color, 1.0); + #include + #include + } + `, + fog: false, + }); +} + +export interface RelayRungState { + lit: number; + flash: number; +} + +export interface RelayMastsWorld { + readonly group: ThreeGroup; + /** + * Section-local progress (null outside Toolkit), the smoothed bass pulse, + * html[data-relay-focus] ("group:item"), frame delta and the frame clock + * (ms) and the viewport aspect. Returns the camera nudge, 0..1 (eased). + */ + update(section: SectionId, p: number | null, pulse: number, focus: string | undefined, deltaSeconds: number, now: number, aspect?: number): number; + /** Test hook: every rung's lit and flash level, in group order. */ + rungs(): RelayRungState[]; + /** Test hook: each beacon's current level. */ + beacons(): number[]; + /** Test hook: masts online, and tones requested. */ + online(): { count: number; tones: number }; +} + +const instanced = (geometry: ThreeBufferGeometry, material: ThreeMaterial, count: number, name: string) => { + const mesh = new THREE.InstancedMesh(geometry, material, count); + mesh.name = name; + mesh.frustumCulled = false; + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + return mesh; +}; + +const stateAttribute = (geometry: ThreeBufferGeometry, count: number): ThreeInstancedBufferAttribute => { + const state = new THREE.InstancedBufferAttribute(new Float32Array(count * 3), 3); + state.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('aState', state); + return state; +}; + +const defaultTone = (): void => { + void import('@/audio/relay-tone').then((tone) => tone.playRelayTone(), () => undefined); +}; + +export function createRelayMasts(resources: SceneResourceBag, full: boolean, tone: () => void = defaultTone): RelayMastsWorld { + const accent = new THREE.Color(SIGN_ACCENTS.toolkit); + const pulseUniform = { value: 0 }; + const group = new THREE.Group(); + group.name = 'relay-station'; + group.visible = false; + + const frameGeometry = resources.own('geometry', latticeGeometry()); + const frameState = stateAttribute(frameGeometry, SLOT_FRAMES.length); + const frames = instanced(frameGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ ` + color = vec3(0.07, 0.06, 0.1) + vec3(0.1, 0.09, 0.15) * key + uAccent * (0.08 + 0.22 * vState.x); + `)), SLOT_FRAMES.length, 'relay-masts'); + + const rungGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1)); + const rungState = stateAttribute(rungGeometry, MAX_RUNGS); + const rungMesh = instanced(rungGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ ` + // Lit lamps are a hot, saturated violet (hotter than the bloom threshold, so they glow violet, not white). + vec3 lamp = vec3(0.62, 0.3, 1.0) * 2.1 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse); + vec3 dark = uAccent * 0.12 + vec3(0.02) * key; + // Hover flash: the rung turns ice-white and swells (see FLASH_SWELL). + color = mix(mix(dark, lamp, vState.x), vec3(0.78, 1.0, 1.0) * 2.4, vState.y); + `)), MAX_RUNGS, 'relay-rungs'); + + const beaconGeometry = resources.own('geometry', new THREE.OctahedronGeometry(1.6)); + const beaconState = stateAttribute(beaconGeometry, SLOT_FRAMES.length); + const beaconMesh = instanced(beaconGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ ` + color = vec3(1.0, 0.12, 0.08) * (0.12 + 2.2 * vState.x); + `)), SLOT_FRAMES.length, 'relay-beacons'); + group.add(frames, rungMesh, beaconMesh); + + // The sky beam: a thin additive quad from the beacon, leaning in over the road and sweeping every 6 s. + let beamMesh: ThreeInstancedMesh | null = null; + let beamState: ThreeInstancedBufferAttribute | null = null; + if (full) { + const beamGeometry = resources.own('geometry', new THREE.PlaneGeometry(1, 1).translate(0, 0.5, 0)); + beamState = stateAttribute(beamGeometry, SLOT_FRAMES.length); + beamMesh = instanced(beamGeometry, resources.own('material', new THREE.ShaderMaterial({ + uniforms: { uAccent: { value: accent } }, + vertexShader: /* glsl */ ` + attribute vec3 aState; + varying vec2 vUv; + varying float vLevel; + void main() { + vUv = uv; + vLevel = aState.x * aState.z; + gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uAccent; + varying vec2 vUv; + varying float vLevel; + void main() { + float across = pow(max(0.0, 1.0 - abs(vUv.x - 0.5) * 2.0), 2.0); + // The base eases in over the first 12% so the source does not read as a blown blob at the beacon. + float along = pow(1.0 - vUv.y, 1.6) * smoothstep(0.0, 0.12, vUv.y); + vec3 color = mix(vec3(0.6, 0.36, 1.0), vec3(0.9, 0.92, 1.0), 0.3) * across * along * ${BEAM_INTENSITY.toFixed(2)} * vLevel; + gl_FragColor = vec4(color, 1.0); + #include + #include + } + `, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + fog: false, + })), SLOT_FRAMES.length, 'relay-beams'); + group.add(beamMesh); + } + + const flash = new Float32Array(MAX_RUNGS); + const sinceFlash = new Float32Array(MAX_RUNGS).fill(Number.POSITIVE_INFINITY); + const lit = new Float32Array(MAX_RUNGS); + const beaconLevels = [0, 0, 0, 0]; + let total = 0; + let lastFocus: string | undefined; + let lastSection: SectionId | null = null; + let onlineCount = 0; + let tones = 0; + let nudgeRun = 0; + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const rotation = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + const forward = new THREE.Vector3(0, 0, 1); + + return { + group, + rungs: () => Array.from({ length: total }, (_, i) => ({ lit: lit[i]!, flash: flash[i]! })), + beacons: () => [...beaconLevels], + online: () => ({ count: onlineCount, tones }), + update(section, p, pulse, focus, deltaSeconds, now, aspect = DESIGN_ASPECT) { + const dt = Math.max(0, deltaSeconds); + const live = section === 'toolkit' ? p : null; + const masts = relayLayout(live, readRelay().counts, full, aspect); + const changed = section !== lastSection; + lastSection = section; + + // A mast coming online (scrolling forward, not a jump into the chapter) chirps once. + const count = live === null ? 0 : RELAY_STOPS.filter((stop, i) => i < masts.length && live >= stop).length; + if (full && !changed && count > onlineCount) { + tones += 1; + tone(); + } + onlineCount = count; + nudgeRun = clamp01(nudgeRun + (count > 0 ? 1 : -1) * (dt / RELAY_NUDGE.seconds)); + + // Hover: the focused technology's rung flashes, at most every 0.35 s. + let focusIndex = -1; + if (full && focus !== undefined && focus !== lastFocus) { + const [g, item] = focus.split(':').map(Number); + if (masts[g!] !== undefined && item! >= 0 && item! < masts[g!]!.rungs.length) { + focusIndex = masts.slice(0, g).reduce((sum, mast) => sum + mast.rungs.length, 0) + item!; + } + } + lastFocus = focus; + + const time = now / 1000; + let index = 0; + let any = false; + masts.forEach((mast, m) => { + const visible = mast.presence > 0; + any ||= visible; + scale.setScalar(visible ? mast.scale : 0); + rotation.identity(); + position.set(mast.x, 0, mast.z); + frames.setMatrixAt(m, matrix.compose(position, rotation, scale)); + frameState.setXYZ(m, mast.level, 0, mast.presence); + + // A soft 0.5 Hz blink while online (lite: steady). + const blink = full ? Math.pow(0.5 + 0.5 * Math.cos(2 * Math.PI * BEACON_HZ * time + m * 1.3), 3) : 0.6; + beaconLevels[m] = visible ? mast.level * (0.2 + 0.8 * blink) : 0; + position.set(mast.x, (MAST_HEIGHT + ANTENNA) * mast.scale, mast.z); + beaconMesh.setMatrixAt(m, matrix.compose(position, rotation, scale)); + beaconState.setXYZ(m, beaconLevels[m]!, 0, mast.presence); + + if (beamMesh !== null && beamState !== null) { + const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7); + rotation.setFromAxisAngle(forward, sweep); + const shown = visible && mast.level > 0 ? mast.scale : 0; + beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1))); + beamState.setXYZ(m, mast.level, 0, mast.presence); + rotation.identity(); + } + + for (const rung of mast.rungs) { + if (index >= MAX_RUNGS) break; + sinceFlash[index]! += dt; + if (index === focusIndex && visible && sinceFlash[index]! >= RUNG_FLASH_GAP_SECONDS) { + flash[index] = 1; + sinceFlash[index] = 0; + } else { + flash[index] = Math.max(0, flash[index]! - dt / FLASH_SECONDS); + } + lit[index] = rung.lit; + const s = visible ? mast.scale : 0; + const f = flash[index]!; + position.set(mast.x, rung.y, mast.z + rung.halfWidth + 0.35 * s); + scale.set(visible ? rung.halfWidth * 2.3 * (1 + FLASH_SWELL.width * f) : 0, 0.9 * s * (1 + FLASH_SWELL.height * f), 0.6 * s); + rungMesh.setMatrixAt(index, matrix.compose(position, rotation, scale)); + rungState.setXYZ(index, rung.lit, flash[index]!, mast.presence); + index += 1; + } + }); + total = index; + rungMesh.count = total; + frames.count = masts.length; + beaconMesh.count = masts.length; + if (beamMesh !== null) beamMesh.count = masts.length; + group.visible = any; + if (any) { + pulseUniform.value = full ? clamp01(pulse) : 0; + for (const mesh of [frames, rungMesh, beaconMesh, beamMesh]) { + if (mesh === null) continue; + mesh.instanceMatrix.needsUpdate = true; + (mesh.geometry.getAttribute('aState') as ThreeInstancedBufferAttribute).needsUpdate = true; + } + } + return nudgeRun * nudgeRun * (3 - 2 * nudgeRun); + }, + }; +} diff --git a/src/styles/relay.css b/src/styles/relay.css new file mode 100644 index 0000000..97c8727 --- /dev/null +++ b/src/styles/relay.css @@ -0,0 +1,25 @@ +/* RELAY STATION: toolkit groups come ONLINE in order, one per mast in the + scene. Also the whole effect without a scene (reduced motion, static). + html[data-relay-online] exists only after mount. */ +html[data-relay-online] #toolkit .toolkit-group { + transition: border-color 600ms ease, box-shadow 600ms ease; +} +html[data-relay-online='1'] #toolkit .toolkit-group:nth-child(-n + 1), +html[data-relay-online='2'] #toolkit .toolkit-group:nth-child(-n + 2), +html[data-relay-online='3'] #toolkit .toolkit-group:nth-child(-n + 3), +html[data-relay-online='4'] #toolkit .toolkit-group:nth-child(-n + 4) { + border-block-start-color: var(--chapter-accent, var(--accent)); + box-shadow: inset 0 2px 0 var(--chapter-accent, var(--accent)); +} +html[data-relay-online='1'] #toolkit .toolkit-group:nth-child(-n + 1) h3::after, +html[data-relay-online='2'] #toolkit .toolkit-group:nth-child(-n + 2) h3::after, +html[data-relay-online='3'] #toolkit .toolkit-group:nth-child(-n + 3) h3::after, +html[data-relay-online='4'] #toolkit .toolkit-group:nth-child(-n + 4) h3::after { + content: ' · ONLINE' / ''; + font: 500 0.62em/1 var(--mono); + letter-spacing: 0.12em; + color: var(--chapter-accent, var(--accent)); +} +@media (prefers-reduced-motion: reduce) { + html[data-relay-online] #toolkit .toolkit-group { transition: none; } +} diff --git a/tests/unit/AppShell.test.ts b/tests/unit/AppShell.test.ts index 63cdc31..79cb6e1 100644 --- a/tests/unit/AppShell.test.ts +++ b/tests/unit/AppShell.test.ts @@ -180,6 +180,16 @@ describe('DRIVE-IN on the server', () => { }); }); +describe('RELAY STATION on the server', () => { + it('ships no relay state: the toolkit groups render unchanged', async () => { + const html = await render('/', validInputs); + expect(html).not.toContain('data-relay'); + expect(html).not.toContain('ONLINE'); + const document = await renderedDocument(); + expect(document.querySelectorAll('#toolkit .toolkit-group').length).toBe(validInputs.content.toolkit.length); + }); +}); + describe('HIGH BEAM on the server', () => { it('ships no beam state: the name renders fully lit and its text is unchanged', async () => { const html = await render('/', validInputs); diff --git a/tests/unit/relay-masts.test.ts b/tests/unit/relay-masts.test.ts new file mode 100644 index 0000000..5846933 --- /dev/null +++ b/tests/unit/relay-masts.test.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SceneResourceBag } from '@/scene/resources'; +import { BEAM_INTENSITY, createRelayMasts, RUNG_FLASH_GAP_SECONDS, relayLayout } from '@/scene/world/relay-masts'; +import { publishRelay } from '@/journey/relay-progress'; +import { readSwitchboard } from '@/audio/music-pulse'; +import { playRelayTone } from '@/audio/relay-tone'; +import type { PortfolioContent, SectionId } from '@/content/types'; + +const content = JSON.parse( + readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8'), +) as PortfolioContent; +const counts = content.toolkit.map((group) => group.technologies.length); + +afterEach(() => publishRelay([])); + +const station = (full: boolean) => { + publishRelay(counts); + const tone = vi.fn(); + return { world: createRelayMasts(new SceneResourceBag(), full, tone), tone }; +}; + +describe('relay mast layout', () => { + it('stands four big masts on the far shoulders, alternating sides, first on the right', () => { + const masts = relayLayout(0.5, counts); + expect(masts).toHaveLength(4); + masts.forEach((mast, i) => { + expect(Math.abs(mast.x)).toBeGreaterThanOrEqual(50); + expect(mast.height).toBeGreaterThanOrEqual(40); + expect(Math.sign(mast.x)).toBe(i % 2 === 0 ? 1 : -1); + expect(mast.z).toBeLessThanOrEqual(-40); + expect(mast.z).toBeGreaterThanOrEqual(-130); + }); + expect(masts.map((mast) => mast.rungs.length)).toEqual(counts); + expect(BEAM_INTENSITY).toBeLessThanOrEqual(0.8); + }); + + it('keeps the pair on each side apart and inside the frame, on wide and narrow lenses', () => { + for (const aspect of [1.6, 1024 / 700]) { + const masts = relayLayout(0.5, counts, true, aspect); + const tanHalfWidth = Math.tan((62 / 2) * (Math.PI / 180)) * aspect; + // Screen x of a mast's centre as a fraction of the half-width, and its lamp half-span in the same units. + const frame = (mast: (typeof masts)[number]) => { + const distance = 25 - mast.z; + const unit = 1 / (tanHalfWidth * distance); + return { centre: Math.abs(mast.x) * unit, span: mast.rungs[0]!.halfWidth * 1.15 * unit }; + }; + const near = frame(masts[0]!); + const far = frame(masts[2]!); + expect(near.centre + near.span).toBeLessThanOrEqual(1); + expect(far.centre - far.span).toBeGreaterThanOrEqual(0.84); + expect(far.centre + far.span).toBeLessThan(near.centre - near.span); + } + }); + + it('is absent outside the chapter and fades at its edges', () => { + expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true); + expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true); + expect(relayLayout(1, counts).every((mast) => mast.presence === 0)).toBe(true); + expect(relayLayout(0.5, counts).every((mast) => mast.presence === 1)).toBe(true); + }); +}); + +describe('relay masts world', () => { + it('draws one rung instance per technology for the current content', () => { + const { world } = station(true); + world.update('toolkit', 0.5, 0, undefined, 0.016, 0); + expect(world.rungs()).toHaveLength(26); + expect(world.group.visible).toBe(true); + }); + + it('is hidden outside Toolkit', () => { + const { world } = station(true); + for (const section of ['start', 'selected-builds', 'contact'] as SectionId[]) { + world.update(section, 0.5, 0, undefined, 0.016, 0); + expect(world.group.visible).toBe(false); + } + world.update('toolkit', null, 0, undefined, 0.016, 0); + expect(world.group.visible).toBe(false); + }); + + it('lights rungs in order with scroll and unlights them in reverse', () => { + const { world } = station(true); + world.update('toolkit', 0.3, 0, undefined, 0.016, 0); + const lit = world.rungs().map((rung) => rung.lit); + expect(lit.slice(0, 11).every((v) => v === 1)).toBe(true); + expect(lit.slice(19).every((v) => v === 0)).toBe(true); + world.update('toolkit', 0.1, 0, undefined, 0.016, 16); + expect(world.rungs().slice(11).every((rung) => rung.lit === 0)).toBe(true); + }); + + it('chirps once per mast coming online, never on a jump into the chapter', () => { + const { world, tone } = station(true); + world.update('contact', null, 0, undefined, 0.016, 0); + world.update('toolkit', 0.5, 0, undefined, 0.016, 16); + expect(tone).not.toHaveBeenCalled(); + world.update('toolkit', 0.6, 0, undefined, 0.016, 32); + expect(tone).toHaveBeenCalledTimes(1); + world.update('toolkit', 0.3, 0, undefined, 0.016, 48); + expect(tone).toHaveBeenCalledTimes(1); + }); + + it('flashes a hovered rung at most every 0.35 s', () => { + const { world } = station(true); + world.update('toolkit', 0.5, 0, undefined, 0.016, 0); + world.update('toolkit', 0.5, 0, '0:2', 0.016, 16); + expect(world.rungs()[2]!.flash).toBe(1); + world.update('toolkit', 0.5, 0, '1:0', 0.1, 116); + expect(world.rungs()[11]!.flash).toBe(1); + world.update('toolkit', 0.5, 0, '0:2', 0.1, 216); + expect(world.rungs()[2]!.flash).toBeLessThan(1); + world.update('toolkit', 0.5, 0, '1:0', RUNG_FLASH_GAP_SECONDS, 566); + world.update('toolkit', 0.5, 0, '0:2', 0.016, 582); + expect(world.rungs()[2]!.flash).toBe(1); + }); + + it('lite: every rung lit from chapter entry, steady beacons, no hover flash and no tone', () => { + const { world, tone } = station(false); + world.update('toolkit', 0.02, 0, undefined, 0.016, 0); + expect(world.rungs().every((rung) => rung.lit === 1)).toBe(true); + const levels = new Set(); + for (let t = 0; t < 4000; t += 100) { + world.update('toolkit', 0.3 + t / 20000, 0, t % 200 === 0 ? '0:1' : '0:2', 0.1, t); + world.beacons().forEach((level) => levels.add(Math.round(level * 1000))); + } + expect(levels.size).toBe(1); + expect(world.rungs().every((rung) => rung.flash === 0)).toBe(true); + expect(tone).not.toHaveBeenCalled(); + }); + + it('full: beacons blink at 0.5 Hz (one cycle every 2 s)', () => { + const { world } = station(true); + const samples: number[] = []; + for (let t = 0; t <= 2000; t += 50) { + world.update('toolkit', 0.7, 0, undefined, 0.05, t); + samples.push(world.beacons()[0]!); + } + expect(samples[0]).toBeCloseTo(samples[40]!, 5); + expect(Math.max(...samples) - Math.min(...samples)).toBeGreaterThan(0.5); + }); + + it('nudges the lens while a mast is online and eases back', () => { + const { world } = station(true); + let nudge = 0; + for (let i = 0; i < 60; i += 1) nudge = world.update('toolkit', 0.5, 0, undefined, 0.016, i * 16); + expect(nudge).toBe(1); + for (let i = 0; i < 60; i += 1) nudge = world.update('contact', null, 0, undefined, 0.016, 1000 + i * 16); + expect(nudge).toBe(0); + }); +}); + +describe('relay tone', () => { + it('stays silent without an audible switchboard', () => { + expect(readSwitchboard()).toBeNull(); + expect(playRelayTone()).toBe(false); + }); +}); diff --git a/tests/unit/relay-progress.test.ts b/tests/unit/relay-progress.test.ts new file mode 100644 index 0000000..ba50f27 --- /dev/null +++ b/tests/unit/relay-progress.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, shallowRef } from 'vue'; +import { useRelay } from '@/composables/useRelay'; +import { BEACON_HZ, publishRelay, readRelay, RELAY_STOPS, relayOnline, rungLit } from '@/journey/relay-progress'; +import type { JourneySnapshot } from '@/journey/types'; +import type { PortfolioContent, SectionId } from '@/content/types'; + +const content = JSON.parse( + readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8'), +) as PortfolioContent; +const counts = content.toolkit.map((group) => group.technologies.length); + +const sweep = (from: number, to: number, steps = 400): number[] => + Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps); + +afterEach(() => publishRelay([])); + +describe('relay progress', () => { + it('has one stop per group and blinks well under 3 Hz', () => { + expect(RELAY_STOPS).toHaveLength(4); + expect(counts).toHaveLength(4); + expect(BEACON_HZ).toBeLessThanOrEqual(3); + }); + + it('brings masts online monotonically and takes them offline in reverse', () => { + const forward = sweep(0, 1).map(relayOnline); + forward.forEach((n, i) => { if (i > 0) expect(n).toBeGreaterThanOrEqual(forward[i - 1]!); }); + const backward = [...sweep(0, 1)].reverse().map(relayOnline); + expect(backward).toEqual([...forward].reverse()); + expect(relayOnline(null)).toBe(0); + expect(relayOnline(0)).toBe(0); + expect(relayOnline(0.6)).toBe(4); + }); + + it('lights every rung monotonically and reversibly, all lit by the end of the ramp', () => { + counts.forEach((count, mast) => { + for (let rung = 0; rung < count; rung += 1) { + const forward = sweep(0, 1).map((p) => rungLit(p, mast, rung, count)); + forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); }); + expect([...sweep(0, 1)].reverse().map((p) => rungLit(p, mast, rung, count))).toEqual([...forward].reverse()); + expect(rungLit(RELAY_STOPS[mast]! - 0.001, mast, rung, count)).toBe(0); + expect(rungLit(0.71, mast, rung, count)).toBe(1); + } + }); + expect(rungLit(null, 0, 0, 3)).toBe(0); + }); + + it('lights rungs bottom-up in index order', () => { + for (const p of sweep(0, 1)) { + counts.forEach((count, mast) => { + for (let rung = 1; rung < count; rung += 1) { + expect(rungLit(p, mast, rung, count)).toBeLessThanOrEqual(rungLit(p, mast, rung - 1, count)); + } + }); + } + }); + + it('carries the rung counts from the DOM to the scene', () => { + expect(readRelay().counts).toEqual([]); + publishRelay(counts); + expect(readRelay().counts).toEqual([11, 8, 4, 3]); + publishRelay([1, 2, 3, 4, 5]); + expect(readRelay().counts).toHaveLength(4); + }); +}); + +describe('useRelay', () => { + const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({ + scrollY: 0, + routeProgress: 0.8, + direction: 0, + acceleration: 0, + driveSpeed: 0, + activeSectionId, + chapter: 'systems', + sign: null, + arrived: false, + sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n }, + } as unknown as JourneySnapshot); + + it('sets html[data-relay-online] through Toolkit only, publishes counts, and cleans up', async () => { + const journey = shallowRef(snapshot('additional-work', -0.5, 1.5)); + const wrapper = mount(defineComponent({ + setup() { + useRelay(journey, () => content.toolkit); + return () => h('span'); + }, + })); + const root = document.documentElement; + await nextTick(); + expect(readRelay().counts).toEqual(counts); + expect(root.dataset.relayOnline).toBeUndefined(); + + journey.value = snapshot('toolkit', -0.1, 1.9); // p 0.05 + await nextTick(); + expect(root.dataset.relayOnline).toBe('1'); + journey.value = snapshot('toolkit', -1, 1); // p 0.5 + await nextTick(); + expect(root.dataset.relayOnline).toBe('3'); + journey.value = snapshot('toolkit', -1.3, 0.7); // p 0.65 + await nextTick(); + expect(root.dataset.relayOnline).toBe('4'); + journey.value = snapshot('toolkit', -0.5, 1.5); // p 0.25, scrolling back + await nextTick(); + expect(root.dataset.relayOnline).toBe('2'); + journey.value = snapshot('contact', -0.2, null); + await nextTick(); + expect(root.dataset.relayOnline).toBeUndefined(); + journey.value = snapshot('toolkit', -1, 1); + await nextTick(); + expect(root.dataset.relayOnline).toBe('3'); + wrapper.unmount(); + expect(root.dataset.relayOnline).toBeUndefined(); + expect(root.dataset.relayFocus).toBeUndefined(); + }); +});