Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue';
|
|
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
|
|
import type { ScrollDriveSample, ScrollDriveSnapshot } from '@/journey/types';
|
|
|
|
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
|
|
|
const initialSnapshot = (): ScrollDriveSnapshot => ({
|
|
scrollY: 0,
|
|
routeProgress: 0,
|
|
direction: 0,
|
|
acceleration: 0,
|
|
driveSpeed: 0.18,
|
|
});
|
|
|
|
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(() => {
|
|
let frameId: number | undefined;
|
|
let resetOnNextFrame = false;
|
|
const visualViewport = window.visualViewport;
|
|
|
|
const readSample = (now: number): ScrollDriveSample => {
|
|
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
|
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
|
|
return {
|
|
scrollY: scrollingElement.scrollTop,
|
|
now,
|
|
maxScrollY: Math.max(0, scrollingElement.scrollHeight - viewportHeight),
|
|
viewportHeight,
|
|
};
|
|
};
|
|
|
|
let scheduleFrame = (): void => {};
|
|
|
|
const runFrame = (now: number): void => {
|
|
frameId = undefined;
|
|
const input = readSample(now);
|
|
const nextSnapshot = resetOnNextFrame
|
|
? controller.reset(input)
|
|
: controller.sample(input);
|
|
snapshot.value = nextSnapshot;
|
|
resetOnNextFrame = false;
|
|
let keepRunning = false;
|
|
for (const subscriber of [...subscribers]) {
|
|
keepRunning = subscriber(now, nextSnapshot) || keepRunning;
|
|
}
|
|
if (nextSnapshot.acceleration > 0 || keepRunning) scheduleFrame();
|
|
};
|
|
|
|
scheduleFrame = (): void => {
|
|
if (frameId === undefined && document.visibilityState !== 'hidden') {
|
|
frameId = requestAnimationFrame(runFrame);
|
|
}
|
|
};
|
|
requestFrame = scheduleFrame;
|
|
|
|
const handleVisibilityChange = (): void => {
|
|
if (document.visibilityState === 'hidden') {
|
|
resetOnNextFrame = true;
|
|
if (frameId !== undefined) {
|
|
cancelAnimationFrame(frameId);
|
|
frameId = undefined;
|
|
}
|
|
snapshot.value = controller.reset();
|
|
return;
|
|
}
|
|
resetOnNextFrame = true;
|
|
scheduleFrame();
|
|
};
|
|
|
|
snapshot.value = controller.reset(readSample(performance.now()));
|
|
window.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
|
window.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
|
visualViewport?.addEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
|
|
visualViewport?.addEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
|
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);
|
|
visualViewport?.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
|
|
if (frameId !== undefined) {
|
|
cancelAnimationFrame(frameId);
|
|
frameId = undefined;
|
|
}
|
|
subscribers.clear();
|
|
};
|
|
});
|
|
|
|
onUnmounted(() => teardown());
|
|
|
|
return {
|
|
snapshot: readonly(snapshot),
|
|
requestFrame: () => requestFrame(),
|
|
subscribeFrame(listener) {
|
|
if (disposed) return () => {};
|
|
subscribers.add(listener);
|
|
return () => subscribers.delete(listener);
|
|
},
|
|
};
|
|
}
|