feat: drive signs from a shared animation clock

This commit is contained in:
2026-09-02 14:14:18 +03:00
parent 24afda8710
commit f2dd3f85dd
9 changed files with 615 additions and 265 deletions

View File

@@ -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 };
}

View File

@@ -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 {