Files
Portfolio/src/journey/navigation-controller.ts

418 lines
13 KiB
TypeScript

import type { SectionId } from "@/content/types";
import type { JourneySource } from "./types";
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
typeof __PORTFOLIO_TEST_BUILD__ !== "undefined" && __PORTFOLIO_TEST_BUILD__;
export type NavigationPhase = "idle" | "travelling" | "settled";
export interface NavigationState {
readonly epoch: number;
readonly source: JourneySource;
readonly phase: NavigationPhase;
readonly destinationId: SectionId | null;
readonly signDestinationId: SectionId | null;
}
export interface NavigationRuntime {
getHash(): string;
pushHash(hash: string): void;
replaceHash(hash: string): void;
scrollTarget(target: HTMLElement, behavior: ScrollBehavior): void;
focusHeading(target: HTMLElement): void;
getScrollY(): number;
requestFrame(callback: FrameRequestCallback): number;
cancelFrame(id: number): void;
setTimer(callback: () => void, delay: number): number;
clearTimer(id: number): void;
supportsScrollEnd: boolean;
addScrollEndListener(listener: EventListener): void;
removeScrollEndListener(listener: EventListener): void;
}
export interface NavigationController {
navigate(
id: SectionId,
target: HTMLElement,
options?: { requestSign?: boolean },
): number;
noteUserScrollIntent(): number;
notePassiveSection(id: SectionId): void;
initialize(id: SectionId, target: HTMLElement): number;
restore(id: SectionId, target: HTMLElement): number;
state(): NavigationState;
subscribe(listener: (state: NavigationState) => void): () => void;
dispose(): void;
}
const SETTLE_TIMEOUT_MS = 1500;
const PASSIVE_REPLACE_DELAY_MS = 150;
const STABLE_FRAME_COUNT = 3;
const STABLE_SCROLL_DELTA_PX = 1;
export function canonicalSectionIdFromHash(
hash: string,
canonicalIds: readonly SectionId[],
): SectionId | null {
const encoded = hash.startsWith("#") ? hash.slice(1) : hash;
if (encoded === "") return canonicalIds[0] ?? null;
let decoded: string;
try {
decoded = decodeURIComponent(encoded);
} catch {
return null;
}
return canonicalIds.find((id) => id === decoded) ?? null;
}
export function validateSectionAnchor(
anchor: HTMLAnchorElement,
canonicalIds: readonly SectionId[],
): { id: SectionId; target: HTMLElement } | null {
let url: URL;
try {
url = new URL(anchor.href, window.location.href);
} catch {
return null;
}
if (
url.origin !== window.location.origin ||
url.pathname !== window.location.pathname ||
url.search !== window.location.search
) {
return null;
}
const id = canonicalSectionIdFromHash(url.hash, canonicalIds);
if (id === null) return null;
const target = document.getElementById(id);
if (!(target instanceof HTMLElement) || target.id !== id) return null;
const heading = target.querySelector<HTMLElement>(
'h1[tabindex="-1"], h2[tabindex="-1"]',
);
return heading === null ? null : { id, target };
}
/** How long a pre-armed interlude waits for the scene loop to take over. */
const PRE_ARM_TIMEOUT_MS = 1400;
/** Matches INTERLUDE_RISE_MS in the environment loop. */
const PRE_ARM_RISE_MS = 1100;
/**
* A long navigator scroll passes the chapters in between. While the scene runs
* (the loop has set data-gantry), the reading plates step aside at click time
* instead of when the sign starts its approach, so those chapters do not flash
* past. The loop takes the attribute over as soon as the sign's interlude
* starts; if no interlude follows, the plates rise again after a timeout.
*/
const preArmInterlude = (): void => {
const root = document.documentElement;
if (root.dataset.gantry === undefined || root.dataset.interlude === "on") return;
root.dataset.interlude = "on";
root.dataset.interludeArmed = "nav";
window.setTimeout(() => {
if (root.dataset.interludeArmed !== "nav") return;
root.dataset.interlude = "rise";
root.dataset.interludeArmed = "rise";
window.setTimeout(() => {
if (root.dataset.interludeArmed !== "rise") return;
delete root.dataset.interlude;
delete root.dataset.interludeArmed;
}, PRE_ARM_RISE_MS);
}, PRE_ARM_TIMEOUT_MS);
};
const browserRuntime = (): NavigationRuntime => ({
getHash: () => window.location.hash,
pushHash: (hash) => window.history.pushState(window.history.state, "", hash),
replaceHash: (hash) =>
window.history.replaceState(window.history.state, "", hash),
scrollTarget: (target, behavior) => {
// Inside the live reading stage the document is the only real scroller;
// scrollIntoView would move the script-driven inner viewport instead.
if (target.closest('[data-reading-stage="live"]') === null) {
target.scrollIntoView({ behavior, block: "start" });
return;
}
const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
const delta = target.getBoundingClientRect().top - margin;
if (behavior === "smooth" && Math.abs(delta) > window.innerHeight * 0.6)
preArmInterlude();
window.scrollTo({ top: window.scrollY + delta, behavior });
},
focusHeading: (target) => {
// The live reading stage can start or stop while a long journey is still
// in flight (the scene boots lazily and steps down on slow GPUs), which
// shifts layout under the scroll. Re-land once, instantly, before focus.
const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
const delta = target.getBoundingClientRect().top - margin;
if (Math.abs(delta) > 2)
window.scrollTo({ top: window.scrollY + delta, behavior: "instant" });
target
.querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]')
?.focus({ preventScroll: true });
},
getScrollY: () => window.scrollY,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (id) => window.cancelAnimationFrame(id),
setTimer: (callback, delay) => window.setTimeout(callback, delay),
clearTimer: (id) => window.clearTimeout(id),
supportsScrollEnd: "onscrollend" in window,
addScrollEndListener: (listener) =>
window.addEventListener("scrollend", listener),
removeScrollEndListener: (listener) =>
window.removeEventListener("scrollend", listener),
});
export function createNavigationController(
canonicalIds: readonly SectionId[],
runtime: NavigationRuntime = browserRuntime(),
): NavigationController {
let currentState: NavigationState = {
epoch: 0,
source: "initial",
phase: "idle",
destinationId: null,
signDestinationId: null,
};
const subscribers = new Set<(state: NavigationState) => void>();
let frameId: number | undefined;
let settleTimerId: number | undefined;
let passiveTimerId: number | undefined;
let scrollEndListener: EventListener | undefined;
const emit = (): void => {
for (const subscriber of subscribers) subscriber({ ...currentState });
};
const cancelSettle = (): void => {
if (frameId !== undefined) {
runtime.cancelFrame(frameId);
frameId = undefined;
}
if (settleTimerId !== undefined) {
runtime.clearTimer(settleTimerId);
settleTimerId = undefined;
}
if (scrollEndListener !== undefined) {
runtime.removeScrollEndListener(scrollEndListener);
scrollEndListener = undefined;
}
};
const cancelPassiveReplace = (): void => {
if (passiveTimerId !== undefined) {
runtime.clearTimer(passiveTimerId);
passiveTimerId = undefined;
}
};
const cancelPending = (): void => {
cancelSettle();
cancelPassiveReplace();
};
const beginEpoch = (
source: JourneySource,
destinationId: SectionId | null,
signDestinationId: SectionId | null = null,
): number => {
cancelPending();
currentState = {
epoch: currentState.epoch + 1,
source,
phase: source === "passive-scroll" ? "idle" : "travelling",
destinationId,
signDestinationId,
};
if (TEST_BUILD)
window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
if (
TEST_BUILD &&
source === "explicit-navigation" &&
(destinationId === "toolkit" || destinationId === "contact")
) {
window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(destinationId);
}
emit();
return currentState.epoch;
};
const requireTarget = (id: SectionId, target: HTMLElement): void => {
if (!canonicalIds.includes(id) || target.id !== id) {
throw new Error(`Invalid canonical navigation target: ${id}`);
}
};
const settleExplicit = (
epoch: number,
id: SectionId,
target: HTMLElement,
): void => {
if (
currentState.epoch !== epoch ||
currentState.source !== "explicit-navigation" ||
currentState.destinationId !== id
) {
return;
}
if (
TEST_BUILD &&
window.__PORTFOLIO_PERFORMANCE__?.holdNavigationSettlement(epoch)
)
return;
cancelSettle();
currentState = { ...currentState, phase: "settled" };
emit();
frameId = runtime.requestFrame(() => {
frameId = undefined;
if (
currentState.epoch === epoch &&
currentState.source === "explicit-navigation" &&
currentState.destinationId === id
) {
runtime.focusHeading(target);
}
});
};
const scheduleSettle = (
epoch: number,
id: SectionId,
target: HTMLElement,
): void => {
if (TEST_BUILD)
window.__PORTFOLIO_PERFORMANCE__?.registerNavigationSettlement(epoch);
settleTimerId = runtime.setTimer(() => {
settleTimerId = undefined;
if (
currentState.epoch !== epoch ||
currentState.source !== "explicit-navigation" ||
currentState.destinationId !== id
) {
return;
}
runtime.scrollTarget(target, "instant");
settleExplicit(epoch, id, target);
}, SETTLE_TIMEOUT_MS);
if (runtime.supportsScrollEnd) {
scrollEndListener = (): void => settleExplicit(epoch, id, target);
runtime.addScrollEndListener(scrollEndListener);
return;
}
let previousScrollY = runtime.getScrollY();
let stableFrames = 0;
const inspectFrame = (): void => {
frameId = undefined;
if (
currentState.epoch !== epoch ||
currentState.source !== "explicit-navigation" ||
currentState.destinationId !== id
) {
return;
}
const scrollY = runtime.getScrollY();
stableFrames =
Math.abs(scrollY - previousScrollY) < STABLE_SCROLL_DELTA_PX
? stableFrames + 1
: 0;
previousScrollY = scrollY;
if (stableFrames >= STABLE_FRAME_COUNT) {
settleExplicit(epoch, id, target);
} else {
frameId = runtime.requestFrame(inspectFrame);
}
};
frameId = runtime.requestFrame(inspectFrame);
};
const alignWithoutFocus = (
source: "initial" | "history-restoration",
id: SectionId,
target: HTMLElement,
): number => {
requireTarget(id, target);
const epoch = beginEpoch(source, id);
if (source === "initial") {
runtime.scrollTarget(target, "instant");
if (
currentState.epoch === epoch &&
currentState.source === source &&
currentState.destinationId === id
) {
currentState = { ...currentState, phase: "settled" };
emit();
}
} else {
frameId = runtime.requestFrame(() => {
frameId = undefined;
if (
currentState.epoch === epoch &&
currentState.source === source &&
currentState.destinationId === id
) {
runtime.scrollTarget(target, "instant");
currentState = { ...currentState, phase: "settled" };
emit();
}
});
}
return epoch;
};
return {
navigate(id, target, options) {
requireTarget(id, target);
const epoch = beginEpoch(
"explicit-navigation",
id,
options?.requestSign === true ? id : null,
);
if (canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id) {
runtime.pushHash(`#${id}`);
}
scheduleSettle(epoch, id, target);
runtime.scrollTarget(target, "smooth");
return epoch;
},
noteUserScrollIntent() {
return beginEpoch("passive-scroll", null);
},
notePassiveSection(id) {
if (currentState.source !== "passive-scroll") return;
cancelPassiveReplace();
const epoch = currentState.epoch;
passiveTimerId = runtime.setTimer(() => {
passiveTimerId = undefined;
if (
currentState.epoch === epoch &&
currentState.source === "passive-scroll" &&
canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id
) {
runtime.replaceHash(`#${id}`);
}
}, PASSIVE_REPLACE_DELAY_MS);
},
initialize(id, target) {
return alignWithoutFocus("initial", id, target);
},
restore(id, target) {
return alignWithoutFocus("history-restoration", id, target);
},
state: () => ({ ...currentState }),
subscribe(listener) {
subscribers.add(listener);
listener({ ...currentState });
return () => subscribers.delete(listener);
},
dispose() {
cancelPending();
currentState = { ...currentState, epoch: currentState.epoch + 1 };
subscribers.clear();
},
};
}