feat: coordinate sections signs and native navigation

This commit is contained in:
2026-09-02 04:27:10 +03:00
parent 9bbe4652ac
commit 22d456a6ca
11 changed files with 1510 additions and 31 deletions

View File

@@ -1,43 +1,34 @@
<script setup lang="ts">
import type { SectionId } from '@/content/types';
import { watch } from "vue";
import type { SectionId } from "@/content/types";
import { useJourneySections } from "@/composables/useJourneySections";
import { useKeyboardNavigation } from "@/composables/useKeyboardNavigation";
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>;
}>();
const canonicalIds = props.items.map((item) => item.id);
const navigation = useKeyboardNavigation(canonicalIds);
const journey = useJourneySections(canonicalIds, navigation.state);
const focusActivatedSection = (event: MouseEvent): void => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
!(event.currentTarget instanceof HTMLAnchorElement)
) {
return;
}
let sectionId: string;
try {
sectionId = decodeURIComponent(event.currentTarget.hash.slice(1));
} catch {
return;
}
const section = document.getElementById(sectionId);
const heading = section?.querySelector<HTMLElement>(
':scope > h2[tabindex="-1"]',
);
if (heading === undefined || heading === null) return;
requestAnimationFrame(() => heading.focus({ preventScroll: true }));
};
watch(
() => journey.value.activeSectionId,
(id) => navigation.notePassiveSection(id),
);
</script>
<template>
<nav class="site-navigation" aria-label="Portfolio sections">
<ul>
<li v-for="item in items" :key="item.id">
<a :href="`#${item.id}`" @click="focusActivatedSection">{{ item.label }}</a>
<a
:href="`#${item.id}`"
:aria-current="
journey.activeSectionId === item.id ? 'page' : undefined
"
@click="navigation.onSectionLinkClick"
>{{ item.label }}</a
>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,243 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
} from "vue";
import type { SectionId } from "@/content/types";
import type { NavigationState } from "@/journey/navigation-controller";
import {
activationLineDocumentY,
deriveSectionState,
isAtDocumentEnd,
selectActiveSection,
type SectionHeadingGeometry,
} from "@/journey/section-controller";
import { nextSign } from "@/journey/sign-controller";
import type { JourneySnapshot } from "@/journey/types";
import { useScrollDrive } from "./useScrollDrive";
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
const initialJourney = (activeSectionId: SectionId): JourneySnapshot => ({
scrollY: 0,
routeProgress: 0,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
...deriveSectionState(activeSectionId),
sign: null,
});
export function useJourneySections(
canonicalIds: readonly SectionId[],
navigationState: Readonly<Ref<NavigationState>>,
motionAllowed?: Readonly<Ref<boolean>>,
): Readonly<Ref<JourneySnapshot>> {
const firstId = canonicalIds[0];
if (firstId === undefined) {
throw new Error("At least one canonical journey section is required.");
}
const drive = useScrollDrive();
const snapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
let scheduleMeasurement = (): void => {};
let teardown = (): void => {};
const stopNavigationWatch = watch(
() => navigationState.value,
() => scheduleMeasurement(),
);
const stopMotionWatch =
motionAllowed === undefined
? (): void => {}
: watch(motionAllowed, () => scheduleMeasurement());
onMounted(() => {
const visualViewport = window.visualViewport;
let frameId: number | undefined;
let disposed = false;
let previousScrollY = window.scrollY;
let previousActiveId = firstId;
let headings: readonly SectionHeadingGeometry[] = [];
const readHeadings = (): readonly SectionHeadingGeometry[] =>
canonicalIds.flatMap((id) => {
const heading = document.getElementById(`${id}-heading`);
return heading instanceof HTMLElement
? [{ id, top: heading.getBoundingClientRect().top + window.scrollY }]
: [];
});
const measure = (): void => {
frameId = undefined;
headings = readHeadings();
if (headings.length === 0) return;
const scrollingElement =
document.scrollingElement ?? document.documentElement;
const viewportHeight =
visualViewport?.height ?? document.documentElement.clientHeight;
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
const scrollY = scrollingElement.scrollTop;
const activationY = activationLineDocumentY({
scrollY,
visualViewportOffsetTop: viewportOffsetTop,
visualViewportHeight: viewportHeight,
});
const atDocumentEnd = isAtDocumentEnd({
scrollY,
scrollHeight: scrollingElement.scrollHeight,
visualViewportHeight: viewportHeight,
});
const activeSectionId = selectActiveSection({
headings,
activationY,
atDocumentEnd,
});
const previousActiveIndex = canonicalIds.indexOf(previousActiveId);
const activeIndex = canonicalIds.indexOf(activeSectionId);
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
const deltaY = scrollY - previousScrollY;
const currentSign = snapshot.value.sign;
const signTargetId =
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
const signTarget = headings.find(
(heading) => heading.id === signTargetId,
);
const sign =
signTargetId === null || signTarget === undefined || viewportHeight <= 0
? null
: nextSign(currentSign, {
source: navigationState.value.source,
deltaY,
viewportHeight,
crossedBoundaries,
candidateId: signTargetId,
distanceVh: (signTarget.top - activationY) / viewportHeight,
motionAllowed: motionAllowed?.value ?? true,
});
const sectionState = deriveSectionState(activeSectionId);
snapshot.value = {
...drive.value,
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
...sectionState,
sign,
};
previousScrollY = scrollY;
previousActiveId = activeSectionId;
};
scheduleMeasurement = (): void => {
if (
!disposed &&
frameId === undefined &&
document.visibilityState !== "hidden"
) {
frameId = window.requestAnimationFrame(measure);
}
};
const resizeObserver = new ResizeObserver(scheduleMeasurement);
resizeObserver.observe(document.documentElement);
for (const id of canonicalIds) {
const section = document.getElementById(id);
const heading = document.getElementById(`${id}-heading`);
if (section !== null) resizeObserver.observe(section);
if (heading !== null) resizeObserver.observe(heading);
}
for (const media of document.querySelectorAll("img, video")) {
resizeObserver.observe(media);
}
const handleVisibility = (): void => {
if (document.visibilityState === "hidden") {
if (frameId !== undefined) {
window.cancelAnimationFrame(frameId);
frameId = undefined;
}
return;
}
previousScrollY = window.scrollY;
scheduleMeasurement();
};
window.addEventListener("scroll", scheduleMeasurement, PASSIVE_LISTENER);
window.addEventListener("resize", scheduleMeasurement, PASSIVE_LISTENER);
window.addEventListener(
"orientationchange",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.addEventListener(
"resize",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.addEventListener(
"scroll",
scheduleMeasurement,
PASSIVE_LISTENER,
);
document.addEventListener("load", scheduleMeasurement, true);
document.addEventListener("loadedmetadata", scheduleMeasurement, true);
document.addEventListener(
"visibilitychange",
handleVisibility,
PASSIVE_LISTENER,
);
scheduleMeasurement();
void document.fonts.ready.then(() => scheduleMeasurement());
teardown = (): void => {
disposed = true;
window.removeEventListener(
"scroll",
scheduleMeasurement,
PASSIVE_LISTENER,
);
window.removeEventListener(
"resize",
scheduleMeasurement,
PASSIVE_LISTENER,
);
window.removeEventListener(
"orientationchange",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.removeEventListener(
"resize",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.removeEventListener(
"scroll",
scheduleMeasurement,
PASSIVE_LISTENER,
);
document.removeEventListener("load", scheduleMeasurement, true);
document.removeEventListener("loadedmetadata", scheduleMeasurement, true);
document.removeEventListener(
"visibilitychange",
handleVisibility,
PASSIVE_LISTENER,
);
resizeObserver.disconnect();
if (frameId !== undefined) {
window.cancelAnimationFrame(frameId);
frameId = undefined;
}
};
});
onUnmounted(() => {
stopNavigationWatch();
stopMotionWatch();
teardown();
});
return readonly(snapshot);
}

View File

@@ -0,0 +1,142 @@
import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from "vue";
import type { SectionId } from "@/content/types";
import {
canonicalSectionIdFromHash,
createNavigationController,
validateSectionAnchor,
type NavigationController,
type NavigationState,
} from "@/journey/navigation-controller";
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
const SCROLL_KEYS = new Set([
"PageUp",
"PageDown",
"Home",
"End",
" ",
"Spacebar",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
export const isScrollIntentKey = (key: string): boolean => SCROLL_KEYS.has(key);
const isEditableTarget = (target: EventTarget | null): boolean =>
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement);
export interface KeyboardNavigation {
state: Readonly<Ref<NavigationState>>;
onSectionLinkClick(event: MouseEvent): void;
notePassiveSection(id: SectionId): void;
}
export function useKeyboardNavigation(
canonicalIds: readonly SectionId[],
): KeyboardNavigation {
const state = shallowRef<NavigationState>({
epoch: 0,
source: "initial",
destinationId: null,
});
let controller: NavigationController | undefined;
let teardown = (): void => {};
const restoreCurrentFragment = (
source: "initial" | "history-restoration",
): void => {
if (controller === undefined) return;
const id = canonicalSectionIdFromHash(window.location.hash, canonicalIds);
if (id === null) return;
const target = document.getElementById(id);
if (!(target instanceof HTMLElement)) return;
if (source === "initial") controller.initialize(id, target);
else controller.restore(id, target);
};
const onSectionLinkClick = (event: MouseEvent): void => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
controller === undefined ||
!(event.currentTarget instanceof HTMLAnchorElement)
) {
return;
}
const validated = validateSectionAnchor(event.currentTarget, canonicalIds);
if (validated === null) return;
event.preventDefault();
controller.navigate(validated.id, validated.target);
};
onMounted(() => {
controller = createNavigationController(canonicalIds);
const unsubscribe = controller.subscribe((nextState) => {
state.value = nextState;
});
const noteScrollIntent = (): void => {
controller?.noteUserScrollIntent();
};
const handleKeydown = (event: KeyboardEvent): void => {
if (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
isEditableTarget(event.target) ||
!isScrollIntentKey(event.key)
) {
return;
}
noteScrollIntent();
};
const handlePopstate = (): void =>
restoreCurrentFragment("history-restoration");
const handlePageshow = (event: PageTransitionEvent): void => {
if (event.persisted) restoreCurrentFragment("history-restoration");
};
window.addEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER);
window.addEventListener("touchmove", noteScrollIntent, PASSIVE_LISTENER);
window.addEventListener("keydown", handleKeydown);
window.addEventListener("popstate", handlePopstate);
window.addEventListener("pageshow", handlePageshow);
if (window.location.hash !== "") restoreCurrentFragment("initial");
teardown = (): void => {
window.removeEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER);
window.removeEventListener(
"touchmove",
noteScrollIntent,
PASSIVE_LISTENER,
);
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("popstate", handlePopstate);
window.removeEventListener("pageshow", handlePageshow);
unsubscribe();
controller?.dispose();
controller = undefined;
};
});
onUnmounted(() => teardown());
return {
state: readonly(state),
onSectionLinkClick,
notePassiveSection(id) {
controller?.notePassiveSection(id);
},
};
}

View File

@@ -0,0 +1,321 @@
import type { SectionId } from "@/content/types";
import type { JourneySource } from "./types";
export interface NavigationState {
epoch: number;
source: JourneySource;
destinationId: 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): 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>(
':scope > h2[tabindex="-1"]',
);
return heading === null ? null : { id, target };
}
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) =>
target.scrollIntoView({ behavior, block: "start" }),
focusHeading: (target) => {
target
.querySelector<HTMLElement>(':scope > 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",
destinationId: 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,
): number => {
cancelPending();
currentState = {
epoch: currentState.epoch + 1,
source,
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;
}
cancelSettle();
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 => {
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");
} else {
frameId = runtime.requestFrame(() => {
frameId = undefined;
if (
currentState.epoch === epoch &&
currentState.source === source &&
currentState.destinationId === id
) {
runtime.scrollTarget(target, "instant");
}
});
}
return epoch;
};
return {
navigate(id, target) {
requireTarget(id, target);
const epoch = beginEpoch("explicit-navigation", id);
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();
},
};
}

View File

@@ -0,0 +1,78 @@
import type { SectionId } from "@/content/types";
import type { EnvironmentChapter } from "./types";
export interface SectionHeadingGeometry {
id: SectionId;
top: number;
}
export interface ActiveSectionInput {
headings: readonly SectionHeadingGeometry[];
activationY: number;
atDocumentEnd: boolean;
}
export interface DerivedSectionState {
activeSectionId: SectionId;
chapter: EnvironmentChapter;
arrived: boolean;
}
export interface ActivationLineInput {
scrollY: number;
visualViewportOffsetTop: number;
visualViewportHeight: number;
}
export const activationLineDocumentY = (input: ActivationLineInput): number =>
input.scrollY +
input.visualViewportOffsetTop +
input.visualViewportHeight * 0.35;
export interface DocumentEndInput {
scrollY: number;
scrollHeight: number;
visualViewportHeight: number;
}
export const isAtDocumentEnd = (input: DocumentEndInput): boolean =>
Math.max(0, input.scrollHeight - input.visualViewportHeight) -
input.scrollY <=
2;
export function selectActiveSection(input: ActiveSectionInput): SectionId {
const first = input.headings[0];
if (first === undefined) {
throw new Error("At least one canonical section heading is required.");
}
if (input.atDocumentEnd) {
return input.headings[input.headings.length - 1]?.id ?? first.id;
}
let active = first.id;
for (const heading of input.headings) {
if (heading.top <= input.activationY) active = heading.id;
}
return active;
}
const chapters: Readonly<Record<SectionId, EnvironmentChapter>> = {
start: "deep-night",
profile: "deep-night",
experience: "systems",
infrastructure: "infrastructure",
"selected-builds": "builds",
"additional-work": "builds",
toolkit: "pre-dawn",
contact: "destination",
};
export function deriveSectionState(
activeSectionId: SectionId,
): DerivedSectionState {
return {
activeSectionId,
chapter: chapters[activeSectionId],
arrived: activeSectionId === "contact",
};
}

View File

@@ -0,0 +1,53 @@
import type { SectionId } from "@/content/types";
import type { JourneySource, SignSnapshot } from "./types";
export interface SignSample {
source: JourneySource;
deltaY: number;
viewportHeight: number;
crossedBoundaries: number;
candidateId: SectionId | null;
distanceVh: number;
motionAllowed: boolean;
}
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
export function nextSign(
current: SignSnapshot | null,
input: SignSample,
): SignSnapshot | null {
const eligible =
input.motionAllowed &&
input.source === "passive-scroll" &&
Number.isFinite(input.deltaY) &&
Number.isFinite(input.viewportHeight) &&
Number.isFinite(input.distanceVh) &&
input.viewportHeight > 0 &&
input.deltaY > 0 &&
input.deltaY <= input.viewportHeight * 0.75 &&
input.crossedBoundaries <= 1;
if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) {
return null;
}
const targetId = current?.targetId ?? input.candidateId;
if (targetId === null) return null;
if (input.distanceVh > 0.55) {
return {
targetId,
phase: "approach",
progress: clamp01((1.2 - input.distanceVh) / 0.65),
};
}
if (input.distanceVh > 0.15) {
return { targetId, phase: "stabilized", progress: 1 };
}
return {
targetId,
phase: "pass",
progress: clamp01((0.15 - input.distanceVh) / 0.4),
};
}

View File

@@ -1,3 +1,5 @@
import type { SectionId } from "@/content/types";
export interface ScrollDriveSample {
scrollY: number;
now: number;
@@ -16,3 +18,27 @@ export interface ScrollDriveController {
sample(input: ScrollDriveSample): ScrollDriveSnapshot;
reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
}
export type JourneySource =
"initial" | "explicit-navigation" | "passive-scroll" | "history-restoration";
export type EnvironmentChapter =
| "deep-night"
| "systems"
| "infrastructure"
| "builds"
| "pre-dawn"
| "destination";
export interface SignSnapshot {
targetId: SectionId;
phase: "approach" | "stabilized" | "pass";
progress: number;
}
export interface JourneySnapshot extends ScrollDriveSnapshot {
activeSectionId: SectionId;
chapter: EnvironmentChapter;
sign: SignSnapshot | null;
arrived: boolean;
}