diff --git a/src/composables/useJourneySections.ts b/src/composables/useJourneySections.ts index 0495ee6..1546036 100644 --- a/src/composables/useJourneySections.ts +++ b/src/composables/useJourneySections.ts @@ -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> { 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(initialJourney(firstId)); const snapshot = computed(() => ({ ...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(); }; }); diff --git a/src/composables/useScrollDrive.ts b/src/composables/useScrollDrive.ts index a3e9f33..e3bca41 100644 --- a/src/composables/useScrollDrive.ts +++ b/src/composables/useScrollDrive.ts @@ -12,9 +12,23 @@ const initialSnapshot = (): ScrollDriveSnapshot => ({ driveSpeed: 0.18, }); -export function useScrollDrive(): Readonly> { +export interface ScrollDriveBinding { + snapshot: Readonly>; + requestFrame(): void; + subscribeFrame( + listener: (now: number, snapshot: ScrollDriveSnapshot) => boolean, + ): () => void; +} + +export function useScrollDrive(): ScrollDriveBinding { const controller = createScrollDriveController(); const snapshot = shallowRef(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> { }; }; + 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> { 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> { 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); + }, + }; } diff --git a/src/journey/sign-controller.ts b/src/journey/sign-controller.ts index ff77238..792ce42 100644 --- a/src/journey/sign-controller.ts +++ b/src/journey/sign-controller.ts @@ -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" && - 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; +const smoothstep = (minimum: number, maximum: number, value: number): number => { + const progress = clamp01((value - minimum) / (maximum - minimum)); + return progress * progress * (3 - 2 * progress); +}; - if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) { - return null; - } +const isFiniteSample = (input: SignControllerSample): boolean => + 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; - if (targetId === null) return null; +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; - if (input.distanceVh > 0.55) { - return { - targetId, - phase: "approach", - progress: clamp01((1.2 - input.distanceVh) / 0.65), - }; - } - if (input.distanceVh > 0.15) { - return { targetId, phase: "stabilized", progress: 1 }; - } - return { - targetId, - phase: "pass", - progress: clamp01((0.15 - input.distanceVh) / 0.4), +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; + } + + 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 }; } diff --git a/src/journey/types.ts b/src/journey/types.ts index 864eff9..3e8745f 100644 --- a/src/journey/types.ts +++ b/src/journey/types.ts @@ -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 { diff --git a/src/scene/create-scene-controller.ts b/src/scene/create-scene-controller.ts index dd0911c..2dd6b8c 100644 --- a/src/scene/create-scene-controller.ts +++ b/src/scene/create-scene-controller.ts @@ -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), }; diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts index 6d1ab34..a21db85 100644 --- a/src/scene/world/sign.ts +++ b/src/scene/world/sign.ts @@ -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; }, }; diff --git a/tests/unit/scene-lifecycle.test.ts b/tests/unit/scene-lifecycle.test.ts index 15cee27..35a1afa 100644 --- a/tests/unit/scene-lifecycle.test.ts +++ b/tests/unit/scene-lifecycle.test.ts @@ -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; diff --git a/tests/unit/scroll-drive-controller.test.ts b/tests/unit/scroll-drive-controller.test.ts index ab6693c..2d72ddb 100644 --- a/tests/unit/scroll-drive-controller.test.ts +++ b/tests/unit/scroll-drive-controller.test.ts @@ -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; const wrapper = mount(defineComponent({ setup() { - return { drive: useScrollDrive() }; + binding = useScrollDrive(); + return () => h('output', binding.snapshot.value.routeProgress.toString()); }, - template: '{{ drive.routeProgress }}', })); - 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(); + 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; + 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(); + 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); + }); }); diff --git a/tests/unit/sign-controller.test.ts b/tests/unit/sign-controller.test.ts index 5a2351f..112c60e 100644 --- a/tests/unit/sign-controller.test.ts +++ b/tests/unit/sign-controller.test.ts @@ -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[1]> = {}, -): Parameters[1] => ({ - source: "passive-scroll", - deltaY: 10, - viewportHeight: 100, +const sample = ( + overrides: Partial = {}, +): 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, }); - }); - - 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: 1_200, direction: 0 }))).toMatchObject({ + phase: 'dwell', + progress: 1, + opacity: 1, }); - expect(nextSign(null, passiveSample(-0.05))).toEqual({ - targetId: "profile", - phase: "pass", - progress: 0.5, + expect(controller.sample(sample({ now: 2_599, direction: 0, activeSectionId: 'profile' }))).toMatchObject({ + phase: 'dwell', }); - }); - - 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", - progress: 0, - }); - expect(nextSign(latched, passiveSample(-0.25))).toBeNull(); }); - it("retains the one latched target while the semantic section advances", () => { - const latched: SignSnapshot = { - targetId: "profile", - phase: "stabilized", - progress: 1, - }; + 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( - nextSign(latched, passiveSample(0.1, { candidateId: "experience" })), - ).toEqual({ - targetId: "profile", - phase: "pass", - progress: expect.closeTo(0.125, 14), + 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(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([ - ["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' }); }); });