feat: drive signs from a shared animation clock
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user