feat: drive signs from a shared animation clock

This commit is contained in:
2026-09-02 14:14:18 +03:00
parent 24afda8710
commit f2dd3f85dd
9 changed files with 615 additions and 265 deletions

View File

@@ -6,19 +6,19 @@ import {
shallowRef, shallowRef,
watch, watch,
type Ref, type Ref,
} from "vue"; } from 'vue';
import type { SectionId } from "@/content/types"; import type { SectionId } from '@/content/types';
import type { NavigationState } from "@/journey/navigation-controller"; import type { NavigationState } from '@/journey/navigation-controller';
import { import {
activationLineDocumentY, activationLineDocumentY,
deriveSectionState, deriveSectionState,
isAtDocumentEnd, isAtDocumentEnd,
selectActiveSection, selectActiveSection,
type SectionHeadingGeometry, type SectionHeadingGeometry,
} from "@/journey/section-controller"; } from '@/journey/section-controller';
import { nextSign } from "@/journey/sign-controller"; import { createSignController } from '@/journey/sign-controller';
import type { JourneySnapshot } from "@/journey/types"; import type { JourneySnapshot, ScrollDriveSnapshot } from '@/journey/types';
import { useScrollDrive } from "./useScrollDrive"; import { useScrollDrive } from '@/composables/useScrollDrive';
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
@@ -39,61 +39,67 @@ export function useJourneySections(
): Readonly<Ref<JourneySnapshot>> { ): Readonly<Ref<JourneySnapshot>> {
const firstId = canonicalIds[0]; const firstId = canonicalIds[0];
if (firstId === undefined) { if (firstId === undefined) {
throw new Error("At least one canonical journey section is required."); throw new Error('At least one canonical journey section is required.');
} }
const drive = useScrollDrive(); const drive = useScrollDrive();
const signController = createSignController();
const sectionSnapshot = shallowRef<JourneySnapshot>(initialJourney(firstId)); const sectionSnapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
const snapshot = computed<JourneySnapshot>(() => ({ const snapshot = computed<JourneySnapshot>(() => ({
...sectionSnapshot.value, ...sectionSnapshot.value,
...drive.value, ...drive.snapshot.value,
routeProgress: sectionSnapshot.value.arrived ? 1 : drive.value.routeProgress, routeProgress: sectionSnapshot.value.arrived
? 1
: drive.snapshot.value.routeProgress,
})); }));
let scheduleMeasurement = (): void => {}; let requestJourneyFrame = (): void => {};
let teardown = (): void => {}; let teardown = (): void => {};
const stopNavigationWatch = watch( const stopNavigationWatch = watch(
() => navigationState.value, () => navigationState.value,
() => scheduleMeasurement(), () => requestJourneyFrame(),
); );
const stopMotionWatch = const stopMotionWatch = motionAllowed === undefined
motionAllowed === undefined ? (): void => {}
? (): void => {} : watch(motionAllowed, () => requestJourneyFrame());
: watch(motionAllowed, () => scheduleMeasurement());
onMounted(() => { onMounted(() => {
const visualViewport = window.visualViewport; const visualViewport = window.visualViewport;
let frameId: number | undefined;
let disposed = false; let disposed = false;
let previousScrollY = window.scrollY; let structureDirty = true;
let previousScrollY = drive.snapshot.value.scrollY;
let previousActiveId = firstId; let previousActiveId = firstId;
let headings: readonly SectionHeadingGeometry[] = []; let headings: readonly SectionHeadingGeometry[] = [];
const readHeadings = (): readonly SectionHeadingGeometry[] => const readHeadings = (scrollY: number): readonly SectionHeadingGeometry[] =>
canonicalIds.flatMap((id) => { canonicalIds.flatMap((id) => {
const heading = document.getElementById(`${id}-heading`); const heading = document.getElementById(`${id}-heading`);
return heading instanceof HTMLElement return heading instanceof HTMLElement
? [{ id, top: heading.getBoundingClientRect().top + window.scrollY }] ? [{ id, top: heading.getBoundingClientRect().top + scrollY }]
: []; : [];
}); });
const measure = (): void => { const measure = (
frameId = undefined; now: number,
headings = readHeadings(); driveSnapshot: ScrollDriveSnapshot,
if (headings.length === 0) return; ): boolean => {
if (disposed) return false;
if (structureDirty) {
headings = readHeadings(driveSnapshot.scrollY);
structureDirty = false;
}
if (headings.length === 0) return false;
const scrollingElement = const scrollingElement = document.scrollingElement ?? document.documentElement;
document.scrollingElement ?? document.documentElement; const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
const viewportHeight =
visualViewport?.height ?? document.documentElement.clientHeight;
const viewportOffsetTop = visualViewport?.offsetTop ?? 0; const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
const scrollY = scrollingElement.scrollTop;
const activationY = activationLineDocumentY({ const activationY = activationLineDocumentY({
scrollY, scrollY: driveSnapshot.scrollY,
visualViewportOffsetTop: viewportOffsetTop, visualViewportOffsetTop: viewportOffsetTop,
visualViewportHeight: viewportHeight, visualViewportHeight: viewportHeight,
}); });
const atDocumentEnd = isAtDocumentEnd({ const atDocumentEnd = isAtDocumentEnd({
scrollY, scrollY: driveSnapshot.scrollY,
scrollHeight: scrollingElement.scrollHeight, scrollHeight: scrollingElement.scrollHeight,
visualViewportHeight: viewportHeight, visualViewportHeight: viewportHeight,
}); });
@@ -105,47 +111,46 @@ export function useJourneySections(
const previousActiveIndex = canonicalIds.indexOf(previousActiveId); const previousActiveIndex = canonicalIds.indexOf(previousActiveId);
const activeIndex = canonicalIds.indexOf(activeSectionId); const activeIndex = canonicalIds.indexOf(activeSectionId);
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex); const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
const deltaY = scrollY - previousScrollY; const candidateId = canonicalIds[activeIndex + 1] ?? null;
const currentSign = sectionSnapshot.value.sign; const candidateHeading = headings.find((heading) => heading.id === candidateId);
const signTargetId = const candidateDistanceVh = candidateHeading === undefined || viewportHeight <= 0
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null; ? null
const signTarget = headings.find( : (candidateHeading.top - activationY) / viewportHeight;
(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); const sectionState = deriveSectionState(activeSectionId);
const sign = signController.sample({
now,
source: navigationState.value.source,
direction: driveSnapshot.direction,
acceleration: driveSnapshot.acceleration,
deltaY: driveSnapshot.scrollY - previousScrollY,
viewportHeight,
crossedBoundaries,
candidateId,
candidateDistanceVh,
activeSectionId,
motionAllowed: motionAllowed?.value ?? true,
});
sectionSnapshot.value = { sectionSnapshot.value = {
...drive.value, ...driveSnapshot,
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress, routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
...sectionState, ...sectionState,
sign, sign,
}; };
previousScrollY = scrollY; previousScrollY = driveSnapshot.scrollY;
previousActiveId = activeSectionId; previousActiveId = activeSectionId;
return signController.needsFrame();
}; };
scheduleMeasurement = (): void => { const removeFrameSubscriber = drive.subscribeFrame(measure);
if ( requestJourneyFrame = drive.requestFrame;
!disposed && const requestMeasurement = (): void => drive.requestFrame();
frameId === undefined && const markStructureDirty = (): void => {
document.visibilityState !== "hidden" structureDirty = true;
) { requestMeasurement();
frameId = window.requestAnimationFrame(measure);
}
}; };
const resizeObserver = new ResizeObserver(scheduleMeasurement); const resizeObserver = new ResizeObserver(markStructureDirty);
resizeObserver.observe(document.documentElement); resizeObserver.observe(document.documentElement);
for (const id of canonicalIds) { for (const id of canonicalIds) {
const section = document.getElementById(id); const section = document.getElementById(id);
@@ -153,89 +158,43 @@ export function useJourneySections(
if (section !== null) resizeObserver.observe(section); if (section !== null) resizeObserver.observe(section);
if (heading !== null) resizeObserver.observe(heading); if (heading !== null) resizeObserver.observe(heading);
} }
for (const media of document.querySelectorAll("img, video")) { for (const media of document.querySelectorAll('img, video')) {
resizeObserver.observe(media); resizeObserver.observe(media);
} }
const handleVisibility = (): void => { const handleVisibility = (): void => {
if (document.visibilityState === "hidden") { if (document.visibilityState === 'hidden') {
if (frameId !== undefined) { signController.reset();
window.cancelAnimationFrame(frameId);
frameId = undefined;
}
return; return;
} }
previousScrollY = window.scrollY; previousScrollY = drive.snapshot.value.scrollY;
scheduleMeasurement(); markStructureDirty();
}; };
window.addEventListener("scroll", scheduleMeasurement, PASSIVE_LISTENER); window.addEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
window.addEventListener("resize", scheduleMeasurement, PASSIVE_LISTENER); window.addEventListener('orientationchange', markStructureDirty, PASSIVE_LISTENER);
window.addEventListener( visualViewport?.addEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
"orientationchange", visualViewport?.addEventListener('scroll', requestMeasurement, PASSIVE_LISTENER);
scheduleMeasurement, document.addEventListener('load', markStructureDirty, true);
PASSIVE_LISTENER, document.addEventListener('loadedmetadata', markStructureDirty, true);
); document.addEventListener('visibilitychange', handleVisibility, 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(); markStructureDirty();
void document.fonts.ready.then(() => scheduleMeasurement()); void document.fonts?.ready.then(markStructureDirty);
teardown = (): void => { teardown = (): void => {
disposed = true; disposed = true;
window.removeEventListener( requestJourneyFrame = (): void => {};
"scroll", removeFrameSubscriber();
scheduleMeasurement, window.removeEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
PASSIVE_LISTENER, window.removeEventListener('orientationchange', markStructureDirty, PASSIVE_LISTENER);
); visualViewport?.removeEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
window.removeEventListener( visualViewport?.removeEventListener('scroll', requestMeasurement, PASSIVE_LISTENER);
"resize", document.removeEventListener('load', markStructureDirty, true);
scheduleMeasurement, document.removeEventListener('loadedmetadata', markStructureDirty, true);
PASSIVE_LISTENER, document.removeEventListener('visibilitychange', handleVisibility, 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(); resizeObserver.disconnect();
if (frameId !== undefined) { signController.reset();
window.cancelAnimationFrame(frameId);
frameId = undefined;
}
}; };
}); });

View File

@@ -12,9 +12,23 @@ const initialSnapshot = (): ScrollDriveSnapshot => ({
driveSpeed: 0.18, driveSpeed: 0.18,
}); });
export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> { export interface ScrollDriveBinding {
snapshot: Readonly<Ref<ScrollDriveSnapshot>>;
requestFrame(): void;
subscribeFrame(
listener: (now: number, snapshot: ScrollDriveSnapshot) => boolean,
): () => void;
}
export function useScrollDrive(): ScrollDriveBinding {
const controller = createScrollDriveController(); const controller = createScrollDriveController();
const snapshot = shallowRef<ScrollDriveSnapshot>(initialSnapshot()); const snapshot = shallowRef<ScrollDriveSnapshot>(initialSnapshot());
const subscribers = new Set<(
now: number,
snapshot: ScrollDriveSnapshot,
) => boolean>();
let requestFrame = (): void => {};
let disposed = false;
let teardown = (): void => {}; let teardown = (): void => {};
onMounted(() => { onMounted(() => {
@@ -32,21 +46,29 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
}; };
}; };
let scheduleFrame = (): void => {};
const runFrame = (now: number): void => { const runFrame = (now: number): void => {
frameId = undefined; frameId = undefined;
const input = readSample(now); const input = readSample(now);
snapshot.value = resetOnNextFrame const nextSnapshot = resetOnNextFrame
? controller.reset(input) ? controller.reset(input)
: controller.sample(input); : controller.sample(input);
snapshot.value = nextSnapshot;
resetOnNextFrame = false; resetOnNextFrame = false;
if (snapshot.value.acceleration > 0) scheduleFrame(); let keepRunning = false;
for (const subscriber of [...subscribers]) {
keepRunning = subscriber(now, nextSnapshot) || keepRunning;
}
if (nextSnapshot.acceleration > 0 || keepRunning) scheduleFrame();
}; };
const scheduleFrame = (): void => { scheduleFrame = (): void => {
if (frameId === undefined && document.visibilityState !== 'hidden') { if (frameId === undefined && document.visibilityState !== 'hidden') {
frameId = requestAnimationFrame(runFrame); frameId = requestAnimationFrame(runFrame);
} }
}; };
requestFrame = scheduleFrame;
const handleVisibilityChange = (): void => { const handleVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden') {
@@ -70,6 +92,8 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER); document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
teardown = (): void => { teardown = (): void => {
disposed = true;
requestFrame = (): void => {};
window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER); window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER); window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER); visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
@@ -79,10 +103,19 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId);
frameId = undefined; frameId = undefined;
} }
subscribers.clear();
}; };
}); });
onUnmounted(() => teardown()); onUnmounted(() => teardown());
return readonly(snapshot); return {
snapshot: readonly(snapshot),
requestFrame: () => requestFrame(),
subscribeFrame(listener) {
if (disposed) return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
},
};
} }

View File

@@ -1,53 +1,187 @@
import type { SectionId } from "@/content/types"; import type { SectionId } from '@/content/types';
import type { JourneySource, SignSnapshot } from "./types"; import type {
SignController,
SignControllerSample,
SignSnapshot,
} from '@/journey/types';
export interface SignSample { const ANTICIPATE_MS = 300;
source: JourneySource; const APPROACH_MS = 900;
deltaY: number; const APPROACH_REDUCTION_MS = 250;
viewportHeight: number; const DWELL_MS = 1_400;
crossedBoundaries: number; const DEPART_MS = 700;
candidateId: SectionId | null; const COOLDOWN_MS = 350;
distanceVh: number; const ELIGIBLE_DISTANCE_VH = 1.2;
motionAllowed: boolean; const JUMP_DISTANCE_VH = 0.75;
interface VisibleState {
kind: 'visible';
targetId: SectionId;
latchedAt: number;
approachDuration: number;
departStartedAt: number | null;
} }
type State =
| { kind: 'idle' }
| VisibleState
| { kind: 'cooldown'; until: number };
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
export function nextSign( const smoothstep = (minimum: number, maximum: number, value: number): number => {
current: SignSnapshot | null, const progress = clamp01((value - minimum) / (maximum - minimum));
input: SignSample, return progress * progress * (3 - 2 * progress);
): 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) { const isFiniteSample = (input: SignControllerSample): boolean =>
return null; Number.isFinite(input.now) &&
} Number.isFinite(input.acceleration) &&
Number.isFinite(input.deltaY) &&
Number.isFinite(input.viewportHeight) &&
Number.isFinite(input.crossedBoundaries) &&
input.viewportHeight > 0 &&
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
const targetId = current?.targetId ?? input.candidateId; const canLatch = (input: SignControllerSample): input is SignControllerSample & {
if (targetId === null) return null; candidateId: SectionId;
candidateDistanceVh: number;
} =>
input.motionAllowed &&
input.source === 'passive-scroll' &&
input.direction === 1 &&
input.deltaY > 0 &&
input.deltaY <= input.viewportHeight * JUMP_DISTANCE_VH &&
input.crossedBoundaries <= 1 &&
input.candidateId !== null &&
input.candidateId !== input.activeSectionId &&
input.candidateDistanceVh !== null &&
input.candidateDistanceVh > 0 &&
input.candidateDistanceVh <= ELIGIBLE_DISTANCE_VH;
if (input.distanceVh > 0.55) { export function createSignController(): SignController {
return { let state: State = { kind: 'idle' };
targetId, let lastNow: number | null = null;
phase: "approach",
progress: clamp01((1.2 - input.distanceVh) / 0.65), const reset = (now?: number): void => {
}; state = { kind: 'idle' };
} lastNow = now !== undefined && Number.isFinite(now) ? now : null;
if (input.distanceVh > 0.15) {
return { targetId, phase: "stabilized", progress: 1 };
}
return {
targetId,
phase: "pass",
progress: clamp01((0.15 - input.distanceVh) / 0.4),
}; };
const enterCooldown = (now: number): void => {
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
};
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
if (progress >= 1) {
state = {
kind: 'cooldown',
until: visible.departStartedAt + DEPART_MS + COOLDOWN_MS,
};
return null;
}
return {
targetId: visible.targetId,
phase: 'depart',
progress,
opacity: 1 - smoothstep(0.1, 1, progress),
};
}
const elapsed = Math.max(0, now - visible.latchedAt);
if (elapsed < ANTICIPATE_MS) {
return {
targetId: visible.targetId,
phase: 'anticipate',
progress: clamp01(elapsed / ANTICIPATE_MS),
opacity: 0,
};
}
const approachElapsed = elapsed - ANTICIPATE_MS;
if (approachElapsed < visible.approachDuration) {
const progress = clamp01(approachElapsed / visible.approachDuration);
return {
targetId: visible.targetId,
phase: 'approach',
progress,
opacity: smoothstep(0, 0.42, progress),
};
}
return {
targetId: visible.targetId,
phase: 'dwell',
progress: 1,
opacity: 1,
};
};
const sample = (input: SignControllerSample): SignSnapshot | null => {
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
reset();
return null;
}
lastNow = input.now;
const suppressed =
!input.motionAllowed ||
input.source !== 'passive-scroll' ||
input.crossedBoundaries > 1 ||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
if (suppressed) {
if (!input.motionAllowed) reset(input.now);
else if (state.kind === 'visible') enterCooldown(input.now);
return null;
}
if (state.kind === 'cooldown') {
if (input.now < state.until) return null;
state = { kind: 'idle' };
}
if (state.kind === 'idle') {
if (!canLatch(input)) return null;
state = {
kind: 'visible',
targetId: input.candidateId,
latchedAt: input.now,
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
departStartedAt: null,
};
return snapshotVisible(state, input.now);
}
if (input.direction < 0) {
const elapsed = input.now - state.latchedAt;
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
if (elapsed < ANTICIPATE_MS + state.approachDuration) {
state = { kind: 'idle' };
return null;
}
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
state.departStartedAt = input.now;
}
}
if (state.departStartedAt === null) {
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
if (dwellComplete && input.activeSectionId === state.targetId) {
state.departStartedAt = input.now;
}
}
return snapshotVisible(state, input.now);
};
const needsFrame = (): boolean => {
if (state.kind !== 'visible' || lastNow === null) return false;
if (state.departStartedAt !== null) return true;
return lastNow < state.latchedAt + ANTICIPATE_MS + state.approachDuration + DWELL_MS;
};
return { sample, reset, needsFrame };
} }

View File

@@ -30,10 +30,33 @@ export type EnvironmentChapter =
| "pre-dawn" | "pre-dawn"
| "destination"; | "destination";
export type SignPhase = 'anticipate' | 'approach' | 'dwell' | 'depart';
export interface SignSnapshot { export interface SignSnapshot {
targetId: SectionId; targetId: SectionId;
phase: "approach" | "stabilized" | "pass"; phase: SignPhase;
progress: number; progress: number;
opacity: number;
}
export interface SignControllerSample {
now: number;
source: JourneySource;
direction: -1 | 0 | 1;
acceleration: number;
deltaY: number;
viewportHeight: number;
crossedBoundaries: number;
candidateId: SectionId | null;
candidateDistanceVh: number | null;
activeSectionId: SectionId;
motionAllowed: boolean;
}
export interface SignController {
sample(input: SignControllerSample): SignSnapshot | null;
reset(now?: number): void;
needsFrame(): boolean;
} }
export interface JourneySnapshot extends ScrollDriveSnapshot { export interface JourneySnapshot extends ScrollDriveSnapshot {

View File

@@ -573,6 +573,7 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
targetId: snapshot.sign.targetId, targetId: snapshot.sign.targetId,
phase: snapshot.sign.phase, phase: snapshot.sign.phase,
progress: finite(snapshot.sign.progress), progress: finite(snapshot.sign.progress),
opacity: finite(snapshot.sign.opacity),
} : null, } : null,
arrived: Boolean(snapshot.arrived), arrived: Boolean(snapshot.arrived),
}; };

View File

@@ -68,11 +68,18 @@ export function createSign(resources: SceneResourceBag) {
return { return {
group, group,
update(sign: SignSnapshot | null) { update(sign: SignSnapshot | null) {
group.visible = sign !== null; group.visible = sign !== null && sign.opacity > 0;
if (!sign) return; if (!sign) return;
updateTexture(sign.targetId); updateTexture(sign.targetId);
const phaseOffset = sign.phase === 'approach' ? -26 : sign.phase === 'stabilized' ? -5 : 8; material.opacity = sign.opacity;
group.position.set(7.2, 4.1, phaseOffset + sign.progress * 8); const phaseOffset = sign.phase === 'anticipate'
? -30
: sign.phase === 'approach'
? -26 + sign.progress * 21
: sign.phase === 'dwell'
? -5
: -5 + sign.progress * 18;
group.position.set(7.2, 4.1, phaseOffset);
group.rotation.y = -0.12; group.rotation.y = -0.12;
}, },
}; };

View File

@@ -214,7 +214,10 @@ describe('scene controller lifecycle', () => {
const harness = createHarness(); const harness = createHarness();
const controller = createSceneController(harness.options); const controller = createSceneController(harness.options);
await controller.start(); await controller.start();
const input = journey({ routeProgress: 0.25, sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2 } }); const input = journey({
routeProgress: 0.25,
sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2, opacity: 0.5 },
});
controller.update(input); controller.update(input);
input.routeProgress = 0.9; input.routeProgress = 0.9;

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { createSSRApp, defineComponent, h, nextTick } from 'vue'; import { createSSRApp, defineComponent, h, nextTick, shallowRef } from 'vue';
import { renderToString } from '@vue/server-renderer'; import { renderToString } from '@vue/server-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createScrollDriveController } from '@/journey/scroll-drive-controller'; import { createScrollDriveController } from '@/journey/scroll-drive-controller';
@@ -170,7 +170,7 @@ describe('useScrollDrive', () => {
const app = createSSRApp(defineComponent({ const app = createSSRApp(defineComponent({
setup() { setup() {
const drive = useScrollDrive(); const drive = useScrollDrive();
return () => h('output', drive.value.driveSpeed.toString()); return () => h('output', drive.snapshot.value.driveSpeed.toString());
}, },
})); }));
@@ -207,13 +207,14 @@ describe('useScrollDrive', () => {
const documentAdd = vi.spyOn(document, 'addEventListener'); const documentAdd = vi.spyOn(document, 'addEventListener');
const viewportAdd = vi.spyOn(visualViewport, 'addEventListener'); const viewportAdd = vi.spyOn(visualViewport, 'addEventListener');
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({ const wrapper = mount(defineComponent({
setup() { setup() {
return { drive: useScrollDrive() }; binding = useScrollDrive();
return () => h('output', binding.snapshot.value.routeProgress.toString());
}, },
template: '<output>{{ drive.routeProgress }}</output>',
})); }));
const drive = () => wrapper.vm.drive; const drive = () => binding.snapshot.value;
expect(drive()).toMatchObject({ expect(drive()).toMatchObject({
scrollY: 250, scrollY: 250,
@@ -306,4 +307,117 @@ describe('useScrollDrive', () => {
document.dispatchEvent(new Event('visibilitychange')); document.dispatchEvent(new Event('visibilitychange'));
expect(callbacks).toHaveLength(0); expect(callbacks).toHaveLength(0);
}); });
it('keeps the shared frame bus alive only while a subscriber requests frames', async () => {
const { useScrollDrive } = await import('@/composables/useScrollDrive');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 500 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
vi.spyOn(performance, 'now').mockReturnValue(100);
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.driveSpeed.toString());
},
}));
let subscriberFrames = 0;
const unsubscribe = binding.subscribeFrame((_now, frameSnapshot) => {
expect(frameSnapshot).toEqual(binding.snapshot.value);
subscriberFrames += 1;
return subscriberFrames < 3;
});
binding.requestFrame();
for (const now of [116, 132, 148]) {
expect(callbacks).toHaveLength(1);
const requested = [...callbacks.entries()][0]!;
callbacks.delete(requested[0]);
requested[1](now);
await nextTick();
}
expect(subscriberFrames).toBe(3);
expect(callbacks).toHaveLength(0);
unsubscribe();
binding.requestFrame();
expect(callbacks).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
it('owns the only raw window scroll listener used by the complete journey binding', async () => {
const { useJourneySections } = await import('@/composables/useJourneySections');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 2000 },
clientHeight: { configurable: true, value: 800 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
vi.spyOn(performance, 'now').mockReturnValue(0);
const add = vi.spyOn(window, 'addEventListener');
const ids = ['start', 'profile', 'contact'] as const;
const navigation = shallowRef({
epoch: 0,
source: 'passive-scroll' as const,
destinationId: null,
});
const motionAllowed = shallowRef(true);
const wrapper = mount(defineComponent({
setup() {
useJourneySections(ids, navigation, motionAllowed);
return () => h('main', ids.map((id) => h('section', { id }, [
h('h2', { id: `${id}-heading` }, id),
])));
},
}), { attachTo: document.body });
expect(add.mock.calls.filter(([type]) => type === 'scroll')).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
}); });

View File

@@ -1,100 +1,176 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest';
import { nextSign } from "@/journey/sign-controller"; import { createSignController } from '@/journey/sign-controller';
import type { SignSnapshot } from "@/journey/types"; import type { SignControllerSample } from '@/journey/types';
const passiveSample = ( const sample = (
distanceVh: number, overrides: Partial<SignControllerSample> = {},
overrides: Partial<Parameters<typeof nextSign>[1]> = {}, ): SignControllerSample => ({
): Parameters<typeof nextSign>[1] => ({ now: 0,
source: "passive-scroll", source: 'passive-scroll',
deltaY: 10, direction: 1,
viewportHeight: 100, acceleration: 0,
deltaY: 16,
viewportHeight: 800,
crossedBoundaries: 0, crossedBoundaries: 0,
candidateId: "profile", candidateId: 'profile',
distanceVh, candidateDistanceVh: 1,
activeSectionId: 'start',
motionAllowed: true, motionAllowed: true,
...overrides, ...overrides,
}); });
describe("forward-only sign latching", () => { describe('clock-driven sign lifecycle', () => {
it("latches a first eligible candidate at the exact approach boundary", () => { it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
expect(nextSign(null, passiveSample(1.2))).toEqual({ const controller = createSignController();
targetId: "profile",
phase: "approach", expect(controller.sample(sample())).toEqual({
targetId: 'profile',
phase: 'anticipate',
progress: 0,
opacity: 0,
});
expect(controller.needsFrame()).toBe(true);
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
phase: 'anticipate',
progress: expect.closeTo(299 / 300, 10),
});
expect(controller.sample(sample({ now: 300, direction: 0 }))).toMatchObject({
phase: 'approach',
progress: 0, progress: 0,
}); });
}); expect(controller.sample(sample({ now: 1_200, direction: 0 }))).toMatchObject({
phase: 'dwell',
it("maps the literal approach and pass ranges independently", () => { progress: 1,
expect(nextSign(null, passiveSample(0.875))).toEqual({ opacity: 1,
targetId: "profile",
phase: "approach",
progress: expect.closeTo(0.5, 14),
}); });
expect(nextSign(null, passiveSample(-0.05))).toEqual({ expect(controller.sample(sample({ now: 2_599, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
targetId: "profile", phase: 'dwell',
phase: "pass",
progress: 0.5,
}); });
}); expect(controller.sample(sample({ now: 2_600, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
phase: 'depart',
it("uses exact stabilized, pass, and completion boundaries", () => {
const latched: SignSnapshot = {
targetId: "profile",
phase: "approach",
progress: 0, progress: 0,
}; opacity: 1,
});
expect(controller.sample(sample({ now: 3_299, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
phase: 'depart',
progress: expect.closeTo(699 / 700, 10),
});
expect(controller.sample(sample({ now: 3_300, direction: 0, activeSectionId: 'profile' }))).toBeNull();
expect(controller.needsFrame()).toBe(false);
expect(controller.sample(sample({
now: 3_649,
candidateId: 'experience',
activeSectionId: 'profile',
}))).toBeNull();
expect(controller.sample(sample({
now: 3_650,
candidateId: 'experience',
activeSectionId: 'profile',
}))).toMatchObject({ targetId: 'experience', phase: 'anticipate' });
});
expect(nextSign(latched, passiveSample(0.55))).toEqual({ it('shortens approach continuously to 650ms at full acceleration', () => {
targetId: "profile", const controller = createSignController();
phase: "stabilized", controller.sample(sample({ acceleration: 1 }));
expect(controller.sample(sample({ now: 300, direction: 0 }))).toMatchObject({
phase: 'approach',
progress: 0,
});
expect(controller.sample(sample({ now: 949, direction: 0 }))).toMatchObject({
phase: 'approach',
progress: expect.closeTo(649 / 650, 10),
});
expect(controller.sample(sample({ now: 950, direction: 0 }))).toMatchObject({
phase: 'dwell',
progress: 1, progress: 1,
}); });
expect(nextSign(latched, passiveSample(0.15))).toEqual({
targetId: "profile",
phase: "pass",
progress: 0,
});
expect(nextSign(latched, passiveSample(-0.25))).toBeNull();
}); });
it("retains the one latched target while the semantic section advances", () => { it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
const latched: SignSnapshot = { const controller = createSignController();
targetId: "profile", controller.sample(sample());
phase: "stabilized", controller.sample(sample({ now: 1_200, direction: 0 }));
progress: 1,
};
expect( expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
nextSign(latched, passiveSample(0.1, { candidateId: "experience" })), expect(controller.needsFrame()).toBe(false);
).toEqual({ expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
targetId: "profile", phase: 'depart',
phase: "pass", progress: 0,
progress: expect.closeTo(0.125, 14), });
expect(controller.needsFrame()).toBe(true);
});
it('cancels an unreadable sign on reverse and departs a readable sign cleanly', () => {
const early = createSignController();
early.sample(sample());
expect(early.sample(sample({ now: 500, direction: -1, deltaY: -20 }))).toBeNull();
expect(early.needsFrame()).toBe(false);
const readable = createSignController();
readable.sample(sample());
readable.sample(sample({ now: 1_200, direction: 0 }));
expect(readable.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
phase: 'depart',
progress: 0,
}); });
}); });
it.each([ it.each([
["reverse input", { deltaY: -2 }], ['explicit navigation', { source: 'explicit-navigation' as const }],
["zero-direction input", { deltaY: 0 }], ['history restoration', { source: 'history-restoration' as const }],
["explicit navigation", { source: "explicit-navigation" as const }], ['initial positioning', { source: 'initial' as const }],
["history restoration", { source: "history-restoration" as const }], ['multiple crossed sections', { crossedBoundaries: 2 }],
["initial positioning", { source: "initial" as const }], ['a jump over 0.75 viewport heights', { deltaY: 601 }],
["more than one activation boundary", { crossedBoundaries: 2 }], ['disabled motion', { motionAllowed: false }],
["a jump over 0.75 viewport heights", { deltaY: 76 }], ])('suppresses or clears signs for %s', (_label, overrides) => {
["disabled motion", { motionAllowed: false }], const controller = createSignController();
])("clears or suppresses signs for %s", (_label, overrides) => { controller.sample(sample());
const latched: SignSnapshot = {
targetId: "profile", expect(controller.sample(sample({ now: 400, ...overrides }))).toBeNull();
phase: "approach", expect(controller.needsFrame()).toBe(false);
progress: 0.5,
};
expect(nextSign(latched, passiveSample(0.5, overrides))).toBeNull();
}); });
it("does not latch before visibility or after a jump directly past completion", () => { it('does not impose cooldown when an idle initial sample precedes real passive input', () => {
expect(nextSign(null, passiveSample(1.20001))).toBeNull(); const controller = createSignController();
expect(nextSign(null, passiveSample(-0.25))).toBeNull();
expect(nextSign(null, passiveSample(-1))).toBeNull(); expect(controller.sample(sample({ source: 'initial' }))).toBeNull();
expect(controller.sample(sample({ now: 100 }))).toMatchObject({
targetId: 'profile',
phase: 'anticipate',
});
});
it.each([
['reverse input', { direction: -1 as const, deltaY: -1 }],
['stationary input', { direction: 0 as const, deltaY: 0 }],
['candidate before the approach window', { candidateDistanceVh: 1.200_001 }],
['candidate already passed', { candidateDistanceVh: 0 }],
['candidate is already active', { activeSectionId: 'profile' as const }],
['missing candidate', { candidateId: null }],
])('does not latch for %s', (_label, overrides) => {
const controller = createSignController();
expect(controller.sample(sample(overrides))).toBeNull();
});
it('keeps one target latched when the next candidate changes', () => {
const controller = createSignController();
controller.sample(sample());
expect(controller.sample(sample({
now: 600,
direction: 0,
candidateId: 'experience',
}))).toMatchObject({ targetId: 'profile', phase: 'approach' });
});
it('resets malformed samples to a finite idle state', () => {
const controller = createSignController();
controller.sample(sample());
expect(controller.sample(sample({ now: Number.NaN }))).toBeNull();
expect(controller.needsFrame()).toBe(false);
controller.reset();
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
}); });
}); });