feat: scroll-driven maintenance window set-piece in the infrastructure chapter

This commit is contained in:
2026-10-11 04:25:28 +03:00
parent bd9bd692b9
commit 52b4a1ad14
45 changed files with 1063 additions and 10 deletions

View File

@@ -0,0 +1,88 @@
import type { JourneySnapshot } from './types';
/**
* The 03:12 window: the Infrastructure chapter's set-piece is driven by how
* far the visitor has read through the chapter, never by time, so scrolling
* back up replays it in reverse (a rollback). Shared by the DOM (HUD sub-line,
* diagram steps) and the lazy scene chunk, so it stays tiny and pure.
*
* The beats follow the featured operation in the content: back up, convert
* and transfer, verify, then cut over.
*/
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
/** Upper bounds of each step, in section-local progress. */
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: 0.9 } as const;
/**
* The clock reads live Bucharest time again from here. The window keeps its
* own clock right up to the next chapter: "CUTOVER OK" always reads 03:51.
*/
export const MAINTENANCE_LIVE_FROM = 1;
type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>;
/**
* Section-local progress: 0 with the Infrastructure heading on the activation
* 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));
}
export function maintenanceStep(p: number): MaintenanceStep {
if (p < MAINTENANCE_BOUNDS.open) return 'open';
if (p < MAINTENANCE_BOUNDS.backup) return 'backup';
if (p < MAINTENANCE_BOUNDS.transfer) return 'transfer';
if (p < MAINTENANCE_BOUNDS.verify) return 'verify';
return 'done';
}
/** The window opens at 03:12 and cuts over at 03:51; the clock runs with the scroll. */
export function maintenanceClock(p: number, live: () => string): string {
if (p >= MAINTENANCE_LIVE_FROM) return live();
if (p >= MAINTENANCE_BOUNDS.verify) return '03:51';
const run = Math.min(1, Math.max(0, (p - MAINTENANCE_BOUNDS.open) / (MAINTENANCE_BOUNDS.verify - MAINTENANCE_BOUNDS.open)));
return `03:${String(12 + Math.round(run * 39)).padStart(2, '0')}`;
}
const LABELS: Readonly<Record<MaintenanceStep, string>> = {
open: 'MAINTENANCE WINDOW',
backup: 'BACKUP',
transfer: 'CONVERT · TRANSFER',
verify: 'VERIFY',
done: 'CUTOVER OK',
};
/** The gantry plate's sub-line, e.g. "MAINTENANCE WINDOW · 03:12". */
export function maintenanceSubline(p: number, reversing: boolean, live: () => string): string {
const label = reversing ? 'ROLLBACK CHECKPOINT' : LABELS[maintenanceStep(p)];
return `${label} · ${maintenanceClock(p, live)}`;
}
/**
* 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;
};
}