feat: route log odometer and lit mile markers for profile and experience
@@ -31,3 +31,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
||||
- **Known weaknesses:** the "SHIFT OVER" label swap is invisible (HUD arrival label collapsed); the bottom credit is mostly hidden by the Contact panel (pre-existing); horizon still bright at full day; reduced-motion version reads as dusk rather than sunrise; audio checked by code review only.
|
||||
- Screenshots: docs/overnight/round3/.
|
||||
|
||||
## Round 4: Mile Markers (Profile + Experience)
|
||||
- **What:** the gantry HUD subline becomes a scroll-driven ROUTE LOG odometer ("LOG 02/04 · JUN 2025 → AUG 2025 · CCF", digit roll, " · REWIND" on reverse), and lit kilometre posts with accent plates and warm light pools come down the free shoulder, one per role/fact, flashing as their plate crosses the activation line.
|
||||
- **Why:** Profile and Experience were the only chapters with no world beat and left half the viewport empty. Career dates are real content, parsed from the JSON, nothing invented.
|
||||
- **Build:** src/journey/route-log.ts, useRouteLog.ts, scene/world/milestones.ts, styles/route-log.css; generalised maintenance-progress helpers. 766 tests green, initial JS +1.25 KB gz (115.9 of 120 - **budget nearly spent, keep DOM-side additions tiny**).
|
||||
- **Verdict: KEEP with fixes** (Fable): the delivered 0.18 x 2.6 m posts were 25 px specks at 80 m, so they were scaled to the ~2.3x world (0.4 x 6.4 m, 2.2 x 1.0 m plate), the pass point moved to z -12 to coincide with the tick, additive light pools added, and the phone subline overflow fixed.
|
||||
- **Known weaknesses:** the free side is narrow because of the camera frame shift, so the big pass is brief; plates read mostly white under tonemapping; no lit number glyphs on the plates.
|
||||
- Screenshots: docs/overnight/round4/.
|
||||
|
||||
|
||||
BIN
docs/overnight/round4/before-experience.webp
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/overnight/round4/before-profile.webp
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
docs/overnight/round4/experience-hover.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/overnight/round4/experience-p15.webp
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/overnight/round4/experience-p33.webp
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/overnight/round4/experience-p39.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/overnight/round4/mobile-390-rewind.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/overnight/round4/mobile-390.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/overnight/round4/profile-p20.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/overnight/round4/profile-p46.webp
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
docs/overnight/round4/profile-p52.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/overnight/round4/reduced-motion.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/overnight/round4/rewind.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/overnight/round4/sweep-experience-pass.webp
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/overnight/round4/sweep-profile-pass.webp
Normal file
|
After Width: | Height: | Size: 158 KiB |
@@ -17,6 +17,7 @@ import './styles/cards.css';
|
||||
import './styles/maintenance.css';
|
||||
import './styles/print.css';
|
||||
import './styles/headlight.css';
|
||||
import './styles/route-log.css';
|
||||
|
||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
|
||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||
import { useRouteMemory } from '@/composables/useRouteMemory';
|
||||
import { useRouteLog } from '@/composables/useRouteLog';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import { signRouteFor } from '@/journey/sign-routes';
|
||||
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
||||
@@ -86,6 +87,8 @@ const journeyBinding = useJourneySections(
|
||||
const journey = journeyBinding.snapshot;
|
||||
/* The 03:12 window (Infrastructure): the plate's sub-line and the diagram steps. */
|
||||
const maintenanceSubline = useMaintenanceWindow(journey);
|
||||
/* MILE MARKERS (Experience, Profile): the ROUTE LOG odometer on the same sub-line. */
|
||||
const routeLogSubline = useRouteLog(journey, () => props.content);
|
||||
useCardEntrances();
|
||||
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
||||
const resume = computed(() => {
|
||||
@@ -133,8 +136,9 @@ const controlsReady = shallowRef(false);
|
||||
/* END OF SHIFT: parked at the destination the plate's sub-line reads the
|
||||
shift's end. Client-only, like the 03:12 window, so the server HTML hydrates
|
||||
unchanged. */
|
||||
const hudSubline = computed(() => maintenanceSubline.value
|
||||
|| (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : ''));
|
||||
const hudSubline = computed(() => (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : '')
|
||||
|| maintenanceSubline.value
|
||||
|| routeLogSubline.value);
|
||||
const adaptive = useAdaptiveQuality({
|
||||
maximum: computed(() => motion.policy.value.maximumTier),
|
||||
sceneState,
|
||||
|
||||
@@ -22,6 +22,11 @@ const next = computed(() =>
|
||||
a route rule that fills along its lower edge. */
|
||||
const route = computed(() => Math.min(1, Math.max(0, props.journey.routeProgress)));
|
||||
const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
|
||||
/* MILE MARKERS: "LOG 02/04 · ..." rolls its index on 1-digit odometer
|
||||
columns, each keyed by its value so a change re-enters (route-log.css).
|
||||
Client-only: the server sub-line is empty, so its HTML is unchanged. */
|
||||
const log = computed(() => /^LOG (\d+)\/(\d+)(.*)$/.exec(props.subline ?? ''));
|
||||
const rewind = computed(() => (props.subline ?? '').endsWith('REWIND'));
|
||||
/* The plate is keyed by chapter, so a new chapter is a new element: under
|
||||
reduced motion it fades in over 160 ms (gantry.css) instead of swapping. */
|
||||
</script>
|
||||
@@ -43,6 +48,10 @@ const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
|
||||
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
||||
<!-- END OF SHIFT: html[data-dawn='day'] swaps the label for data-day (CSS). -->
|
||||
<strong v-if="journey.arrived" class="journey-hud__arrival" data-day="SHIFT OVER">ARRIVED</strong>
|
||||
<span class="journey-hud__subline">{{ subline }}</span>
|
||||
<template v-if="log">
|
||||
<span class="journey-hud__subline" :data-rewind="rewind">LOG <span class="route-odo"><span v-for="(digit, i) in log[1]" :key="`${i}:${digit}`" class="route-odo__digit">{{ digit }}</span></span>/{{ log[2] }}{{ log[3] }}</span>
|
||||
<span :key="log[1]" class="journey-hud__log-pulse"></span>
|
||||
</template>
|
||||
<span v-else class="journey-hud__subline">{{ subline }}</span>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
62
src/composables/useRouteLog.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
|
||||
import type { PortfolioContent } from '@/content/types';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import {
|
||||
createRewindLatch,
|
||||
isMilestoneSection,
|
||||
milestoneIndex,
|
||||
milestonesFor,
|
||||
routeLogSubline,
|
||||
sectionLocalProgress,
|
||||
} from '@/journey/route-log';
|
||||
|
||||
/**
|
||||
* MILE MARKERS, DOM side: the ROUTE LOG sub-line and html[data-route-log]
|
||||
* ("2/4") through Experience and Profile. Client-only and started after
|
||||
* mount, like the 03:12 window, so the server HTML (no attribute, an empty
|
||||
* sub-line) hydrates unchanged. Works without the scene.
|
||||
*/
|
||||
export function useRouteLog(
|
||||
journey: Readonly<Ref<JourneySnapshot>>,
|
||||
content: () => Pick<PortfolioContent, 'experience' | 'systemsOperations' | 'profile'>,
|
||||
): Readonly<Ref<string>> {
|
||||
const mounted = shallowRef(false);
|
||||
const wide = shallowRef(true);
|
||||
const reversing = shallowRef(false);
|
||||
const latch = createRewindLatch();
|
||||
const section = computed(() => {
|
||||
const id = journey.value.activeSectionId;
|
||||
return mounted.value && isMilestoneSection(id) ? id : null;
|
||||
});
|
||||
const items = computed(() => (section.value === null ? [] : milestonesFor(content(), section.value)));
|
||||
const progress = computed(() =>
|
||||
section.value === null || items.value.length < 2 ? null : sectionLocalProgress(journey.value, section.value));
|
||||
const log = computed(() =>
|
||||
progress.value === null ? null : `${milestoneIndex(progress.value, items.value.length) + 1}/${items.value.length}`);
|
||||
|
||||
watch(progress, (p) => { reversing.value = latch(p, section.value ?? undefined); }, { flush: 'sync' });
|
||||
watch(log, (value) => {
|
||||
const root = document.documentElement;
|
||||
if (value === null) delete root.dataset.routeLog;
|
||||
else root.dataset.routeLog = value;
|
||||
});
|
||||
|
||||
let query: MediaQueryList | null = null;
|
||||
const onWidth = (): void => { wide.value = query?.matches ?? true; };
|
||||
onMounted(() => {
|
||||
query = typeof matchMedia === 'function' ? matchMedia('(min-width: 1280px)') : null;
|
||||
onWidth();
|
||||
query?.addEventListener?.('change', onWidth);
|
||||
mounted.value = true;
|
||||
});
|
||||
onUnmounted(() => {
|
||||
mounted.value = false;
|
||||
query?.removeEventListener?.('change', onWidth);
|
||||
delete document.documentElement.dataset.routeLog;
|
||||
});
|
||||
|
||||
return computed(() => {
|
||||
const p = progress.value;
|
||||
return p === null ? '' : routeLogSubline(p, items.value, reversing.value, wide.value ? 'wide' : 'compact') ?? '';
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JourneySnapshot } from './types';
|
||||
import { createRewindLatch, sectionLocalProgress } from './route-log';
|
||||
|
||||
/**
|
||||
* The 03:12 window: the Infrastructure chapter's set-piece is driven by how
|
||||
@@ -27,12 +28,7 @@ type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivatio
|
||||
* line, 1 when the next chapter's heading reaches it. Null outside the chapter.
|
||||
*/
|
||||
export function maintenanceProgress(snapshot: ProgressInput): number | null {
|
||||
if (snapshot.activeSectionId !== 'infrastructure') return null;
|
||||
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
|
||||
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
|
||||
const span = next - active;
|
||||
if (!(span > 0)) return null;
|
||||
return Math.min(1, Math.max(0, -active / span));
|
||||
return sectionLocalProgress(snapshot, 'infrastructure');
|
||||
}
|
||||
|
||||
export function maintenanceStep(p: number): MaintenanceStep {
|
||||
@@ -69,20 +65,4 @@ export function maintenanceSubline(p: number, reversing: boolean, live: () => st
|
||||
* Latches the scroll direction inside the window: progress falling means a
|
||||
* rollback until it rises again. Small jitter is ignored.
|
||||
*/
|
||||
export function createMaintenanceDirection(): (p: number | null) => boolean {
|
||||
let last: number | null = null;
|
||||
let reversing = false;
|
||||
return (p) => {
|
||||
if (p === null) {
|
||||
last = null;
|
||||
reversing = false;
|
||||
return false;
|
||||
}
|
||||
if (last !== null) {
|
||||
if (p < last - 0.001) reversing = true;
|
||||
else if (p > last + 0.001) reversing = false;
|
||||
}
|
||||
last = p;
|
||||
return reversing && p > 0 && p < 1;
|
||||
};
|
||||
}
|
||||
export const createMaintenanceDirection: () => (p: number | null) => boolean = createRewindLatch;
|
||||
|
||||
145
src/journey/route-log.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { PortfolioContent, SectionId } from '@/content/types';
|
||||
import type { JourneySnapshot } from './types';
|
||||
|
||||
/**
|
||||
* MILE MARKERS: Experience and Profile become literal route. Each role or
|
||||
* fact is a kilometre post that rolls past as its plate crosses the
|
||||
* activation line, and the gantry sub-line keeps a ROUTE LOG odometer.
|
||||
* Shared by the DOM (sub-line, html[data-route-log]) and the lazy scene
|
||||
* chunk (post positions), so it stays tiny and pure. Scroll-driven only.
|
||||
*/
|
||||
type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>;
|
||||
|
||||
/**
|
||||
* Section-local progress: 0 with the chapter heading on the activation line,
|
||||
* 1 when the next chapter's heading reaches it. Null outside the chapter.
|
||||
*/
|
||||
export function sectionLocalProgress(snapshot: ProgressInput, id: SectionId): number | null {
|
||||
if (snapshot.activeSectionId !== id) return null;
|
||||
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
|
||||
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
|
||||
const span = next - active;
|
||||
if (!(span > 0)) return null;
|
||||
return Math.min(1, Math.max(0, -active / span));
|
||||
}
|
||||
|
||||
/** Posts per chapter, in reading order (Experience: 3 roles + the systems plate). */
|
||||
export const MILESTONE_COUNTS = { experience: 4, profile: 3 } as const;
|
||||
export type MilestoneSection = keyof typeof MILESTONE_COUNTS;
|
||||
|
||||
export const isMilestoneSection = (id: SectionId): id is MilestoneSection => id in MILESTONE_COUNTS;
|
||||
|
||||
export interface Milestone {
|
||||
index: number;
|
||||
/** Wide plate, e.g. "JUN 2025 → AUG 2025"; '' when the content has no date. */
|
||||
dateLabel: string;
|
||||
/** Narrow plate, e.g. "JUN→AUG 2025". */
|
||||
compactDate: string;
|
||||
/** Organisation or fact, upper case, e.g. "ISENSE SOLUTIONS". */
|
||||
caption: string;
|
||||
}
|
||||
|
||||
/** Post i passes the line at the middle of its share of the chapter. */
|
||||
export const passPoint = (index: number, count: number): number => (index + 0.5) / count;
|
||||
|
||||
const MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' ');
|
||||
const month = (word: string | undefined): string =>
|
||||
word !== undefined && MONTHS.includes(word.slice(0, 3).toLowerCase()) ? `${word.slice(0, 3).toUpperCase()} ` : '';
|
||||
const SPAN = /(?:([A-Za-z]+)\s+)?(\d{4})\s*[-–]\s*(?:(?:([A-Za-z]+)\s+)?(\d{4})|(Present))/;
|
||||
|
||||
/** "June 2025-August 2025" -> JUN 2025 → AUG 2025; "2023-2026"; lists of years. */
|
||||
export function parseDates(text: string): { dateLabel: string; compactDate: string } {
|
||||
const span = SPAN.exec(text);
|
||||
if (span !== null) {
|
||||
const [, m1, y1, m2, y2, present] = span;
|
||||
const end = present === undefined ? `${month(m2)}${y2}` : 'PRESENT';
|
||||
const sameYear = y1 === y2 && month(m1) !== '' && month(m2) !== '';
|
||||
return {
|
||||
dateLabel: `${month(m1)}${y1} → ${end}`,
|
||||
compactDate: sameYear ? `${month(m1).trim()}→${month(m2)}${y2}` : `${y1}→${present === undefined ? y2 : 'PRESENT'}`,
|
||||
};
|
||||
}
|
||||
const years = [...new Set(text.match(/\b(?:19|20)\d{2}\b/g) ?? [])].sort().join(' · ');
|
||||
return { dateLabel: years, compactDate: years };
|
||||
}
|
||||
|
||||
type MilestoneContent = Pick<PortfolioContent, 'experience' | 'systemsOperations' | 'profile'>;
|
||||
|
||||
export function milestonesFor(content: MilestoneContent, section: SectionId): Milestone[] {
|
||||
const rows: Array<{ dates: string; caption: string }> = section === 'experience'
|
||||
? [
|
||||
...content.experience.map((record) => ({ dates: record.dates, caption: record.organization })),
|
||||
{ dates: '', caption: content.systemsOperations.title.replace(/^professional\s+/i, '') },
|
||||
]
|
||||
: section === 'profile'
|
||||
? content.profile.screeningFacts.map((fact) => ({ dates: fact.value, caption: fact.label }))
|
||||
: [];
|
||||
return rows.map((row, index) => ({ index, ...parseDates(row.dates), caption: row.caption.toUpperCase() }));
|
||||
}
|
||||
|
||||
/** Which post the reader is at: the one whose share of the chapter holds p. */
|
||||
export const milestoneIndex = (p: number, count: number): number =>
|
||||
Math.min(count - 1, Math.max(0, Math.floor(p * count)));
|
||||
|
||||
/** Sub-line widths in characters: the wide (>=1280 px) and narrow plates. */
|
||||
export const ROUTE_LOG_BUDGET = { wide: 50, compact: 32 } as const;
|
||||
|
||||
/** Full name, then (3+ words) initials, then the first word, then initials, then nothing. */
|
||||
export function fitCaption(caption: string, room: number): string {
|
||||
const words = caption.split(/\s+/).filter(Boolean);
|
||||
const initials = words.length > 1 ? words.map((word) => word[0]).join('') : '';
|
||||
const tries = words.length > 2 ? [caption, initials, words[0] ?? ''] : [caption, words[0] ?? '', initials];
|
||||
return tries.find((value) => value !== '' && value.length <= room) ?? '';
|
||||
}
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* "LOG 02/04 · JUN 2025 → AUG 2025 · ISENSE SOLUTIONS", with " · REWIND" while
|
||||
* scrolling back. Null for chapters with fewer than two posts.
|
||||
*/
|
||||
export function routeLogSubline(
|
||||
p: number,
|
||||
items: readonly Milestone[],
|
||||
reversing = false,
|
||||
width: keyof typeof ROUTE_LOG_BUDGET = 'wide',
|
||||
): string | null {
|
||||
if (items.length < 2) return null;
|
||||
const item = items[milestoneIndex(p, items.length)]!;
|
||||
const date = width === 'wide' ? item.dateLabel : item.compactDate;
|
||||
const index = `LOG ${pad(item.index + 1)}/${pad(items.length)}`;
|
||||
const tail = reversing ? ' · REWIND' : '';
|
||||
// The date goes before the caption, unless (narrow plate) it would leave the
|
||||
// caption with less than its initials: then the caption alone is the better log line.
|
||||
let head = date === '' ? index : `${index} · ${date}`;
|
||||
let caption = fitCaption(item.caption, ROUTE_LOG_BUDGET[width] - head.length - tail.length - 3);
|
||||
if (date !== '' && caption.length < 3) {
|
||||
head = index;
|
||||
caption = fitCaption(item.caption, ROUTE_LOG_BUDGET[width] - head.length - tail.length - 3);
|
||||
}
|
||||
return `${head}${caption === '' ? '' : ` · ${caption}`}${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latches the scroll direction: progress falling means a rewind until it
|
||||
* rises again. Small jitter is ignored; a new key (chapter) starts fresh.
|
||||
*/
|
||||
export function createRewindLatch(): (p: number | null, key?: string) => boolean {
|
||||
let last: number | null = null;
|
||||
let lastKey: string | undefined;
|
||||
let reversing = false;
|
||||
return (p, key) => {
|
||||
if (p === null || key !== lastKey) {
|
||||
last = null;
|
||||
reversing = false;
|
||||
lastKey = key;
|
||||
if (p === null) return false;
|
||||
}
|
||||
if (last !== null) {
|
||||
if (p < last - 0.001) reversing = true;
|
||||
else if (p > last + 0.001) reversing = false;
|
||||
}
|
||||
last = p;
|
||||
return reversing && p > 0 && p < 1;
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
||||
import { createHeadlights } from '@/scene/world/headlights';
|
||||
import { maintenanceProgress } from '@/journey/maintenance-progress';
|
||||
import { createMilestones } from '@/scene/world/milestones';
|
||||
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
|
||||
import { dawnStep, type DawnStep } from '@/journey/dawn';
|
||||
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
@@ -169,8 +171,13 @@ export async function createProceduralSceneGeneration(
|
||||
// the star drain (uniform writes); the warm rims and the bloom change are
|
||||
// high/medium fine-pointer only.
|
||||
const dawnRims = maintenanceTier === 'full';
|
||||
// MILE MARKERS: kilometre posts roll past through Experience and Profile.
|
||||
// Low tier and touch draw them without beam, pulse or flash.
|
||||
const milestones = createMilestones(context.resources, maintenanceTier === 'full');
|
||||
scene.add(
|
||||
maintenance.group,
|
||||
milestones.mesh,
|
||||
milestones.pool,
|
||||
atmosphere.group,
|
||||
road.group,
|
||||
expressway.group,
|
||||
@@ -351,6 +358,14 @@ export async function createProceduralSceneGeneration(
|
||||
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
||||
}
|
||||
districts.setServiceState(service.dim, service.relight);
|
||||
milestones.update(
|
||||
snapshot.activeSectionId,
|
||||
isMilestoneSection(snapshot.activeSectionId)
|
||||
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
|
||||
: null,
|
||||
musicPulse,
|
||||
deltaSeconds,
|
||||
);
|
||||
// END OF SHIFT: parked at the destination, the night ends. One scalar
|
||||
// drives it all; leaving rewinds it as fast as the outro returns.
|
||||
const dawn = easeInOutCubic(outroProgress);
|
||||
|
||||
317
src/scene/world/milestones.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||
import { isMilestoneSection, MILESTONE_COUNTS, passPoint, type MilestoneSection } from '@/journey/route-log';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { BEAM_PARS, beamUniforms } from './headlights';
|
||||
|
||||
/**
|
||||
* MILE MARKERS, scene side. Each role (Experience) or screening fact
|
||||
* (Profile) is a lit kilometre post on the shoulder of the side the reading
|
||||
* panel leaves free. Its position is a pure function of section-local scroll
|
||||
* progress, so scrolling back rolls the posts back. One InstancedMesh, one
|
||||
* draw call: a dark post, a pale retro-reflective cap plate that catches the
|
||||
* high beam, and a chapter-accent emissive stripe that breathes +8% with the
|
||||
* bass. A post flashes for 300 ms as its plate crosses the activation line,
|
||||
* which is the moment it reaches the camera: the post is at its largest, at
|
||||
* the bottom edge of the frame, and the odometer ticks as it goes by. A second
|
||||
* additive mesh lays a warm light pool on the shoulder under each post.
|
||||
* Low tier and touch draw the posts without beam, pulse or flash.
|
||||
*/
|
||||
/**
|
||||
* Where a post stands as its plate crosses the line. The camera sits at z ~25
|
||||
* with the frame shifted toward the panel, so the free shoulder leaves the
|
||||
* frame's side edge about 35 m ahead of the camera: the post goes by there,
|
||||
* at its largest, as the odometer ticks.
|
||||
*/
|
||||
export const MILESTONE_PASS_Z = -12;
|
||||
/** Metres of road per unit of travel: the next post is RUN / count metres up the road. */
|
||||
export const MILESTONE_RUN = 200;
|
||||
export const MILESTONE_FAR_Z = MILESTONE_PASS_Z - MILESTONE_RUN / 2;
|
||||
export const MILESTONE_NEAR_Z = MILESTONE_PASS_Z + MILESTONE_RUN / 2;
|
||||
/** The free shoulder: the Experience panel is on the left, Profile's on the right. */
|
||||
export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 };
|
||||
/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */
|
||||
const POST_W = 0.4;
|
||||
const POST_H = 6.4;
|
||||
const PLATE_W = 2.2;
|
||||
const PLATE_H = 1.0;
|
||||
/** The pool of light each post throws on the shoulder (x across the road, z along it). */
|
||||
const POOL_W = 8;
|
||||
const POOL_L = 14;
|
||||
/** Plates turn this far toward the road (rad). */
|
||||
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;
|
||||
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;
|
||||
const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[];
|
||||
const INSTANCES = SECTIONS.reduce((sum, id) => sum + MILESTONE_COUNTS[id], 0);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
/** Travel parameter of post i: 0.5 exactly when its plate is on the line. */
|
||||
export const milestoneTravel = (p: number, index: number, count: number): number =>
|
||||
p - passPoint(index, count) + 0.5;
|
||||
|
||||
export const milestoneZ = (p: number, index: number, count: number): number =>
|
||||
THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, milestoneTravel(p, index, count));
|
||||
|
||||
/** The chapter fades the posts in and out at its edges. */
|
||||
export const milestoneChapterFade = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
|
||||
|
||||
/** A post rises out of the haze far up the road and leaves once it is past the camera. */
|
||||
export const milestoneRangeFade = (z: number): number => smooth(-190, -130, z) * (1 - smooth(10, 22, z));
|
||||
|
||||
export interface MilestonePost {
|
||||
section: MilestoneSection;
|
||||
index: number;
|
||||
x: number;
|
||||
z: number;
|
||||
/** 0..1 screen-door cover: chapter edges and the far end of the run. */
|
||||
presence: number;
|
||||
}
|
||||
|
||||
/** Every post's place for this frame; posts of other chapters are absent (presence 0). */
|
||||
export function milestoneLayout(section: SectionId, p: number | null): MilestonePost[] {
|
||||
const posts: MilestonePost[] = [];
|
||||
for (const id of SECTIONS) {
|
||||
const count = MILESTONE_COUNTS[id];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const t = p === null ? 0 : milestoneTravel(p, index, count);
|
||||
const live = p !== null && id === section;
|
||||
const z = THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, t);
|
||||
posts.push({
|
||||
section: id,
|
||||
index,
|
||||
x: MILESTONE_SIDE[id],
|
||||
z,
|
||||
presence: live ? milestoneChapterFade(p) * milestoneRangeFade(z) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number) {
|
||||
const geometry = new THREE.BoxGeometry(width, height, depth);
|
||||
geometry.translate(x, y, z);
|
||||
return { geometry, part };
|
||||
}
|
||||
|
||||
/** Post, plate and stripes merged into one indexed geometry with a part id. */
|
||||
function postGeometry() {
|
||||
const pieces = [
|
||||
box(POST_W, POST_H, POST_W, 0, POST_H / 2, 0, 0),
|
||||
// The number plate, framed top and bottom by the chapter's accent.
|
||||
box(PLATE_W, PLATE_H, 0.08, 0, POST_H + PLATE_H / 2 - 0.12, 0.18, 1),
|
||||
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H + PLATE_H - 0.06, 0.18, 2),
|
||||
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H - 0.18, 0.18, 2),
|
||||
// Two reflector bands down the post, like a real kilometre post.
|
||||
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.62, 0, 2),
|
||||
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.3, 0, 2),
|
||||
];
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const parts: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const { geometry, part } of pieces) {
|
||||
const offset = positions.length / 3;
|
||||
const position = geometry.getAttribute('position');
|
||||
positions.push(...position.array);
|
||||
normals.push(...geometry.getAttribute('normal').array);
|
||||
for (let i = 0; i < position.count; i += 1) parts.push(part);
|
||||
indices.push(...Array.from(geometry.getIndex().array as ArrayLike<number>, (i) => i + offset));
|
||||
geometry.dispose();
|
||||
}
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
|
||||
merged.setIndex(indices);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export interface MilestonesWorld {
|
||||
readonly mesh: { visible: boolean; count: number };
|
||||
/** The additive light pools on the shoulder, one per post. */
|
||||
readonly pool: { visible: boolean; count: number };
|
||||
/** Section-local progress (null outside the chapter) and the smoothed bass pulse (0..1). */
|
||||
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number): void;
|
||||
/** Test hook: the current flash level of each instance. */
|
||||
flashes(): readonly number[];
|
||||
}
|
||||
|
||||
export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld {
|
||||
const geometry = resources.own('geometry', postGeometry());
|
||||
// x: flash 0..1, y: presence 0..1.
|
||||
const state = new THREE.InstancedBufferAttribute(new Float32Array(INSTANCES * 2), 2);
|
||||
state.setUsage(THREE.DynamicDrawUsage);
|
||||
geometry.setAttribute('aState', state);
|
||||
const accent = new THREE.Color(SIGN_ACCENTS.experience);
|
||||
const pulseUniform = { value: 0 };
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform, ...(full ? beamUniforms : {}) },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute float aPart;
|
||||
attribute vec2 aState;
|
||||
varying float vPart;
|
||||
varying vec2 vState;
|
||||
varying vec3 vWorld;
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
vWorld = world.xyz;
|
||||
vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal);
|
||||
vPart = aPart;
|
||||
vState = aState;
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 uAccent;
|
||||
uniform float uPulse;
|
||||
varying float vPart;
|
||||
varying vec2 vState;
|
||||
varying vec3 vWorld;
|
||||
varying vec3 vNormal;
|
||||
${full ? BEAM_PARS : ''}
|
||||
void main() {
|
||||
// Screen-door fade, like the districts: never a translucent sheet.
|
||||
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.y < threshold) discard;
|
||||
vec3 n = normalize(vNormal);
|
||||
vec3 toCamera = normalize(cameraPosition - vWorld);
|
||||
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
|
||||
float facing = max(0.0, dot(n, toCamera));
|
||||
float flash = ${full ? 'vState.x' : '0.0'};
|
||||
vec3 color;
|
||||
if (vPart < 0.5) {
|
||||
color = vec3(0.04, 0.034, 0.06) + vec3(0.09, 0.08, 0.13) * key + uAccent * 0.06 * facing;
|
||||
} 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;
|
||||
} else {
|
||||
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
|
||||
}
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
fog: false,
|
||||
}));
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, INSTANCES);
|
||||
mesh.name = 'mile-markers';
|
||||
mesh.frustumCulled = false;
|
||||
mesh.visible = false;
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
|
||||
// The light pool: an additive quad on the shoulder, pulled toward the road.
|
||||
const poolGeometry = resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2));
|
||||
poolGeometry.setAttribute('aState', state);
|
||||
const poolMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec2 aState;
|
||||
varying vec2 vUv;
|
||||
varying vec2 vState;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vState = aState;
|
||||
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 uAccent;
|
||||
uniform float uPulse;
|
||||
varying vec2 vUv;
|
||||
varying vec2 vState;
|
||||
void main() {
|
||||
vec2 d = (vUv - 0.5) * 2.0;
|
||||
float r = length(d);
|
||||
float pool = pow(max(0.0, 1.0 - r), 1.8);
|
||||
vec3 warm = vec3(1.0, 0.86, 0.62);
|
||||
vec3 color = mix(warm, uAccent, 0.5) * pool * (2.4 + ${PULSE_GAIN.toFixed(2)} * 3.0 * uPulse + 3.0 * vState.x);
|
||||
gl_FragColor = vec4(color * vState.y, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
}));
|
||||
const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, INSTANCES);
|
||||
pool.name = 'mile-marker-pools';
|
||||
pool.frustumCulled = false;
|
||||
pool.visible = false;
|
||||
pool.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
const poolMatrix = new THREE.Matrix4();
|
||||
const poolPosition = new THREE.Vector3();
|
||||
const noRotation = new THREE.Quaternion();
|
||||
|
||||
const flash = new Float32Array(INSTANCES);
|
||||
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
|
||||
const side = new Float32Array(INSTANCES);
|
||||
let lastSection: SectionId | null = null;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const rotation = new THREE.Quaternion();
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
return {
|
||||
mesh,
|
||||
pool,
|
||||
flashes: () => Array.from(flash),
|
||||
update(section, p, pulse, deltaSeconds) {
|
||||
const posts = milestoneLayout(section, p);
|
||||
const changed = section !== lastSection;
|
||||
lastSection = section;
|
||||
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) {
|
||||
flash[i] = 1;
|
||||
sinceFlash[i] = 0;
|
||||
} else {
|
||||
flash[i] = Math.max(0, flash[i]! - deltaSeconds / FLASH_SECONDS);
|
||||
}
|
||||
if (crossing !== 0) side[i] = crossing;
|
||||
if (post.presence === 0) side[i] = 0;
|
||||
const visible = post.presence > 0;
|
||||
any ||= visible;
|
||||
position.set(post.x, 0, post.z);
|
||||
rotation.setFromAxisAngle(up, -Math.sign(post.x) * PLATE_YAW);
|
||||
scale.setScalar(visible ? 1 : 0);
|
||||
mesh.setMatrixAt(i, matrix.compose(position, rotation, scale));
|
||||
poolPosition.set(post.x - Math.sign(post.x) * 1.4, 0.05, post.z);
|
||||
pool.setMatrixAt(i, poolMatrix.compose(poolPosition, noRotation, scale));
|
||||
state.setXY(i, flash[i]!, post.presence);
|
||||
});
|
||||
mesh.visible = any;
|
||||
pool.visible = any;
|
||||
if (!any) return;
|
||||
if (isMilestoneSection(section)) accent.set(SIGN_ACCENTS[section]);
|
||||
pulseUniform.value = full ? clamp01(pulse) : 0;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
pool.instanceMatrix.needsUpdate = true;
|
||||
state.needsUpdate = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
27
src/styles/route-log.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* ---------- MILE MARKERS: the ROUTE LOG odometer ----------
|
||||
html[data-route-log='2/4'] is set client-side through Experience and
|
||||
Profile. The log index rolls on 1-digit columns (each keyed by its value,
|
||||
so a change re-enters) and the route rule thickens 1 px for 200 ms. */
|
||||
html[data-route-log] .journey-hud__subline { letter-spacing: 0.06em; }
|
||||
.route-odo { display: inline-flex; overflow: hidden; vertical-align: bottom; }
|
||||
.route-odo__digit { display: inline-block; }
|
||||
.journey-hud__log-pulse {
|
||||
position: absolute; inset: auto auto 0 0; block-size: 3px;
|
||||
inline-size: calc(var(--route, 0) * 100%);
|
||||
background: var(--sign-accent); opacity: 0; pointer-events: none;
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.journey-hud__log-pulse { inset: auto auto 12px 77px; inline-size: calc(var(--route, 0) * (100% - 107px)); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
html[data-route-log] .journey-hud__subline { letter-spacing: 0.01em; }
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.route-odo__digit { animation: route-odo-roll 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; }
|
||||
[data-rewind='true'] .route-odo__digit { animation-name: route-odo-rewind; }
|
||||
.journey-hud__log-pulse { animation: route-log-rule 200ms ease-out both; }
|
||||
}
|
||||
@keyframes route-odo-roll { from { transform: translateY(100%); } }
|
||||
@keyframes route-odo-rewind { from { transform: translateY(-100%); } }
|
||||
@keyframes route-log-rule { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@media (forced-colors: active), print { .journey-hud__log-pulse { display: none; } }
|
||||
@@ -163,6 +163,16 @@ describe('the 03:12 window on the server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MILE MARKERS on the server', () => {
|
||||
it('ships no route log: no attribute, no odometer and an empty sub-line', async () => {
|
||||
const html = await render('/', validInputs);
|
||||
expect(html).not.toContain('data-route-log');
|
||||
expect(html).not.toContain('route-odo');
|
||||
expect(html).not.toContain('journey-hud__log-pulse');
|
||||
expect(html).not.toMatch(/LOG \d\d\//u);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -448,4 +448,32 @@ describe("JourneyHud", () => {
|
||||
expect(window.get(".journey-hud__subline").text()).toBe("MAINTENANCE WINDOW \u00b7 03:12");
|
||||
window.unmount();
|
||||
});
|
||||
|
||||
it("rolls the ROUTE LOG index on odometer digits keyed by value", async () => {
|
||||
const hud = mount(JourneyHud, {
|
||||
props: {
|
||||
journey: journey({ activeSectionId: "experience" }),
|
||||
sectionLabel: "Experience",
|
||||
subline: "LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218",
|
||||
},
|
||||
});
|
||||
const subline = hud.get(".journey-hud__subline");
|
||||
expect(subline.text()).toBe("LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218");
|
||||
expect(hud.findAll(".route-odo__digit").map((digit) => digit.text())).toEqual(["0", "1"]);
|
||||
const tens = hud.findAll(".route-odo__digit")[0]!.element;
|
||||
const ones = hud.findAll(".route-odo__digit")[1]!.element;
|
||||
const pulse = hud.get(".journey-hud__log-pulse").element;
|
||||
await hud.setProps({ subline: "LOG 02/04 \u00b7 JUN 2025 \u2192 AUG 2025 \u00b7 CCF \u00b7 REWIND" });
|
||||
const digits = hud.findAll(".route-odo__digit");
|
||||
expect(digits.map((digit) => digit.text())).toEqual(["0", "2"]);
|
||||
expect(digits[0]!.element).toBe(tens);
|
||||
expect(digits[1]!.element).not.toBe(ones);
|
||||
expect(hud.get(".journey-hud__log-pulse").element).not.toBe(pulse);
|
||||
expect(hud.get(".journey-hud__subline").attributes("data-rewind")).toBe("true");
|
||||
await hud.setProps({ subline: "" });
|
||||
expect(hud.findAll(".route-odo").length).toBe(0);
|
||||
expect(hud.find(".journey-hud__log-pulse").exists()).toBe(false);
|
||||
expect(hud.get(".journey-hud__subline").text()).toBe("");
|
||||
hud.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
113
tests/unit/milestones.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import {
|
||||
createMilestones,
|
||||
MILESTONE_FAR_Z,
|
||||
MILESTONE_NEAR_Z,
|
||||
MILESTONE_PASS_Z,
|
||||
MILESTONE_SIDE,
|
||||
milestoneLayout,
|
||||
milestoneZ,
|
||||
} from '@/scene/world/milestones';
|
||||
import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log';
|
||||
|
||||
const sweep = (from: number, to: number, steps = 200): number[] =>
|
||||
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
|
||||
|
||||
describe('mile marker layout', () => {
|
||||
it('rolls every post toward the camera monotonically with progress, in order', () => {
|
||||
for (const section of ['experience', 'profile'] as const) {
|
||||
const count = MILESTONE_COUNTS[section];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
for (const p of sweep(0, 1)) {
|
||||
const z = milestoneZ(p, i, count);
|
||||
expect(z).toBeGreaterThan(previous);
|
||||
previous = z;
|
||||
}
|
||||
// At the camera's near edge exactly as its plate crosses the line.
|
||||
expect(milestoneZ(passPoint(i, count), i, count)).toBeCloseTo(MILESTONE_PASS_Z);
|
||||
expect(MILESTONE_PASS_Z).toBeCloseTo((MILESTONE_FAR_Z + MILESTONE_NEAR_Z) / 2);
|
||||
// Still on the near side of the camera (z ~25) and large in frame.
|
||||
expect(MILESTONE_PASS_Z).toBeLessThan(20);
|
||||
// Earlier posts are always nearer.
|
||||
if (i > 0) expect(milestoneZ(0.5, i - 1, count)).toBeGreaterThan(milestoneZ(0.5, i, count));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stands on the free shoulder: right of the road in Experience, left in Profile', () => {
|
||||
expect(MILESTONE_SIDE.experience).toBeGreaterThan(8);
|
||||
expect(MILESTONE_SIDE.profile).toBeLessThan(-8);
|
||||
const posts = milestoneLayout('experience', 0.5).filter((post) => post.presence > 0);
|
||||
expect(posts.length).toBeGreaterThan(0);
|
||||
expect(posts.every((post) => post.section === 'experience' && post.x === MILESTONE_SIDE.experience)).toBe(true);
|
||||
});
|
||||
|
||||
it('is hidden outside its chapters and fades at the chapter edges', () => {
|
||||
for (const section of ['start', 'infrastructure', 'toolkit', 'contact'] as const) {
|
||||
expect(milestoneLayout(section, 0.5).every((post) => post.presence === 0)).toBe(true);
|
||||
}
|
||||
expect(milestoneLayout('experience', null).every((post) => post.presence === 0)).toBe(true);
|
||||
expect(milestoneLayout('experience', 0).every((post) => post.presence === 0)).toBe(true);
|
||||
expect(milestoneLayout('profile', 1).every((post) => post.presence === 0)).toBe(true);
|
||||
const inside = milestoneLayout('profile', 0.5);
|
||||
expect(inside.filter((post) => post.section === 'experience').every((post) => post.presence === 0)).toBe(true);
|
||||
expect(inside.some((post) => post.section === 'profile' && post.presence === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('is symmetric in reverse: the same progress gives the same frame either way', () => {
|
||||
const ps = sweep(0, 1);
|
||||
const forward = ps.map((p) => milestoneLayout('experience', p));
|
||||
const backward = [...ps].reverse().map((p) => milestoneLayout('experience', p)).reverse();
|
||||
expect(backward).toEqual(forward);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mile marker mesh', () => {
|
||||
it('draws seven instances in one mesh, visible only in Experience and Profile', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const markers = createMilestones(resources, true);
|
||||
expect(markers.mesh.count).toBe(7);
|
||||
expect(markers.pool.count).toBe(7);
|
||||
markers.update('start', null, 0, 1 / 60);
|
||||
expect(markers.mesh.visible).toBe(false);
|
||||
expect(markers.pool.visible).toBe(false);
|
||||
markers.update('experience', 0.4, 0.5, 1 / 60);
|
||||
expect(markers.mesh.visible).toBe(true);
|
||||
expect(markers.pool.visible).toBe(true);
|
||||
markers.update('infrastructure', null, 0, 1 / 60);
|
||||
expect(markers.mesh.visible).toBe(false);
|
||||
expect(markers.pool.visible).toBe(false);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('flashes a post as its plate crosses the line, either way, and decays in 300 ms', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const markers = createMilestones(resources, true);
|
||||
const second = passPoint(1, 4);
|
||||
markers.update('experience', second - 0.02, 0, 1 / 60);
|
||||
markers.update('experience', second - 0.01, 0, 1 / 60);
|
||||
expect(markers.flashes()[1]).toBe(0);
|
||||
markers.update('experience', second + 0.01, 0, 1 / 60);
|
||||
expect(markers.flashes()[1]).toBe(1);
|
||||
for (let i = 0; i < 20; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
|
||||
expect(markers.flashes()[1]).toBe(0);
|
||||
// Back the other way (after the 0.6 s gap) flashes again.
|
||||
for (let i = 0; i < 30; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
|
||||
markers.update('experience', second - 0.01, 0, 1 / 60);
|
||||
expect(markers.flashes()[1]).toBe(1);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('never flashes on the low tier or touch', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const markers = createMilestones(resources, false);
|
||||
const first = passPoint(0, 3);
|
||||
markers.update('profile', first - 0.01, 1, 1 / 60);
|
||||
markers.update('profile', first + 0.01, 1, 1 / 60);
|
||||
expect(markers.flashes().every((level) => level === 0)).toBe(true);
|
||||
expect(markers.mesh.visible).toBe(true);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
187
tests/unit/route-log.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick, shallowRef } from 'vue';
|
||||
import { useRouteLog } from '@/composables/useRouteLog';
|
||||
import { maintenanceProgress } from '@/journey/maintenance-progress';
|
||||
import {
|
||||
createRewindLatch,
|
||||
fitCaption,
|
||||
milestoneIndex,
|
||||
milestonesFor,
|
||||
parseDates,
|
||||
passPoint,
|
||||
ROUTE_LOG_BUDGET,
|
||||
routeLogSubline,
|
||||
sectionLocalProgress,
|
||||
} from '@/journey/route-log';
|
||||
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 at = (activeSectionId: SectionId, active: number, next: number | null) => ({
|
||||
activeSectionId,
|
||||
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: active, nextHeadingOffsetVh: next },
|
||||
});
|
||||
|
||||
describe('section-local progress', () => {
|
||||
it('runs 0..1 between the chapter heading and the next, null elsewhere', () => {
|
||||
expect(sectionLocalProgress(at('experience', 0, 2), 'experience')).toBe(0);
|
||||
expect(sectionLocalProgress(at('experience', -1, 1), 'experience')).toBeCloseTo(0.5);
|
||||
expect(sectionLocalProgress(at('experience', -3, -1), 'experience')).toBe(1);
|
||||
expect(sectionLocalProgress(at('experience', -1, 1), 'profile')).toBeNull();
|
||||
expect(sectionLocalProgress(at('profile', 0, null), 'profile')).toBeNull();
|
||||
expect(sectionLocalProgress(at('profile', 1, 1), 'profile')).toBeNull();
|
||||
expect(sectionLocalProgress(at('profile', Number.NaN, 1), 'profile')).toBeNull();
|
||||
});
|
||||
|
||||
it('still backs the 03:12 window', () => {
|
||||
expect(maintenanceProgress(at('infrastructure', -1, 1))).toBeCloseTo(0.5);
|
||||
expect(maintenanceProgress(at('experience', -1, 1))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('milestones from the real content', () => {
|
||||
it('lists three roles and the systems plate for Experience', () => {
|
||||
const items = milestonesFor(content, 'experience');
|
||||
expect(items).toHaveLength(4);
|
||||
expect(items.map((item) => item.index)).toEqual([0, 1, 2, 3]);
|
||||
expect(items[0]).toMatchObject({ dateLabel: 'AUG 2025 → PRESENT', compactDate: '2025→PRESENT', caption: 'TRUPA LUI RĂZUȘ' });
|
||||
expect(items[1]).toMatchObject({ dateLabel: 'JUN 2025 → AUG 2025', compactDate: 'JUN→AUG 2025' });
|
||||
expect(items[2]).toMatchObject({ dateLabel: 'JUN 2025 → AUG 2025', caption: 'ISENSE SOLUTIONS' });
|
||||
expect(items[3]).toMatchObject({ dateLabel: '', caption: 'SYSTEMS OPERATIONS' });
|
||||
});
|
||||
|
||||
it('lists the three screening facts for Profile', () => {
|
||||
const items = milestonesFor(content, 'profile');
|
||||
expect(items.map((item) => item.caption)).toEqual(['EDUCATION', 'LANGUAGES', 'CERTIFICATIONS']);
|
||||
expect(items.map((item) => item.dateLabel)).toEqual(['2023 → 2026', '', '2022 · 2025']);
|
||||
});
|
||||
|
||||
it('has no posts in other chapters', () => {
|
||||
expect(milestonesFor(content, 'toolkit')).toEqual([]);
|
||||
expect(routeLogSubline(0.5, milestonesFor(content, 'toolkit'))).toBeNull();
|
||||
expect(routeLogSubline(0.5, milestonesFor(content, 'experience').slice(0, 1))).toBeNull();
|
||||
});
|
||||
|
||||
it('parses spans, open spans and year lists', () => {
|
||||
expect(parseDates('Jan 2024 - Present')).toEqual({ dateLabel: 'JAN 2024 → PRESENT', compactDate: '2024→PRESENT' });
|
||||
expect(parseDates('2019–2021')).toEqual({ dateLabel: '2019 → 2021', compactDate: '2019→2021' });
|
||||
expect(parseDates('Remote')).toEqual({ dateLabel: '', compactDate: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ROUTE LOG sub-line', () => {
|
||||
const experience = milestonesFor(content, 'experience');
|
||||
const profile = milestonesFor(content, 'profile');
|
||||
|
||||
it('steps through the posts at the middle of each share', () => {
|
||||
expect(passPoint(0, 4)).toBe(0.125);
|
||||
expect(passPoint(3, 4)).toBe(0.875);
|
||||
expect(milestoneIndex(0, 4)).toBe(0);
|
||||
expect(milestoneIndex(0.26, 4)).toBe(1);
|
||||
expect(milestoneIndex(1, 4)).toBe(3);
|
||||
expect(routeLogSubline(0.3, experience)).toBe('LOG 02/04 · JUN 2025 → AUG 2025 · CCF');
|
||||
expect(routeLogSubline(0.6, experience)).toBe('LOG 03/04 · JUN 2025 → AUG 2025 · ISENSE SOLUTIONS');
|
||||
expect(routeLogSubline(0.95, experience)).toBe('LOG 04/04 · SYSTEMS OPERATIONS');
|
||||
expect(routeLogSubline(0.4, profile)).toBe('LOG 02/03 · LANGUAGES');
|
||||
expect(routeLogSubline(0.1, profile)).toBe('LOG 01/03 · 2023 → 2026 · EDUCATION');
|
||||
});
|
||||
|
||||
it('adds REWIND while scrolling back', () => {
|
||||
expect(routeLogSubline(0.6, experience, true)).toMatch(/^LOG 03\/04 · JUN 2025 → AUG 2025 · .+ · REWIND$/u);
|
||||
// A narrow plate rewinding has no room for the date: the index and caption stay.
|
||||
expect(routeLogSubline(0.1, experience, true, 'compact')).toBe('LOG 01/04 · TLR · REWIND');
|
||||
});
|
||||
|
||||
it('fits the narrow plates (phones) in the budget, shortening the organisation', () => {
|
||||
for (const items of [experience, profile]) {
|
||||
for (let p = 0; p <= 1; p += 0.05) {
|
||||
for (const reversing of [false, true]) {
|
||||
expect(routeLogSubline(p, items, reversing, 'compact')!.length).toBeLessThanOrEqual(ROUTE_LOG_BUDGET.compact);
|
||||
expect(routeLogSubline(p, items, reversing, 'wide')!.length).toBeLessThanOrEqual(ROUTE_LOG_BUDGET.wide);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(routeLogSubline(0.1, experience, false, 'compact')).toBe('LOG 01/04 · 2025→PRESENT · TLR');
|
||||
// Only 'IS' would fit after the date, so the caption takes the date's place.
|
||||
expect(routeLogSubline(0.6, experience, false, 'compact')).toBe('LOG 03/04 · ISENSE SOLUTIONS');
|
||||
expect(fitCaption('CAMERA CONSULTANȚILOR FISCALI', 10)).toBe('CCF');
|
||||
expect(fitCaption('ISENSE SOLUTIONS', 2)).toBe('IS');
|
||||
expect(fitCaption('CERTIFICATIONS', 4)).toBe('');
|
||||
});
|
||||
|
||||
it('latches a rewind while progress falls, per chapter', () => {
|
||||
const latch = createRewindLatch();
|
||||
expect(latch(0.2, 'experience')).toBe(false);
|
||||
expect(latch(0.4, 'experience')).toBe(false);
|
||||
expect(latch(0.3, 'experience')).toBe(true);
|
||||
expect(latch(0.3005, 'experience')).toBe(true);
|
||||
expect(latch(0.5, 'experience')).toBe(false);
|
||||
// Profile ends at 1 and Experience starts at 0: a new chapter is not a rewind.
|
||||
expect(latch(1, 'profile')).toBe(false);
|
||||
expect(latch(0, 'experience')).toBe(false);
|
||||
expect(latch(0.1, 'experience')).toBe(false);
|
||||
expect(latch(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRouteLog', () => {
|
||||
const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0.3,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0,
|
||||
activeSectionId,
|
||||
chapter: 'systems',
|
||||
sign: null,
|
||||
arrived: false,
|
||||
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n },
|
||||
});
|
||||
|
||||
it('sets html[data-route-log] and the sub-line only in its chapters, then cleans up', async () => {
|
||||
const journey = shallowRef(snapshot('start', -1, 1));
|
||||
let subline = shallowRef('');
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
subline = useRouteLog(journey, () => content) as typeof subline;
|
||||
return () => h('span', subline.value);
|
||||
},
|
||||
}));
|
||||
const root = document.documentElement;
|
||||
await nextTick();
|
||||
expect(root.dataset.routeLog).toBeUndefined();
|
||||
expect(subline.value).toBe('');
|
||||
|
||||
journey.value = snapshot('experience', -0.2, 1.8);
|
||||
await nextTick();
|
||||
expect(root.dataset.routeLog).toBe('1/4');
|
||||
expect(subline.value).toMatch(/^LOG 01\/04 · /u);
|
||||
|
||||
journey.value = snapshot('experience', -1.2, 0.8);
|
||||
await nextTick();
|
||||
expect(root.dataset.routeLog).toBe('3/4');
|
||||
expect(subline.value).not.toMatch(/REWIND/u);
|
||||
|
||||
journey.value = snapshot('experience', -0.8, 1.2);
|
||||
await nextTick();
|
||||
expect(root.dataset.routeLog).toBe('2/4');
|
||||
expect(subline.value).toMatch(/^LOG 02\/04 · .* · REWIND$/u);
|
||||
|
||||
journey.value = snapshot('infrastructure', -0.5, 1.5);
|
||||
await nextTick();
|
||||
expect(root.dataset.routeLog).toBeUndefined();
|
||||
expect(subline.value).toBe('');
|
||||
|
||||
journey.value = snapshot('profile', -1, 1);
|
||||
await nextTick();
|
||||
expect(root.dataset.routeLog).toBe('2/3');
|
||||
wrapper.unmount();
|
||||
expect(root.dataset.routeLog).toBeUndefined();
|
||||
});
|
||||
});
|
||||