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

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