fix: make nightdrive signage reliable and legible
This commit is contained in:
@@ -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 };
|
||||
|
||||
75
src/journey/sign-routes.ts
Normal file
75
src/journey/sign-routes.ts
Normal 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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user