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
|
||||
? (): void => {}
|
||||
: watch(motionAllowed, () => scheduleMeasurement());
|
||||
const stopMotionWatch = motionAllowed === undefined
|
||||
? (): void => {}
|
||||
: 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
|
||||
? null
|
||||
: nextSign(currentSign, {
|
||||
source: navigationState.value.source,
|
||||
deltaY,
|
||||
viewportHeight,
|
||||
crossedBoundaries,
|
||||
candidateId: signTargetId,
|
||||
distanceVh: (signTarget.top - activationY) / viewportHeight,
|
||||
motionAllowed: motionAllowed?.value ?? true,
|
||||
});
|
||||
const candidateId = canonicalIds[activeIndex + 1] ?? null;
|
||||
const candidateHeading = headings.find((heading) => heading.id === candidateId);
|
||||
const candidateDistanceVh = candidateHeading === undefined || viewportHeight <= 0
|
||||
? null
|
||||
: (candidateHeading.top - activationY) / viewportHeight;
|
||||
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 = {
|
||||
...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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user