feat: route log odometer and lit mile markers for profile and experience
This commit is contained in:
@@ -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
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user