fix: make nightdrive signage reliable and legible

This commit is contained in:
2026-09-03 02:34:15 +03:00
parent a03d4d8dba
commit cce018d3cb
39 changed files with 4635 additions and 196 deletions

View File

@@ -4,33 +4,21 @@ import type {
SignControllerSample,
SignSnapshot,
} from '@/journey/types';
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;
const CONCEALED_OPACITY = 0.02;
const SECTION_ORDER: readonly SectionId[] = [
'start',
'profile',
'experience',
'infrastructure',
'selected-builds',
'additional-work',
'toolkit',
'contact',
];
import {
SIGN_LIFECYCLE,
SIGN_ROUTE_ORDER,
signRouteFor,
signViewportProfileFor,
type SignViewportProfile,
} from '@/journey/sign-routes';
interface VisibleState {
kind: 'visible';
targetId: SectionId;
latchedAt: number;
approachDuration: number;
departureRequested: boolean;
viewportProfile: SignViewportProfile;
dwellStartedAt: number | null;
departStartedAt: number | null;
generation: number;
}
@@ -51,56 +39,44 @@ const isFiniteSample = (input: SignControllerSample): boolean =>
Number.isFinite(input.now) &&
Number.isFinite(input.acceleration) &&
Number.isFinite(input.deltaY) &&
(input.viewportWidth === undefined || (
Number.isFinite(input.viewportWidth) && input.viewportWidth > 0
)) &&
Number.isFinite(input.viewportHeight) &&
Number.isFinite(input.crossedBoundaries) &&
input.viewportHeight > 0 &&
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
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;
let generation = 0;
let pendingTargetId: SectionId | null = null;
const completedTargets = new Set<SectionId>();
const reset = (now?: number): void => {
generation += 1;
state = { kind: 'idle' };
pendingTargetId = null;
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
};
const enterCooldown = (now: number): void => {
generation += 1;
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
};
const latch = (
input: SignControllerSample & {
candidateId: SectionId;
candidateDistanceVh: number;
},
targetId: SectionId,
now: number,
acceleration: number,
viewportWidth: number,
viewportHeight: number,
): VisibleState => {
generation += 1;
return {
kind: 'visible',
targetId: input.candidateId,
latchedAt: input.now,
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
departureRequested: false,
targetId,
latchedAt: now,
approachDuration: SIGN_LIFECYCLE.approachMs
- SIGN_LIFECYCLE.approachReductionMs * clamp01(acceleration),
viewportProfile: signViewportProfileFor(viewportWidth, viewportHeight),
dwellStartedAt: null,
departStartedAt: null,
generation,
};
@@ -115,27 +91,25 @@ export function createSignController(): SignController {
const requestDeparture = (
visible: VisibleState,
current: SignSnapshot,
now: number,
): void => {
const dwellCompleteAt = visible.latchedAt
+ ANTICIPATE_MS
+ visible.approachDuration
+ DWELL_MS;
if (current.phase === 'dwell' && now < dwellCompleteAt) {
visible.departureRequested = true;
return;
}
if (
visible.dwellStartedAt === null
|| now < visible.dwellStartedAt + SIGN_LIFECYCLE.dwellMs
) return;
beginDeparture(visible, now);
};
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
if (progress >= 1) {
if (visible.dwellStartedAt !== null) completedTargets.add(visible.targetId);
state = {
kind: 'cooldown',
until: visible.departStartedAt + DEPART_MS + COOLDOWN_MS,
until: visible.departStartedAt
+ SIGN_LIFECYCLE.departMs
+ SIGN_LIFECYCLE.cooldownMs,
};
return null;
}
@@ -144,20 +118,22 @@ export function createSignController(): SignController {
phase: 'depart',
progress,
opacity: 1 - smoothstep(0.1, 1, progress),
viewportProfile: visible.viewportProfile,
};
}
const elapsed = Math.max(0, now - visible.latchedAt);
if (elapsed < ANTICIPATE_MS) {
if (elapsed < SIGN_LIFECYCLE.anticipateMs) {
return {
targetId: visible.targetId,
phase: 'anticipate',
progress: clamp01(elapsed / ANTICIPATE_MS),
progress: clamp01(elapsed / SIGN_LIFECYCLE.anticipateMs),
opacity: 0,
viewportProfile: visible.viewportProfile,
};
}
const approachElapsed = elapsed - ANTICIPATE_MS;
const approachElapsed = elapsed - SIGN_LIFECYCLE.anticipateMs;
if (approachElapsed < visible.approachDuration) {
const progress = clamp01(approachElapsed / visible.approachDuration);
return {
@@ -165,17 +141,46 @@ export function createSignController(): SignController {
phase: 'approach',
progress,
opacity: smoothstep(0, 0.42, progress),
viewportProfile: visible.viewportProfile,
};
}
if (visible.dwellStartedAt === null) visible.dwellStartedAt = now;
return {
targetId: visible.targetId,
phase: 'dwell',
progress: 1,
opacity: 1,
viewportProfile: visible.viewportProfile,
};
};
const canonicalCandidate = (input: SignControllerSample): SectionId | null => {
if (
input.direction !== 1
|| input.deltaY <= 0
|| input.source !== 'passive-scroll'
|| !input.motionAllowed
) return null;
if (input.crossedBoundaries > 0) {
const route = signRouteFor(input.activeSectionId);
return route.eligible && !completedTargets.has(route.id) ? route.id : null;
}
if (
input.candidateId === null
|| input.candidateId === input.activeSectionId
|| input.candidateDistanceVh === null
|| input.candidateDistanceVh <= 0
) return null;
const route = signRouteFor(input.candidateId);
return route.eligible
&& input.candidateDistanceVh <= route.triggerMaximumVh
&& !completedTargets.has(route.id)
? route.id
: null;
};
const sample = (input: SignControllerSample): SignSnapshot | null => {
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
reset();
@@ -184,85 +189,113 @@ export function createSignController(): SignController {
lastNow = input.now;
if (!input.motionAllowed || input.source !== 'passive-scroll') {
if (input.source !== 'passive-scroll') {
const route = signRouteFor(input.activeSectionId);
if (route.eligible) completedTargets.add(route.id);
}
reset(input.now);
return null;
}
const candidate = canonicalCandidate(input);
const discontinuous =
input.crossedBoundaries > 1 ||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
if (discontinuous) {
if (state.kind !== 'visible') return null;
const current = snapshotVisible(state, input.now);
if (current === null || current.opacity <= CONCEALED_OPACITY) {
enterCooldown(input.now);
return null;
}
requestDeparture(state, current, input.now);
return snapshotVisible(state, input.now);
}
input.crossedBoundaries > 1
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75;
if (state.kind === 'cooldown') {
if (candidate !== null) pendingTargetId = candidate;
if (input.now < state.until) return null;
state = { kind: 'idle' };
if (pendingTargetId !== null && !completedTargets.has(pendingTargetId)) {
const targetId = pendingTargetId;
pendingTargetId = null;
state = latch(
targetId,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
);
return snapshotVisible(state, input.now);
}
}
if (state.kind === 'idle') {
if (!canLatch(input)) return null;
state = latch(input);
if (candidate === null) return null;
state = latch(
candidate,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
);
return snapshotVisible(state, input.now);
}
if (candidate !== null && candidate !== state.targetId) {
pendingTargetId = candidate;
}
const current = snapshotVisible(state, input.now);
if (current === null) return null;
if (
current.opacity <= CONCEALED_OPACITY
&& canLatch(input)
&& input.candidateId !== state.targetId
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
&& candidate !== null
&& candidate !== state.targetId
) {
state = latch(input);
pendingTargetId = null;
state = latch(
candidate,
input.now,
input.acceleration,
input.viewportWidth ?? input.viewportHeight * (16 / 9),
input.viewportHeight,
);
return snapshotVisible(state, input.now);
}
const activeIndex = SECTION_ORDER.indexOf(input.activeSectionId);
const targetIndex = SECTION_ORDER.indexOf(state.targetId);
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
if (targetPassed && state.departStartedAt === null) {
if (current.opacity <= CONCEALED_OPACITY) {
enterCooldown(input.now);
if (discontinuous && state.departStartedAt === null) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
}
requestDeparture(state, current, input.now);
requestDeparture(state, input.now);
return snapshotVisible(state, input.now);
}
const activeIndex = SIGN_ROUTE_ORDER.indexOf(input.activeSectionId);
const targetIndex = SIGN_ROUTE_ORDER.indexOf(state.targetId);
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
if (targetPassed && state.departStartedAt === null) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
}
requestDeparture(state, input.now);
return snapshotVisible(state, input.now);
}
if (input.direction < 0) {
if (current.opacity <= CONCEALED_OPACITY) {
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
state = { kind: 'idle' };
return null;
}
if (state.departStartedAt === null) requestDeparture(state, current, input.now);
if (state.departStartedAt === null) requestDeparture(state, input.now);
}
if (state.departStartedAt === null) {
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
if (
dwellComplete
&& (state.departureRequested || input.activeSectionId === state.targetId)
) {
beginDeparture(state, input.now);
}
const dwellStartedAt = state.dwellStartedAt;
const dwellComplete = dwellStartedAt !== null
&& input.now >= dwellStartedAt + SIGN_LIFECYCLE.dwellMs;
if (dwellComplete) beginDeparture(state, 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;
if (state.kind === 'cooldown') return pendingTargetId !== null;
return state.kind === 'visible' && lastNow !== null;
};
return { sample, reset, needsFrame };

View File

@@ -0,0 +1,75 @@
import type { SectionId } from '@/content/types';
export type SignCorridor = 'left' | 'right' | 'outer-left' | 'outer-right';
export type SignViewportProfile = 'wide' | 'tall' | 'short';
export interface SignRouteSpec {
id: SectionId;
label: string;
marker: string;
eligible: boolean;
corridor: SignCorridor | null;
triggerMaximumVh: number;
}
export const SIGN_LIFECYCLE = Object.freeze({
anticipateMs: 300,
approachMs: 900,
approachReductionMs: 250,
dwellMs: 1_400,
departMs: 700,
cooldownMs: 350,
concealedOpacity: 0.02,
});
export const SIGN_ROUTE_ORDER: readonly SectionId[] = [
'start',
'profile',
'experience',
'infrastructure',
'selected-builds',
'additional-work',
'toolkit',
'contact',
];
const routeSpecs: Readonly<Record<SectionId, SignRouteSpec>> = Object.freeze({
start: {
id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0,
},
profile: {
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
},
experience: {
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
},
infrastructure: {
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
},
'selected-builds': {
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
},
'additional-work': {
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
},
toolkit: {
id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
},
contact: {
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
},
});
export function signRouteFor(id: SectionId): SignRouteSpec {
return routeSpecs[id];
}
export function signViewportProfileFor(
width: number,
height: number,
): SignViewportProfile {
const safeWidth = Number.isFinite(width) ? Math.max(1, width) : 1;
const safeHeight = Number.isFinite(height) ? Math.max(1, height) : 1;
const aspect = safeWidth / safeHeight;
return safeHeight < 560 && aspect >= 1 ? 'short' : aspect < 1.05 ? 'tall' : 'wide';
}

View File

@@ -1,4 +1,5 @@
import type { SectionId } from "@/content/types";
import type { SignViewportProfile } from '@/journey/sign-routes';
export interface ScrollDriveSample {
scrollY: number;
@@ -38,6 +39,7 @@ export interface SignSnapshot {
phase: SignPhase;
progress: number;
opacity: number;
viewportProfile?: SignViewportProfile;
}
export interface SignControllerSample {
@@ -46,6 +48,7 @@ export interface SignControllerSample {
direction: -1 | 0 | 1;
acceleration: number;
deltaY: number;
viewportWidth?: number;
viewportHeight: number;
crossedBoundaries: number;
candidateId: SectionId | null;