feat: drive signs from a shared animation clock
This commit is contained in:
@@ -6,19 +6,19 @@ import {
|
||||
shallowRef,
|
||||
watch,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { NavigationState } from "@/journey/navigation-controller";
|
||||
} 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";
|
||||
} from '@/journey/section-controller';
|
||||
import { createSignController } from '@/journey/sign-controller';
|
||||
import type { JourneySnapshot, ScrollDriveSnapshot } from '@/journey/types';
|
||||
import { useScrollDrive } from '@/composables/useScrollDrive';
|
||||
|
||||
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
||||
|
||||
@@ -39,61 +39,67 @@ export function useJourneySections(
|
||||
): Readonly<Ref<JourneySnapshot>> {
|
||||
const firstId = canonicalIds[0];
|
||||
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 signController = createSignController();
|
||||
const sectionSnapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
|
||||
const snapshot = computed<JourneySnapshot>(() => ({
|
||||
...sectionSnapshot.value,
|
||||
...drive.value,
|
||||
routeProgress: sectionSnapshot.value.arrived ? 1 : drive.value.routeProgress,
|
||||
...drive.snapshot.value,
|
||||
routeProgress: sectionSnapshot.value.arrived
|
||||
? 1
|
||||
: drive.snapshot.value.routeProgress,
|
||||
}));
|
||||
let scheduleMeasurement = (): void => {};
|
||||
let requestJourneyFrame = (): void => {};
|
||||
let teardown = (): void => {};
|
||||
|
||||
const stopNavigationWatch = watch(
|
||||
() => navigationState.value,
|
||||
() => scheduleMeasurement(),
|
||||
() => requestJourneyFrame(),
|
||||
);
|
||||
const stopMotionWatch =
|
||||
motionAllowed === undefined
|
||||
const stopMotionWatch = motionAllowed === undefined
|
||||
? (): void => {}
|
||||
: watch(motionAllowed, () => scheduleMeasurement());
|
||||
: watch(motionAllowed, () => requestJourneyFrame());
|
||||
|
||||
onMounted(() => {
|
||||
const visualViewport = window.visualViewport;
|
||||
let frameId: number | undefined;
|
||||
let disposed = false;
|
||||
let previousScrollY = window.scrollY;
|
||||
let structureDirty = true;
|
||||
let previousScrollY = drive.snapshot.value.scrollY;
|
||||
let previousActiveId = firstId;
|
||||
let headings: readonly SectionHeadingGeometry[] = [];
|
||||
|
||||
const readHeadings = (): readonly SectionHeadingGeometry[] =>
|
||||
const readHeadings = (scrollY: number): readonly SectionHeadingGeometry[] =>
|
||||
canonicalIds.flatMap((id) => {
|
||||
const heading = document.getElementById(`${id}-heading`);
|
||||
return heading instanceof HTMLElement
|
||||
? [{ id, top: heading.getBoundingClientRect().top + window.scrollY }]
|
||||
? [{ id, top: heading.getBoundingClientRect().top + scrollY }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const measure = (): void => {
|
||||
frameId = undefined;
|
||||
headings = readHeadings();
|
||||
if (headings.length === 0) return;
|
||||
const measure = (
|
||||
now: number,
|
||||
driveSnapshot: ScrollDriveSnapshot,
|
||||
): boolean => {
|
||||
if (disposed) return false;
|
||||
if (structureDirty) {
|
||||
headings = readHeadings(driveSnapshot.scrollY);
|
||||
structureDirty = false;
|
||||
}
|
||||
if (headings.length === 0) return false;
|
||||
|
||||
const scrollingElement =
|
||||
document.scrollingElement ?? document.documentElement;
|
||||
const viewportHeight =
|
||||
visualViewport?.height ?? document.documentElement.clientHeight;
|
||||
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,
|
||||
scrollY: driveSnapshot.scrollY,
|
||||
visualViewportOffsetTop: viewportOffsetTop,
|
||||
visualViewportHeight: viewportHeight,
|
||||
});
|
||||
const atDocumentEnd = isAtDocumentEnd({
|
||||
scrollY,
|
||||
scrollY: driveSnapshot.scrollY,
|
||||
scrollHeight: scrollingElement.scrollHeight,
|
||||
visualViewportHeight: viewportHeight,
|
||||
});
|
||||
@@ -105,47 +111,46 @@ export function useJourneySections(
|
||||
const previousActiveIndex = canonicalIds.indexOf(previousActiveId);
|
||||
const activeIndex = canonicalIds.indexOf(activeSectionId);
|
||||
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
|
||||
const deltaY = scrollY - previousScrollY;
|
||||
const currentSign = sectionSnapshot.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
|
||||
const candidateId = canonicalIds[activeIndex + 1] ?? null;
|
||||
const candidateHeading = headings.find((heading) => heading.id === candidateId);
|
||||
const candidateDistanceVh = candidateHeading === undefined || viewportHeight <= 0
|
||||
? null
|
||||
: nextSign(currentSign, {
|
||||
: (candidateHeading.top - activationY) / viewportHeight;
|
||||
const sectionState = deriveSectionState(activeSectionId);
|
||||
const sign = signController.sample({
|
||||
now,
|
||||
source: navigationState.value.source,
|
||||
deltaY,
|
||||
direction: driveSnapshot.direction,
|
||||
acceleration: driveSnapshot.acceleration,
|
||||
deltaY: driveSnapshot.scrollY - previousScrollY,
|
||||
viewportHeight,
|
||||
crossedBoundaries,
|
||||
candidateId: signTargetId,
|
||||
distanceVh: (signTarget.top - activationY) / viewportHeight,
|
||||
candidateId,
|
||||
candidateDistanceVh,
|
||||
activeSectionId,
|
||||
motionAllowed: motionAllowed?.value ?? true,
|
||||
});
|
||||
const sectionState = deriveSectionState(activeSectionId);
|
||||
|
||||
sectionSnapshot.value = {
|
||||
...drive.value,
|
||||
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
|
||||
...driveSnapshot,
|
||||
routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
|
||||
...sectionState,
|
||||
sign,
|
||||
};
|
||||
previousScrollY = scrollY;
|
||||
previousScrollY = driveSnapshot.scrollY;
|
||||
previousActiveId = activeSectionId;
|
||||
return signController.needsFrame();
|
||||
};
|
||||
|
||||
scheduleMeasurement = (): void => {
|
||||
if (
|
||||
!disposed &&
|
||||
frameId === undefined &&
|
||||
document.visibilityState !== "hidden"
|
||||
) {
|
||||
frameId = window.requestAnimationFrame(measure);
|
||||
}
|
||||
const removeFrameSubscriber = drive.subscribeFrame(measure);
|
||||
requestJourneyFrame = drive.requestFrame;
|
||||
const requestMeasurement = (): void => drive.requestFrame();
|
||||
const markStructureDirty = (): void => {
|
||||
structureDirty = true;
|
||||
requestMeasurement();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleMeasurement);
|
||||
const resizeObserver = new ResizeObserver(markStructureDirty);
|
||||
resizeObserver.observe(document.documentElement);
|
||||
for (const id of canonicalIds) {
|
||||
const section = document.getElementById(id);
|
||||
@@ -153,89 +158,43 @@ export function useJourneySections(
|
||||
if (section !== null) resizeObserver.observe(section);
|
||||
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);
|
||||
}
|
||||
|
||||
const handleVisibility = (): void => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
if (frameId !== undefined) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
if (document.visibilityState === 'hidden') {
|
||||
signController.reset();
|
||||
return;
|
||||
}
|
||||
previousScrollY = window.scrollY;
|
||||
scheduleMeasurement();
|
||||
previousScrollY = drive.snapshot.value.scrollY;
|
||||
markStructureDirty();
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
window.addEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
|
||||
window.addEventListener('orientationchange', markStructureDirty, PASSIVE_LISTENER);
|
||||
visualViewport?.addEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
|
||||
visualViewport?.addEventListener('scroll', requestMeasurement, PASSIVE_LISTENER);
|
||||
document.addEventListener('load', markStructureDirty, true);
|
||||
document.addEventListener('loadedmetadata', markStructureDirty, true);
|
||||
document.addEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
|
||||
|
||||
scheduleMeasurement();
|
||||
void document.fonts.ready.then(() => scheduleMeasurement());
|
||||
markStructureDirty();
|
||||
void document.fonts?.ready.then(markStructureDirty);
|
||||
|
||||
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,
|
||||
);
|
||||
requestJourneyFrame = (): void => {};
|
||||
removeFrameSubscriber();
|
||||
window.removeEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
|
||||
window.removeEventListener('orientationchange', markStructureDirty, PASSIVE_LISTENER);
|
||||
visualViewport?.removeEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
|
||||
visualViewport?.removeEventListener('scroll', requestMeasurement, PASSIVE_LISTENER);
|
||||
document.removeEventListener('load', markStructureDirty, true);
|
||||
document.removeEventListener('loadedmetadata', markStructureDirty, true);
|
||||
document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
|
||||
resizeObserver.disconnect();
|
||||
if (frameId !== undefined) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
signController.reset();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,23 @@ const initialSnapshot = (): ScrollDriveSnapshot => ({
|
||||
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 snapshot = shallowRef<ScrollDriveSnapshot>(initialSnapshot());
|
||||
const subscribers = new Set<(
|
||||
now: number,
|
||||
snapshot: ScrollDriveSnapshot,
|
||||
) => boolean>();
|
||||
let requestFrame = (): void => {};
|
||||
let disposed = false;
|
||||
let teardown = (): void => {};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -32,21 +46,29 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
|
||||
};
|
||||
};
|
||||
|
||||
let scheduleFrame = (): void => {};
|
||||
|
||||
const runFrame = (now: number): void => {
|
||||
frameId = undefined;
|
||||
const input = readSample(now);
|
||||
snapshot.value = resetOnNextFrame
|
||||
const nextSnapshot = resetOnNextFrame
|
||||
? controller.reset(input)
|
||||
: controller.sample(input);
|
||||
snapshot.value = nextSnapshot;
|
||||
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') {
|
||||
frameId = requestAnimationFrame(runFrame);
|
||||
}
|
||||
};
|
||||
requestFrame = scheduleFrame;
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
@@ -70,6 +92,8 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
|
||||
|
||||
teardown = (): void => {
|
||||
disposed = true;
|
||||
requestFrame = (): void => {};
|
||||
window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
||||
window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
||||
visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
||||
@@ -79,10 +103,19 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
subscribers.clear();
|
||||
};
|
||||
});
|
||||
|
||||
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 { JourneySource, SignSnapshot } from "./types";
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type {
|
||||
SignController,
|
||||
SignControllerSample,
|
||||
SignSnapshot,
|
||||
} from '@/journey/types';
|
||||
|
||||
export interface SignSample {
|
||||
source: JourneySource;
|
||||
deltaY: number;
|
||||
viewportHeight: number;
|
||||
crossedBoundaries: number;
|
||||
candidateId: SectionId | null;
|
||||
distanceVh: number;
|
||||
motionAllowed: boolean;
|
||||
const ANTICIPATE_MS = 300;
|
||||
const APPROACH_MS = 900;
|
||||
const APPROACH_REDUCTION_MS = 250;
|
||||
const DWELL_MS = 1_400;
|
||||
const DEPART_MS = 700;
|
||||
const COOLDOWN_MS = 350;
|
||||
const ELIGIBLE_DISTANCE_VH = 1.2;
|
||||
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));
|
||||
|
||||
export function nextSign(
|
||||
current: SignSnapshot | null,
|
||||
input: SignSample,
|
||||
): SignSnapshot | null {
|
||||
const eligible =
|
||||
input.motionAllowed &&
|
||||
input.source === "passive-scroll" &&
|
||||
const smoothstep = (minimum: number, maximum: number, value: number): number => {
|
||||
const progress = clamp01((value - minimum) / (maximum - minimum));
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
};
|
||||
|
||||
const isFiniteSample = (input: SignControllerSample): boolean =>
|
||||
Number.isFinite(input.now) &&
|
||||
Number.isFinite(input.acceleration) &&
|
||||
Number.isFinite(input.deltaY) &&
|
||||
Number.isFinite(input.viewportHeight) &&
|
||||
Number.isFinite(input.distanceVh) &&
|
||||
Number.isFinite(input.crossedBoundaries) &&
|
||||
input.viewportHeight > 0 &&
|
||||
input.deltaY > 0 &&
|
||||
input.deltaY <= input.viewportHeight * 0.75 &&
|
||||
input.crossedBoundaries <= 1;
|
||||
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
|
||||
|
||||
if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) {
|
||||
const canLatch = (input: SignControllerSample): input is SignControllerSample & {
|
||||
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;
|
||||
|
||||
export function createSignController(): SignController {
|
||||
let state: State = { kind: 'idle' };
|
||||
let lastNow: number | null = null;
|
||||
|
||||
const reset = (now?: number): void => {
|
||||
state = { kind: 'idle' };
|
||||
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const targetId = current?.targetId ?? input.candidateId;
|
||||
if (targetId === null) return null;
|
||||
if (state.kind === 'cooldown') {
|
||||
if (input.now < state.until) return null;
|
||||
state = { kind: 'idle' };
|
||||
}
|
||||
|
||||
if (input.distanceVh > 0.55) {
|
||||
return {
|
||||
targetId,
|
||||
phase: "approach",
|
||||
progress: clamp01((1.2 - input.distanceVh) / 0.65),
|
||||
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.distanceVh > 0.15) {
|
||||
return { targetId, phase: "stabilized", progress: 1 };
|
||||
|
||||
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;
|
||||
}
|
||||
return {
|
||||
targetId,
|
||||
phase: "pass",
|
||||
progress: clamp01((0.15 - input.distanceVh) / 0.4),
|
||||
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"
|
||||
| "destination";
|
||||
|
||||
export type SignPhase = 'anticipate' | 'approach' | 'dwell' | 'depart';
|
||||
|
||||
export interface SignSnapshot {
|
||||
targetId: SectionId;
|
||||
phase: "approach" | "stabilized" | "pass";
|
||||
phase: SignPhase;
|
||||
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 {
|
||||
|
||||
@@ -573,6 +573,7 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
||||
targetId: snapshot.sign.targetId,
|
||||
phase: snapshot.sign.phase,
|
||||
progress: finite(snapshot.sign.progress),
|
||||
opacity: finite(snapshot.sign.opacity),
|
||||
} : null,
|
||||
arrived: Boolean(snapshot.arrived),
|
||||
};
|
||||
|
||||
@@ -68,11 +68,18 @@ export function createSign(resources: SceneResourceBag) {
|
||||
return {
|
||||
group,
|
||||
update(sign: SignSnapshot | null) {
|
||||
group.visible = sign !== null;
|
||||
group.visible = sign !== null && sign.opacity > 0;
|
||||
if (!sign) return;
|
||||
updateTexture(sign.targetId);
|
||||
const phaseOffset = sign.phase === 'approach' ? -26 : sign.phase === 'stabilized' ? -5 : 8;
|
||||
group.position.set(7.2, 4.1, phaseOffset + sign.progress * 8);
|
||||
material.opacity = sign.opacity;
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -214,7 +214,10 @@ describe('scene controller lifecycle', () => {
|
||||
const harness = createHarness();
|
||||
const controller = createSceneController(harness.options);
|
||||
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);
|
||||
input.routeProgress = 0.9;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
|
||||
@@ -170,7 +170,7 @@ describe('useScrollDrive', () => {
|
||||
const app = createSSRApp(defineComponent({
|
||||
setup() {
|
||||
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 viewportAdd = vi.spyOn(visualViewport, 'addEventListener');
|
||||
|
||||
let binding!: ReturnType<typeof useScrollDrive>;
|
||||
const wrapper = mount(defineComponent({
|
||||
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({
|
||||
scrollY: 250,
|
||||
@@ -306,4 +307,117 @@ describe('useScrollDrive', () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
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 { nextSign } from "@/journey/sign-controller";
|
||||
import type { SignSnapshot } from "@/journey/types";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSignController } from '@/journey/sign-controller';
|
||||
import type { SignControllerSample } from '@/journey/types';
|
||||
|
||||
const passiveSample = (
|
||||
distanceVh: number,
|
||||
overrides: Partial<Parameters<typeof nextSign>[1]> = {},
|
||||
): Parameters<typeof nextSign>[1] => ({
|
||||
source: "passive-scroll",
|
||||
deltaY: 10,
|
||||
viewportHeight: 100,
|
||||
const sample = (
|
||||
overrides: Partial<SignControllerSample> = {},
|
||||
): SignControllerSample => ({
|
||||
now: 0,
|
||||
source: 'passive-scroll',
|
||||
direction: 1,
|
||||
acceleration: 0,
|
||||
deltaY: 16,
|
||||
viewportHeight: 800,
|
||||
crossedBoundaries: 0,
|
||||
candidateId: "profile",
|
||||
distanceVh,
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 1,
|
||||
activeSectionId: 'start',
|
||||
motionAllowed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("forward-only sign latching", () => {
|
||||
it("latches a first eligible candidate at the exact approach boundary", () => {
|
||||
expect(nextSign(null, passiveSample(1.2))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
describe('clock-driven sign lifecycle', () => {
|
||||
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
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,
|
||||
});
|
||||
expect(controller.sample(sample({ now: 1_200, direction: 0 }))).toMatchObject({
|
||||
phase: 'dwell',
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
it("maps the literal approach and pass ranges independently", () => {
|
||||
expect(nextSign(null, passiveSample(0.875))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: expect.closeTo(0.5, 14),
|
||||
expect(controller.sample(sample({ now: 2_599, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
|
||||
phase: 'dwell',
|
||||
});
|
||||
expect(nextSign(null, passiveSample(-0.05))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "pass",
|
||||
progress: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses exact stabilized, pass, and completion boundaries", () => {
|
||||
const latched: SignSnapshot = {
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
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({
|
||||
targetId: "profile",
|
||||
phase: "stabilized",
|
||||
it('shortens approach continuously to 650ms at full acceleration', () => {
|
||||
const controller = createSignController();
|
||||
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,
|
||||
});
|
||||
expect(nextSign(latched, passiveSample(0.15))).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "pass",
|
||||
});
|
||||
|
||||
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0 }));
|
||||
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
expect(nextSign(latched, passiveSample(-0.25))).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the one latched target while the semantic section advances", () => {
|
||||
const latched: SignSnapshot = {
|
||||
targetId: "profile",
|
||||
phase: "stabilized",
|
||||
progress: 1,
|
||||
};
|
||||
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);
|
||||
|
||||
expect(
|
||||
nextSign(latched, passiveSample(0.1, { candidateId: "experience" })),
|
||||
).toEqual({
|
||||
targetId: "profile",
|
||||
phase: "pass",
|
||||
progress: expect.closeTo(0.125, 14),
|
||||
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([
|
||||
["reverse input", { deltaY: -2 }],
|
||||
["zero-direction input", { deltaY: 0 }],
|
||||
["explicit navigation", { source: "explicit-navigation" as const }],
|
||||
["history restoration", { source: "history-restoration" as const }],
|
||||
["initial positioning", { source: "initial" as const }],
|
||||
["more than one activation boundary", { crossedBoundaries: 2 }],
|
||||
["a jump over 0.75 viewport heights", { deltaY: 76 }],
|
||||
["disabled motion", { motionAllowed: false }],
|
||||
])("clears or suppresses signs for %s", (_label, overrides) => {
|
||||
const latched: SignSnapshot = {
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: 0.5,
|
||||
};
|
||||
expect(nextSign(latched, passiveSample(0.5, overrides))).toBeNull();
|
||||
['explicit navigation', { source: 'explicit-navigation' as const }],
|
||||
['history restoration', { source: 'history-restoration' as const }],
|
||||
['initial positioning', { source: 'initial' as const }],
|
||||
['multiple crossed sections', { crossedBoundaries: 2 }],
|
||||
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
||||
['disabled motion', { motionAllowed: false }],
|
||||
])('suppresses or clears signs for %s', (_label, overrides) => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
|
||||
expect(controller.sample(sample({ now: 400, ...overrides }))).toBeNull();
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not latch before visibility or after a jump directly past completion", () => {
|
||||
expect(nextSign(null, passiveSample(1.20001))).toBeNull();
|
||||
expect(nextSign(null, passiveSample(-0.25))).toBeNull();
|
||||
expect(nextSign(null, passiveSample(-1))).toBeNull();
|
||||
it('does not impose cooldown when an idle initial sample precedes real passive input', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
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