feat: route log odometer and lit mile markers for profile and experience

This commit is contained in:
2026-10-11 06:15:36 +03:00
parent b6e22a1299
commit 19f3ce79e6
29 changed files with 932 additions and 26 deletions

View File

@@ -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/.

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View 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') ?? '';
});
}

View File

@@ -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
View 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;
};
}

View File

@@ -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);

View 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
View 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; } }

View File

@@ -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);

View File

@@ -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();
});
});

View 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();
});
});

View 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();
});
});