feat: mile-marker posts travel with the road and carry the route log

This commit is contained in:
2026-10-11 15:48:49 +03:00
parent 1326cee886
commit 388289f2fe
11 changed files with 783 additions and 267 deletions

View File

@@ -102,3 +102,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Budgets:** initial JS 115.43 KB gz (from 119.08: IconGlyph draws the six Phosphor glyphs from their regular-weight paths instead of importing the package components, which ship all six weights each), CSS 26.93 KB gz (+0.24), scene chunk 222.95 KB gz (+1.6), DashcamFrame 3.74 KB gz. 925 tests (79 files), violations []. - **Budgets:** initial JS 115.43 KB gz (from 119.08: IconGlyph draws the six Phosphor glyphs from their regular-weight paths instead of importing the package components, which ship all six weights each), CSS 26.93 KB gz (+0.24), scene chunk 222.95 KB gz (+1.6), DashcamFrame 3.74 KB gz. 925 tests (79 files), violations [].
- **Left:** relay lamp cores (a saturated violet cannot pass the luminance bloom threshold, so a violet core loses its glow); mast girth at 1024/phones (bounded by the panel-margin layout test); cold-GPU first frame; underpass pier vs billboard edge; yard lens slide and far-stack lean. Headless hardware-GL Chromium loses its GPU process when a project video starts (SharedImage BGRA failure), also at ca12cfd; not an app fault. - **Left:** relay lamp cores (a saturated violet cannot pass the luminance bloom threshold, so a violet core loses its glow); mast girth at 1024/phones (bounded by the panel-margin layout test); cold-GPU first frame; underpass pier vs billboard edge; yard lens slide and far-stack lean. Headless hardware-GL Chromium loses its GPU process when a project video starts (SharedImage BGRA failure), also at ca12cfd; not an app fault.
- Screenshots: docs/overnight/polish/ (WEBP). - Screenshots: docs/overnight/polish/ (WEBP).
## Polish: mile markers ride the road (owner feedback)
- **What:** the kilometre posts were parked at scroll-mapped z and stood still while idle ("way too static... half implemented"). They are now road furniture on a fixed 60 m lattice (4 posts, one InstancedMesh) driven by the drive's travel distance, like the rails, delineators and pylons, so they stream past at idle cruise and speed up with the drive. Plain posts carry a small dark plate with an accent rule and a lit three-digit kilometre number (digit strip in a canvas atlas). The route log rides the lattice: a post still in the haze (z < -165) is handed the next entry when, from the smoothed scroll rate and road speed, it would reach the pass line about when that entry's plate crosses the activation line, or straight away once the reader has stopped (0.4 s) on an entry it has not passed. That feature post gets a large dark plate (accent band "LOG 02/04 · EXP", the dates "JUN→AUG 2025", the organisation code "CCF", lettered from the page's inert input snapshot so nothing is added to the initial bundle), a lamp head that warms up as it approaches, an accent-tinted pool that sweeps the shoulder and lane as it passes, a +8% bass swell, and a soft glint at the pass line.
- **Rules kept:** the road never runs backwards; scrolling back re-arms entries and their posts come up the road again. Predictions only reach the next entry either way and wait 0.3 s after a chapter change and 0.6 s after a sweep, so a navigator jump hands nothing out mid-flight; no glint while sweeping, at most one every 0.6 s. Chapter changes are linear 0.35/0.25 s screen-door ramps (no pop; Profile's left-shoulder posts fade as Experience's right-shoulder posts arrive). Plates stay under the bloom threshold; only the lamp blooms when close. Low tier/touch: no beam, pools, pulse or glint. Reduced motion and SSR untouched.
- **Budgets:** initial JS 115.54 kB gz by Vite (+13 B gz: three more export bindings from route-log for the scene), scene chunk 225.05 kB gz (+2.1). 932 tests (79 files), violations [].
- **Review (Sonnet, hardware GL):** per-frame allocations removed, non-finite distance guarded; featured plates now hang on a 4.2 m arm over the road (clears traffic at ~6.3 m) so the Experience plate is fully in frame at 1440.
- **Left:** the first entry of a chapter can arrive late when the reader starts scrolling right at the heading (transit from the haze is ~6-10 s at cruise).
- Screenshots: docs/overnight/polish/posts-*.webp (before/after, idle contact strips before and after, feature plate zoom).

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -183,8 +183,8 @@ export async function createProceduralSceneGeneration(
// the star drain (uniform writes); the warm rims and the bloom change are // the star drain (uniform writes); the warm rims and the bloom change are
// high/medium fine-pointer only. // high/medium fine-pointer only.
const dawnRims = maintenanceTier === 'full'; const dawnRims = maintenanceTier === 'full';
// MILE MARKERS: kilometre posts roll past through Experience and Profile. // MILE MARKERS: kilometre posts stream past on the free shoulder through
// Low tier and touch draw them without beam, pulse or flash. // Profile and Experience. Low tier and touch: no beam, pools, pulse or glint.
const milestones = createMilestones(context.resources, maintenanceTier === 'full'); const milestones = createMilestones(context.resources, maintenanceTier === 'full');
// DRIVE-IN: the Selected builds billboard. Low tier and touch: one steady // DRIVE-IN: the Selected builds billboard. Low tier and touch: one steady
// flood, no pulse, flicker or hover zoom, 512 px screens. // flood, no pulse, flicker or hover zoom, 512 px screens.
@@ -425,14 +425,6 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm; targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
} }
districts.setServiceState(service.dim, service.relight); districts.setServiceState(service.dim, service.relight);
milestones.update(
snapshot.activeSectionId,
isMilestoneSection(snapshot.activeSectionId)
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
: null,
musicPulse,
deltaSeconds,
);
// The deck from the previous frame (underpass updates after the sign). // The deck from the previous frame (underpass updates after the sign).
const yardDeck = underpass.inspect(); const yardDeck = underpass.inspect();
yard.update( yard.update(
@@ -487,6 +479,17 @@ export async function createProceduralSceneGeneration(
snapshot.driveSpeed, snapshot.driveSpeed,
response.roadEnergy, response.roadEnergy,
); );
// MILE MARKERS ride the road: the posts move with the travel distance,
// the section-local progress only picks which entry the next one carries.
milestones.update({
section: snapshot.activeSectionId,
p: isMilestoneSection(snapshot.activeSectionId)
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
: null,
distance: driveDistance,
pulse: musicPulse,
deltaSeconds,
});
const visualSpeed = THREE.MathUtils.clamp( const visualSpeed = THREE.MathUtils.clamp(
0.18 + response.roadEnergy * 0.82, 0.18 + response.roadEnergy * 0.82,
0.18, 0.18,

View File

@@ -1,62 +1,98 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { SectionId } from '@/content/types'; import type { PortfolioContent, SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes'; import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { isMilestoneSection, MILESTONE_COUNTS, passPoint, type MilestoneSection } from '@/journey/route-log'; import {
fitCaption,
isMilestoneSection,
MILESTONE_COUNTS,
milestoneIndex,
milestonesFor,
passPoint,
type Milestone,
type MilestoneSection,
} from '@/journey/route-log';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import { BEAM_PARS, beamUniforms } from './headlights'; import { BEAM_PARS, beamUniforms } from './headlights';
import { WORLD_NEAR_Z } from './metrics';
/** /**
* MILE MARKERS, scene side. Each role (Experience) or screening fact * MILE MARKERS, scene side. Kilometre posts are road furniture: they stand
* (Profile) is a lit kilometre post on the shoulder of the side the reading * on a fixed lattice along the road and travel with the drive's distance,
* panel leaves free. Its position is a pure function of section-local scroll * like the rails, delineators and pylons, so they keep streaming past while
* progress, so scrolling back rolls the posts back. One InstancedMesh, one * the reader is still. Through Profile and Experience the free shoulder (the
* draw call: a dark post, a pale retro-reflective cap plate that catches the * side the reading panel leaves open) carries one every MILESTONE_SPACING
* high beam, and a chapter-accent emissive stripe that breathes +8% with the * metres: small dark plates with a lit kilometre number. The route log rides
* bass. A post flashes for 300 ms as its plate crosses the activation line, * on that lattice. When a post comes out of the haze far up the road, it is
* which is the moment it reaches the camera: the post is at its largest, at * given to the next log entry if it would reach the camera about when the
* the bottom edge of the frame, and the odometer ticks as it goes by. A second * entry's plate crosses the activation line (predicted from the scroll rate
* additive mesh lays a warm light pool on the shoulder under each post. * and the road speed), or straight away when the reader has stopped on an
* Low tier and touch draw the posts without beam, pulse or flash. * entry it has not passed yet. That feature post gets the large accent plate
*/ * (LOG nn/nn, the dates, the organisation), a lamp that warms up as it
/** * approaches, a light pool that sweeps over the lane as it goes by, a soft
* Where a post stands as its plate crosses the line. The camera sits at z ~25 * glint at the pass line and a +8% swell with the bass. Scrolling back never
* with the frame shifted toward the panel, so the free shoulder leaves the * runs the road backwards: the entries rewind and their posts come up the
* frame's side edge about 35 m ahead of the camera: the post goes by there, * road again. Posts fade in and out (screen door) at the chapter changes and
* at its largest, as the odometer ticks. * in the haze. Low tier and touch: no beam, pools, pulse or glint.
*/ */
/** Where a post goes by: the free shoulder leaves the frame about here. */
export const MILESTONE_PASS_Z = -12; export const MILESTONE_PASS_Z = -12;
/** Metres of road per unit of travel: the next post is RUN / count metres up the road. */ /** Metres between two posts, and how many stand on the road at once. */
export const MILESTONE_RUN = 200; export const MILESTONE_SPACING = 60;
export const MILESTONE_FAR_Z = MILESTONE_PASS_Z - MILESTONE_RUN / 2; export const MILESTONE_SLOTS = 4;
export const MILESTONE_NEAR_Z = MILESTONE_PASS_Z + MILESTONE_RUN / 2; /** A post leaves (wraps) here, behind the camera, and is born at the far end. */
export const MILESTONE_NEAR_Z = WORLD_NEAR_Z;
export const MILESTONE_FAR_Z = MILESTONE_NEAR_Z - MILESTONE_SPACING * MILESTONE_SLOTS;
/** Only a post this far up the road (still in the haze) can take a log entry. */
export const MILESTONE_ASSIGN_Z = -165;
/** The free shoulder: the Experience panel is on the left, Profile's on the right. */ /** The free shoulder: the Experience panel is on the left, Profile's on the right. */
export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 }; export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 };
/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */ /* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */
const POST_W = 0.4; const POST_W = 0.4;
const POST_H = 6.4; const POST_H = 6.4;
/** The plate hangs on an arm this far over the road (the post stays on the shoulder),
* so it stays in frame on the narrow side and clears traffic (4 m) by 2 m. */
const ARM = 4.2;
const PLATE_W = 2.2; const PLATE_W = 2.2;
const PLATE_H = 1.0; const PLATE_H = 1.0;
/** The pool of light each post throws on the shoulder (x across the road, z along it). */ /** The plain kilometre plate is drawn at this scale of the feature plate. */
const POOL_W = 8; const PLAIN_PLATE = 0.62;
const POOL_L = 14; /** The pool of light a feature post throws across the shoulder and the lane. */
const POOL_W = 13;
const POOL_L = 18;
/** Plates turn this far toward the road (rad). */ /** Plates turn this far toward the road (rad). */
const PLATE_YAW = 0.3; const PLATE_YAW = 0.3;
const FLASH_SECONDS = 0.3; const GLINT_SECONDS = 0.3;
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */ /** At most one glint every 0.6 s, whatever the speed (photosensitivity). */
const FLASH_GAP_SECONDS = 0.6; const GLINT_GAP_SECONDS = 0.6;
/** Smoothed section-local progress per second above which a crossing does not /** Smoothed section-local progress per second above which nothing glints and
* flash: a navigator jump or a fling sweeps several posts past in well under * no entry is handed out: a navigator jump or a fling. A wheel tick lands in
* a second, and their flashes stacked into one bright bloom frame on arrival. * one frame, so the rate is smoothed: it rises over ~80 ms and falls over ~400 ms. */
* A wheel tick lands in one frame, so the rate is smoothed: it rises over
* ~80 ms (a sweep is caught within three frames) and falls over ~400 ms. */
export const MILESTONE_FLASH_MAX_RATE = 0.8; export const MILESTONE_FLASH_MAX_RATE = 0.8;
const RATE_RISE_SECONDS = 0.08; const RATE_RISE_SECONDS = 0.08;
const RATE_FALL_SECONDS = 0.4; const RATE_FALL_SECONDS = 0.4;
/** Below this |progress/s| the reader counts as stopped on an entry. */
const IDLE_RATE = 0.012;
/** An entry is due once its activation falls within this share of a post's arrival. */
const DUE_TOLERANCE = 0.06;
/** An entry can come round again once the reader is this many shares from it. */
const REARM_SHARES = 0.6;
/** Chapter fades are linear ramps (screen door), so no frame jumps by more than ~7%. */
const PRESENCE_IN_SECONDS = 0.35;
const PRESENCE_OUT_SECONDS = 0.25;
/** The reader counts as stopped after this long below IDLE_RATE. */
const STILL_SECONDS = 0.4;
/** No entry is handed out this long after a chapter change or a sweep: the
* scroll rate is not a reading pace yet. */
const SETTLE_CHANGE_SECONDS = 0.3;
const SETTLE_SWEEP_SECONDS = 0.6;
const PULSE_GAIN = 0.08; const PULSE_GAIN = 0.08;
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
const PLATE_GLOW = 0.42;
const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[]; const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[];
const INSTANCES = SECTIONS.reduce((sum, id) => sum + MILESTONE_COUNTS[id], 0); /** Atlas: 2 x 4 feature cells (512 x 232) over a strip of ten digits. */
const CELL_W = 512;
const CELL_H = 232;
const ATLAS = 1024;
const DIGITS_Y = CELL_H * 4;
const CELL_BASE: Readonly<Record<MilestoneSection, number>> = { profile: 0, experience: MILESTONE_COUNTS.profile };
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const smooth = (from: number, to: number, value: number): number => { const smooth = (from: number, to: number, value: number): number => {
@@ -64,69 +100,279 @@ const smooth = (from: number, to: number, value: number): number => {
return t * t * (3 - 2 * t); return t * t * (3 - 2 * t);
}; };
/** Travel parameter of post i: 0.5 exactly when its plate is on the line. */ /** Slot k's place on the lattice at drive distance d: its z, and the post's
export const milestoneTravel = (p: number, index: number, count: number): number => * running number (one per post, rising along the route). */
p - passPoint(index, count) + 0.5; export function milestoneSlot(distance: number, slot: number): { z: number; id: number } {
const run = MILESTONE_SPACING * MILESTONE_SLOTS;
export const milestoneZ = (p: number, index: number, count: number): number => const along = distance + slot * MILESTONE_SPACING;
THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, milestoneTravel(p, index, count)); const lap = Math.floor(along / run);
return { z: MILESTONE_FAR_Z + (along - lap * run), id: lap * MILESTONE_SLOTS - slot };
/** The chapter fades the posts in and out at its edges. */ }
export const milestoneChapterFade = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
/** A post rises out of the haze far up the road and leaves once it is past the camera. */ /** A post rises out of the haze far up the road and leaves once it is past the camera. */
export const milestoneRangeFade = (z: number): number => smooth(-190, -130, z) * (1 - smooth(10, 22, z)); export const milestoneRangeFade = (z: number): number => smooth(-198, -140, z) * (1 - smooth(12, 28, z));
/** The lamp of a feature post warms up as it comes down the road. */
export const milestoneLamp = (z: number): number => smooth(-190, -50, z) * (1 - smooth(16, 30, z));
/** Kilometre number on a plain plate (three digits). */
export const milestoneNumber = (id: number): number => (((id + 128) % 1000) + 1000) % 1000;
export interface MilestonePost { export interface MilestonePost {
section: MilestoneSection;
index: number;
x: number;
z: number; z: number;
/** 0..1 screen-door cover: chapter edges and the far end of the run. */ x: number;
/** The post's chapter, or null while it is unclaimed (hidden). */
section: MilestoneSection | null;
/** The log entry it carries, -1 for a plain kilometre post. */
entry: number;
number: number;
/** 0..1 screen-door cover: chapter fades and the haze. */
presence: number; presence: number;
lamp: number;
glint: number;
} }
/** Every post's place for this frame; posts of other chapters are absent (presence 0). */ export interface MilestoneFrame {
export function milestoneLayout(section: SectionId, p: number | null): MilestonePost[] { section: SectionId;
const posts: MilestonePost[] = []; /** Section-local progress, null outside Profile and Experience. */
for (const id of SECTIONS) { p: number | null;
const count = MILESTONE_COUNTS[id]; /** The drive's travel distance (metres of road, never decreasing). */
for (let index = 0; index < count; index += 1) { distance: number;
const t = p === null ? 0 : milestoneTravel(p, index, count); /** Smoothed bass pulse, 0..1. */
const live = p !== null && id === section; pulse: number;
const z = THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, t); deltaSeconds: number;
posts.push({
section: id,
index,
x: MILESTONE_SIDE[id],
z,
presence: live ? milestoneChapterFade(p) * milestoneRangeFade(z) : 0,
});
} }
interface Slot {
id: number;
section: MilestoneSection | null;
entry: number;
chapter: number;
z: number;
}
/**
* The lattice and the route log, without three.js: which post carries which
* entry, its chapter fade, lamp and glint. Pure state, one step per frame.
*/
export function createMilestoneRoad(full: boolean) {
const slots: Slot[] = Array.from({ length: MILESTONE_SLOTS }, () => ({
id: Number.NaN, section: null, entry: -1, chapter: 0, z: MILESTONE_FAR_Z,
}));
const glints = new Float32Array(MILESTONE_SLOTS);
const armed = new Set<number>();
let armedFor: MilestoneSection | null = null;
let lastDistance: number | null = null;
let lastProgress: number | null = null;
let lastSection: SectionId | null = null;
let speed = 0;
let rate = 0;
let drift = 0;
let still = 0;
let settle = 0;
let sinceGlint = Number.POSITIVE_INFINITY;
// Entries carried by posts on the road, per frame (refilled, never reallocated).
const inFlight = new Set<number>();
// One record per slot, rewritten in place every frame.
const posts: MilestonePost[] = slots.map(() => ({
z: MILESTONE_FAR_Z, x: 0, section: null, entry: -1, number: 0, presence: 0, lamp: 0, glint: 0,
}));
const due = (slot: Slot, section: MilestoneSection, p: number): number => {
if (settle > 0) return -1;
const count = MILESTONE_COUNTS[section];
const share = 1 / count;
const open = (i: number): boolean => armed.has(i) && !inFlight.has(i);
// Where the reader will be when this post reaches the pass line.
const arrival = clamp01(p + drift * Math.min(30, (MILESTONE_PASS_Z - slot.z) / Math.max(speed, 1)));
const lo = Math.min(p, arrival) - DUE_TOLERANCE * share;
const hi = Math.max(p, arrival) + DUE_TOLERANCE * share;
let best = -1;
for (let i = 0; i < count; i += 1) {
const point = passPoint(i, count);
// At most the next entry either way: a prediction is only a reading pace.
if (open(i) && point >= lo && point <= hi && Math.abs(point - p) <= share
&& (best < 0 || Math.abs(point - arrival) < Math.abs(passPoint(best, count) - arrival))) best = i;
}
if (best >= 0) return best;
// Stopped on an entry whose post has not come by: send it now.
const here = milestoneIndex(p, count);
return still >= STILL_SECONDS && open(here) ? here : -1;
};
return {
posts: (): readonly MilestonePost[] => posts,
step({ section, p, distance, pulse, deltaSeconds }: MilestoneFrame): readonly MilestonePost[] {
const dt = Math.max(deltaSeconds, 1 / 240);
// A non-finite distance (a bad frame) keeps the road where it was.
if (!Number.isFinite(distance)) distance = lastDistance ?? 0;
const travelled = lastDistance === null ? 0 : Math.max(0, distance - lastDistance);
lastDistance = distance;
speed += (travelled / dt - speed) * Math.min(1, dt / 0.3);
const changed = section !== lastSection;
lastSection = section;
const delta = p !== null && lastProgress !== null && !changed ? p - lastProgress : 0;
lastProgress = p;
const instant = Math.abs(delta) / dt;
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
drift += (delta / dt - drift) * Math.min(1, dt / 0.25);
still = p !== null && !changed && instant < IDLE_RATE && Math.abs(drift) < IDLE_RATE ? still + deltaSeconds : 0;
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
settle = Math.max(changed ? SETTLE_CHANGE_SECONDS : sweeping ? SETTLE_SWEEP_SECONDS : 0, settle - deltaSeconds);
const live = p !== null && isMilestoneSection(section) ? section : null;
// Every entry is armed again once the reader is well away from it.
if (live !== armedFor) {
armed.clear();
armedFor = live;
}
inFlight.clear();
for (const s of slots) if (s.section === live && s.entry >= 0) inFlight.add(s.entry);
if (live !== null && p !== null) {
const count = MILESTONE_COUNTS[live];
for (let i = 0; i < count; i += 1) {
if (!inFlight.has(i) && Math.abs(p - passPoint(i, count)) > REARM_SHARES / count) armed.add(i);
}
// Entering mid-entry (a jump, a reload): that entry is due too.
if (changed || sweeping) armed.add(milestoneIndex(p, count));
}
sinceGlint += deltaSeconds;
for (let k = 0; k < slots.length; k += 1) {
const slot = slots[k]!;
const { z, id } = milestoneSlot(distance, k);
const crossed = slot.z < MILESTONE_PASS_Z && z >= MILESTONE_PASS_Z && slot.id === id;
if (id !== slot.id) {
// A new post at the far end of the lattice.
slot.id = id;
slot.section = live;
slot.entry = -1;
slot.chapter = live === null ? 0 : 1;
}
slot.z = z;
// An unclaimed or faded post that is still ahead joins the current chapter.
if (slot.section !== live && slot.chapter < 0.01 && z < MILESTONE_PASS_Z) {
slot.section = live;
slot.entry = -1;
}
const target = live !== null && slot.section === live ? 1 : 0;
const ramp = deltaSeconds / (target > slot.chapter ? PRESENCE_IN_SECONDS : PRESENCE_OUT_SECONDS);
slot.chapter = target > slot.chapter ? Math.min(target, slot.chapter + ramp) : Math.max(target, slot.chapter - ramp);
if (live !== null && p !== null && slot.section === live && slot.entry < 0 && z < MILESTONE_ASSIGN_Z) {
slot.entry = due(slot, live, p);
if (slot.entry >= 0) {
armed.delete(slot.entry);
inFlight.add(slot.entry);
}
}
const presence = slot.section === null ? 0 : slot.chapter * milestoneRangeFade(z);
const feature = slot.entry >= 0 && presence > 0;
if (full && feature && crossed && !sweeping && sinceGlint >= GLINT_GAP_SECONDS) {
glints[k] = 1;
sinceGlint = 0;
} else {
glints[k] = Math.max(0, glints[k]! - deltaSeconds / GLINT_SECONDS);
}
const swell = full ? 1 + PULSE_GAIN * clamp01(pulse) : 1;
const post = posts[k]!;
post.z = z;
post.x = slot.section === null ? 0 : MILESTONE_SIDE[slot.section];
post.section = slot.section;
post.entry = slot.entry;
post.number = milestoneNumber(id);
post.presence = presence;
post.lamp = feature ? milestoneLamp(z) * swell : 0;
post.glint = feature ? glints[k]! : 0;
} }
return posts; return posts;
},
};
} }
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number) { /**
const geometry = new THREE.BoxGeometry(width, height, depth); * The log entries, read once from the page's inert input snapshot (the same
* JSON the app hydrates from), so the plates need nothing from the initial
* bundle. Without it the plates still carry LOG nn/nn.
*/
const entries = new Map<MilestoneSection, readonly Milestone[]>();
export function milestoneEntries(section: MilestoneSection, source?: Document): readonly Milestone[] {
const cached = source === undefined ? entries.get(section) : undefined;
if (cached !== undefined) return cached;
let items: readonly Milestone[] = [];
try {
const text = (source ?? document).getElementById('portfolio-inputs')?.textContent ?? '';
if (text !== '') items = milestonesFor((JSON.parse(text) as { content: PortfolioContent }).content, section);
} catch {
items = [];
}
if (source === undefined) entries.set(section, items);
return items;
}
/** One feature plate: the log index on an accent band, the dates, the organisation. */
function drawFeaturePlate(context: CanvasRenderingContext2D, cell: number, section: MilestoneSection, item: Milestone, count: number) {
const x = (cell % 2) * CELL_W;
const y = Math.floor(cell / 2) * CELL_H;
const accent = SIGN_ACCENTS[section];
const fit = (text: string, size: number, weight: number, room: number): void => {
let px = size;
do {
context.font = `${weight} ${px}px "Geist Mono Variable", ui-monospace, monospace`;
px -= 2;
} while (px > 18 && context.measureText(text).width > room);
};
context.clearRect(x, y, CELL_W, CELL_H);
context.fillStyle = '#0c0815';
context.fillRect(x, y, CELL_W, CELL_H);
context.strokeStyle = accent;
context.lineWidth = 8;
context.strokeRect(x + 6, y + 6, CELL_W - 12, CELL_H - 12);
context.fillStyle = accent;
context.fillRect(x + 6, y + 6, CELL_W - 12, 50);
context.textBaseline = 'middle';
context.fillStyle = '#120a1d';
fit(`LOG ${String(item.index + 1).padStart(2, '0')}/${String(count).padStart(2, '0')}`, 34, 760, 300);
context.textAlign = 'left';
context.fillText(`LOG ${String(item.index + 1).padStart(2, '0')}/${String(count).padStart(2, '0')}`, x + 24, y + 33);
context.textAlign = 'right';
context.fillText(section === 'experience' ? 'EXP' : 'PRF', x + CELL_W - 24, y + 33);
context.textAlign = 'center';
const code = fitCaption(item.caption, 14);
const main = item.compactDate !== '' ? item.compactDate : code;
context.fillStyle = '#f1e8ff';
fit(main, 62, 720, CELL_W - 56);
context.fillText(main, x + CELL_W / 2, y + (main === code ? 140 : 118));
if (main !== code && code !== '') {
context.fillStyle = accent;
fit(code, 42, 680, CELL_W - 64);
context.fillText(code, x + CELL_W / 2, y + 186);
}
}
type Geometry = InstanceType<typeof THREE.BufferGeometry>;
function piece(geometry: Geometry, x: number, y: number, z: number, part: number): { geometry: Geometry; part: number } {
geometry.translate(x, y, z); geometry.translate(x, y, z);
return { geometry, part }; return { geometry, part };
} }
/** Post, plate and stripes merged into one indexed geometry with a part id. */ /** Post, plate, bands and lamp merged into one indexed geometry with a part id:
* 0 post, 1 plate face (atlas uv), 2 accent bands, 3 lamp, 4 plate body, 5 arm
* (a unit-length box along +x, the shader sends it toward the road). */
function postGeometry() { function postGeometry() {
const plateY = POST_H + PLATE_H / 2 - 0.12;
const pieces = [ const pieces = [
box(POST_W, POST_H, POST_W, 0, POST_H / 2, 0, 0), piece(new THREE.BoxGeometry(POST_W, POST_H, POST_W), 0, POST_H / 2, 0, 0),
// The number plate, framed top and bottom by the chapter's accent. piece(new THREE.BoxGeometry(PLATE_W + 0.12, PLATE_H + 0.12, 0.1), 0, plateY, 0.14, 4),
box(PLATE_W, PLATE_H, 0.08, 0, POST_H + PLATE_H / 2 - 0.12, 0.18, 1), piece(new THREE.PlaneGeometry(PLATE_W, PLATE_H), 0, plateY, 0.2, 1),
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H + PLATE_H - 0.06, 0.18, 2),
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H - 0.18, 0.18, 2),
// Two reflector bands down the post, like a real kilometre post. // Two reflector bands down the post, like a real kilometre post.
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.62, 0, 2), piece(new THREE.BoxGeometry(POST_W + 0.05, 0.42, POST_W + 0.05), 0, POST_H * 0.62, 0, 2),
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.3, 0, 2), piece(new THREE.BoxGeometry(POST_W + 0.05, 0.42, POST_W + 0.05), 0, POST_H * 0.3, 0, 2),
// A lamp head capping the post, lit on feature posts only.
piece(new THREE.BoxGeometry(0.62, 0.42, 0.62), 0, POST_H + PLATE_H + 0.24, 0.08, 3),
piece(new THREE.BoxGeometry(1, 0.22, 0.2), 0.5, POST_H + 0.02, 0.06, 5),
]; ];
const positions: number[] = []; const positions: number[] = [];
const normals: number[] = []; const normals: number[] = [];
const uvs: number[] = [];
const parts: number[] = []; const parts: number[] = [];
const indices: number[] = []; const indices: number[] = [];
for (const { geometry, part } of pieces) { for (const { geometry, part } of pieces) {
@@ -134,13 +380,15 @@ function postGeometry() {
const position = geometry.getAttribute('position'); const position = geometry.getAttribute('position');
positions.push(...position.array); positions.push(...position.array);
normals.push(...geometry.getAttribute('normal').array); normals.push(...geometry.getAttribute('normal').array);
uvs.push(...geometry.getAttribute('uv').array);
for (let i = 0; i < position.count; i += 1) parts.push(part); for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(geometry.getIndex().array as ArrayLike<number>, (i) => i + offset)); indices.push(...Array.from(geometry.getIndex()!.array as ArrayLike<number>, (i) => i + offset));
geometry.dispose(); geometry.dispose();
} }
const merged = new THREE.BufferGeometry(); const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
merged.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1)); merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices); merged.setIndex(indices);
return merged; return merged;
@@ -148,45 +396,100 @@ function postGeometry() {
export interface MilestonesWorld { export interface MilestonesWorld {
readonly mesh: { visible: boolean; count: number }; readonly mesh: { visible: boolean; count: number };
/** The additive light pools on the shoulder, one per post. */ /** The additive light pools of feature posts. */
readonly pool: { visible: boolean; count: number }; readonly pool: { visible: boolean; count: number };
/** Section-local progress (null outside the chapter) and the smoothed bass pulse (0..1). */ update(frame: MilestoneFrame): void;
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number): void; /** Test hook: this frame's posts. */
/** Test hook: the current flash level of each instance. */ posts(): readonly MilestonePost[];
flashes(): readonly number[];
} }
export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld { export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld {
const road = createMilestoneRoad(full);
const geometry = resources.own('geometry', postGeometry()); const geometry = resources.own('geometry', postGeometry());
// x: flash 0..1, y: presence 0..1. // x: glint, y: presence, z: lamp, w: chapter (0 profile, 1 experience).
const state = new THREE.InstancedBufferAttribute(new Float32Array(INSTANCES * 2), 2); const state = new THREE.InstancedBufferAttribute(new Float32Array(MILESTONE_SLOTS * 4), 4);
state.setUsage(THREE.DynamicDrawUsage); state.setUsage(THREE.DynamicDrawUsage);
// x: atlas cell of a feature plate (-1 plain), y: kilometre number.
const plate = new THREE.InstancedBufferAttribute(new Float32Array(MILESTONE_SLOTS * 2), 2);
plate.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('aState', state); geometry.setAttribute('aState', state);
const accent = new THREE.Color(SIGN_ACCENTS.experience); geometry.setAttribute('aPlate', plate);
const pulseUniform = { value: 0 };
const canvas = typeof document === 'undefined' ? null : document.createElement('canvas');
const context = canvas === null ? null : (canvas.getContext('2d') as CanvasRenderingContext2D | null);
if (canvas !== null) {
canvas.width = ATLAS;
canvas.height = ATLAS;
}
const atlas = resources.own('texture', new THREE.CanvasTexture(canvas as HTMLCanvasElement));
atlas.colorSpace = THREE.SRGBColorSpace;
atlas.anisotropy = 4;
if (context !== null) {
context.fillStyle = '#ffffff';
context.font = '700 84px "Geist Mono Variable", ui-monospace, monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
for (let digit = 0; digit < 10; digit += 1) {
context.fillText(String(digit), (digit + 0.5) * (ATLAS / 10), DIGITS_Y + (ATLAS - DIGITS_Y) / 2 + 4);
}
atlas.needsUpdate = true;
}
const lettered = new Map<number, string>();
const letter = (section: MilestoneSection, entry: number): number => {
const cell = CELL_BASE[section] + entry;
const items = milestoneEntries(section);
const item = items[entry] ?? { index: entry, dateLabel: '', compactDate: '', caption: '' };
const key = `${item.index}|${item.compactDate}|${item.caption}|${items.length}`;
if (context !== null && lettered.get(cell) !== key) {
lettered.set(cell, key);
drawFeaturePlate(context, cell, section, item, Math.max(items.length, MILESTONE_COUNTS[section]));
atlas.needsUpdate = true;
}
return cell;
};
const accents = [new THREE.Color(SIGN_ACCENTS.profile), new THREE.Color(SIGN_ACCENTS.experience)];
const material = resources.own('material', new THREE.ShaderMaterial({ const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform, ...(full ? beamUniforms : {}) }, uniforms: { uAccents: { value: accents }, uAtlas: { value: atlas }, ...(full ? beamUniforms : {}) },
vertexShader: /* glsl */ ` vertexShader: /* glsl */ `
attribute float aPart; attribute float aPart;
attribute vec2 aState; attribute vec4 aState;
attribute vec2 aPlate;
varying float vPart; varying float vPart;
varying vec2 vState; varying vec4 vState;
varying vec2 vPlate;
varying vec2 vUv;
varying vec3 vWorld; varying vec3 vWorld;
varying vec3 vNormal; varying vec3 vNormal;
void main() { void main() {
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); vec3 local = position;
// A plain kilometre post: a smaller plate and no lamp head.
if (aPlate.x < 0.0) {
float plateY = ${(POST_H + PLATE_H / 2 - 0.12).toFixed(3)};
if (aPart == 1.0 || aPart == 4.0) local.xy = vec2(local.x * ${PLAIN_PLATE.toFixed(2)}, plateY - 0.2 + (local.y - plateY) * ${PLAIN_PLATE.toFixed(2)});
if (aPart == 3.0) local *= 0.0;
}
// The plate hangs on an arm over the road, on the side the road lies.
float toRoad = aState.w > 0.5 ? -1.0 : 1.0;
if (aPart == 5.0) local.x *= ${ARM.toFixed(2)} * toRoad;
if (aPart == 1.0 || aPart == 4.0) local.x += ${ARM.toFixed(2)} * toRoad;
vec4 world = modelMatrix * instanceMatrix * vec4(local, 1.0);
vWorld = world.xyz; vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal); vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal);
vPart = aPart; vPart = aPart;
vState = aState; vState = aState;
vPlate = aPlate;
vUv = uv;
gl_Position = projectionMatrix * viewMatrix * world; gl_Position = projectionMatrix * viewMatrix * world;
} }
`, `,
fragmentShader: /* glsl */ ` fragmentShader: /* glsl */ `
uniform vec3 uAccent; uniform vec3 uAccents[2];
uniform float uPulse; uniform sampler2D uAtlas;
varying float vPart; varying float vPart;
varying vec2 vState; varying vec4 vState;
varying vec2 vPlate;
varying vec2 vUv;
varying vec3 vWorld; varying vec3 vWorld;
varying vec3 vNormal; varying vec3 vNormal;
${full ? BEAM_PARS : ''} ${full ? BEAM_PARS : ''}
@@ -195,27 +498,50 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0); vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0; float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0;
if (vState.y < threshold) discard; if (vState.y < threshold) discard;
vec3 accent = vState.w > 0.5 ? uAccents[1] : uAccents[0];
vec3 n = normalize(vNormal); vec3 n = normalize(vNormal);
vec3 toCamera = normalize(cameraPosition - vWorld); vec3 toCamera = normalize(cameraPosition - vWorld);
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35)))); float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
float facing = max(0.0, dot(n, toCamera)); float facing = max(0.0, dot(n, toCamera));
float flash = ${full ? 'vState.x' : '0.0'}; float lamp = vState.z;
float glint = vState.x;
float beam = ${full ? 'headlightBeam(vWorld)' : '0.0'};
bool feature = vPlate.x >= 0.0;
vec3 color; vec3 color;
if (vPart < 0.5) { if (vPart < 0.5 || vPart > 3.5) {
color = vec3(0.04, 0.034, 0.06) + vec3(0.09, 0.08, 0.13) * key + uAccent * 0.06 * facing; // Graphite post and plate body, rimmed by the chapter colour.
color = vec3(0.025, 0.02, 0.04) + vec3(0.05, 0.045, 0.08) * key + accent * 0.05 * facing;
color += vec3(1.0, 0.78, 0.5) * 0.05 * lamp;
} else if (vPart < 1.5) { } else if (vPart < 1.5) {
// Lit retro-reflective plate: brightest facing the camera, and in the beam. vec3 ink;
// Tinted by the chapter accent so it never reads as one more street lamp. float glyph;
// Kept below the tonemapper's shoulder, so the plate stays the if (feature) {
// chapter colour instead of clipping to white; the beam and the vec2 at = vec2(mod(vPlate.x, 2.0) * 0.5 + vUv.x * 0.5,
// flash lift it in the accent's own hue. 1.0 - (floor(vPlate.x / 2.0) + 1.0 - vUv.y) * ${(CELL_H / ATLAS).toFixed(5)});
vec3 sheet = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.82); ink = texture2D(uAtlas, at).rgb;
color = sheet * (${PLATE_GLOW.toFixed(2)} + 0.1 * key + 0.32 * facing * facing); glyph = 1.0;
color += uAccent * 0.18; // Lit from within as its lamp warms up; the beam lifts it like sheeting.
${full ? 'color += sheet * headlightBeam(vWorld) * (0.3 + 0.8 * facing);' : ''} color = ink * (0.32 + 0.4 * lamp + 0.28 * glint + 0.22 * beam * facing);
color += mix(uAccent, vec3(1.0, 0.96, 0.9), 0.35) * flash * 0.9;
} else { } else {
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8); // Plain plate: dark sheeting, an accent rule and three lit digits.
float edge = step(min(min(vUv.x, 1.0 - vUv.x) * ${(PLATE_W / PLATE_H).toFixed(2)}, min(vUv.y, 1.0 - vUv.y)), 0.07);
vec2 d = (vUv - vec2(0.2, 0.2)) / vec2(0.6, 0.6);
float glyphs = 0.0;
if (d.x > 0.0 && d.x < 1.0 && d.y > 0.0 && d.y < 1.0) {
float slot = floor(d.x * 3.0);
float value = slot < 0.5 ? floor(vPlate.y / 100.0) : slot < 1.5 ? mod(floor(vPlate.y / 10.0), 10.0) : mod(vPlate.y, 10.0);
vec2 at = vec2((value + fract(d.x * 3.0)) / 10.0, d.y * ${((ATLAS - DIGITS_Y) / ATLAS).toFixed(5)});
glyphs = texture2D(uAtlas, at).r;
}
vec3 sheet = mix(vec3(0.9, 0.9, 1.0), accent, 0.45);
color = vec3(0.03, 0.025, 0.05) + accent * 0.42 * edge + sheet * glyphs * (0.34 + 0.4 * beam * facing);
}
} else if (vPart < 2.5) {
color = accent * ((feature ? 0.6 : 0.36) + 0.25 * lamp + 0.3 * glint + 0.25 * beam);
} else {
// The lamp head: warm, under the bloom threshold until it is close.
vec3 warm = mix(vec3(1.0, 0.8, 0.55), accent, 0.25);
color = vec3(0.05, 0.04, 0.07) + warm * (1.7 * lamp + 0.6 * glint) * (0.75 + 0.25 * facing);
} }
gl_FragColor = vec4(color, 1.0); gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment> #include <tonemapping_fragment>
@@ -224,21 +550,21 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
`, `,
fog: false, fog: false,
})); }));
const mesh = new THREE.InstancedMesh(geometry, material, INSTANCES); const mesh = new THREE.InstancedMesh(geometry, material, MILESTONE_SLOTS);
mesh.name = 'mile-markers'; mesh.name = 'mile-markers';
mesh.frustumCulled = false; mesh.frustumCulled = false;
mesh.visible = false; mesh.visible = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// The light pool: an additive quad on the shoulder, pulled toward the road. // The light pool of a feature post: an additive quad over the shoulder and the lane.
const poolGeometry = resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2)); const poolGeometry = resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2));
poolGeometry.setAttribute('aState', state); poolGeometry.setAttribute('aState', state);
const poolMaterial = resources.own('material', new THREE.ShaderMaterial({ const poolMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform }, uniforms: { uAccents: { value: accents } },
vertexShader: /* glsl */ ` vertexShader: /* glsl */ `
attribute vec2 aState; attribute vec4 aState;
varying vec2 vUv; varying vec2 vUv;
varying vec2 vState; varying vec4 vState;
void main() { void main() {
vUv = uv; vUv = uv;
vState = aState; vState = aState;
@@ -246,16 +572,14 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
} }
`, `,
fragmentShader: /* glsl */ ` fragmentShader: /* glsl */ `
uniform vec3 uAccent; uniform vec3 uAccents[2];
uniform float uPulse;
varying vec2 vUv; varying vec2 vUv;
varying vec2 vState; varying vec4 vState;
void main() { void main() {
vec2 d = (vUv - 0.5) * 2.0; vec2 d = (vUv - 0.5) * 2.0;
float r = length(d); float pool = pow(max(0.0, 1.0 - length(d)), 1.7);
float pool = pow(max(0.0, 1.0 - r), 1.8); vec3 accent = vState.w > 0.5 ? uAccents[1] : uAccents[0];
vec3 warm = vec3(1.0, 0.86, 0.62); vec3 color = mix(vec3(1.0, 0.84, 0.6), accent, 0.6) * pool * (0.7 * vState.z + 0.35 * vState.x);
vec3 color = mix(warm, uAccent, 0.5) * pool * (2.4 + ${PULSE_GAIN.toFixed(2)} * 3.0 * uPulse + 3.0 * vState.x);
gl_FragColor = vec4(color * vState.y, 1.0); gl_FragColor = vec4(color * vState.y, 1.0);
#include <tonemapping_fragment> #include <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
@@ -266,23 +590,15 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
depthWrite: false, depthWrite: false,
fog: false, fog: false,
})); }));
const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, INSTANCES); const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, MILESTONE_SLOTS);
pool.name = 'mile-marker-pools'; pool.name = 'mile-marker-pools';
pool.frustumCulled = false; pool.frustumCulled = false;
pool.visible = false; pool.visible = false;
pool.instanceMatrix.setUsage(THREE.DynamicDrawUsage); pool.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const poolMatrix = new THREE.Matrix4();
const poolPosition = new THREE.Vector3();
const noRotation = new THREE.Quaternion();
const flash = new Float32Array(INSTANCES);
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
const side = new Float32Array(INSTANCES);
let lastSection: SectionId | null = null;
let lastProgress: number | null = null;
let rate = 0;
const matrix = new THREE.Matrix4(); const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion(); const rotation = new THREE.Quaternion();
const noRotation = new THREE.Quaternion();
const position = new THREE.Vector3(); const position = new THREE.Vector3();
const scale = new THREE.Vector3(); const scale = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0); const up = new THREE.Vector3(0, 1, 0);
@@ -290,47 +606,36 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
return { return {
mesh, mesh,
pool, pool,
flashes: () => Array.from(flash), posts: () => road.posts(),
update(section, p, pulse, deltaSeconds) { update(frame) {
const posts = milestoneLayout(section, p); const posts = road.step(frame);
const changed = section !== lastSection;
lastSection = section;
const dt = Math.max(deltaSeconds, 1 / 240);
const instant = p !== null && lastProgress !== null && !changed ? Math.abs(p - lastProgress) / dt : 0;
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
lastProgress = p;
let any = false; let any = false;
posts.forEach((post, i) => { let lit = false;
// The flash fires when the plate crosses the line, either way. for (let i = 0; i < posts.length; i += 1) {
const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section])); const post = posts[i]!;
sinceFlash[i]! += deltaSeconds; const visible = post.presence > 0 && post.section !== null;
if (full && !changed && !sweeping && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
flash[i] = 1;
sinceFlash[i] = 0;
} else {
flash[i] = Math.max(0, flash[i]! - deltaSeconds / FLASH_SECONDS);
}
if (crossing !== 0) side[i] = crossing;
if (post.presence === 0) side[i] = 0;
const visible = post.presence > 0;
any ||= visible; any ||= visible;
const side = Math.sign(post.x) || 1;
position.set(post.x, 0, post.z); position.set(post.x, 0, post.z);
rotation.setFromAxisAngle(up, -Math.sign(post.x) * PLATE_YAW); rotation.setFromAxisAngle(up, -side * PLATE_YAW);
scale.setScalar(visible ? 1 : 0); scale.setScalar(visible ? 1 : 0);
mesh.setMatrixAt(i, matrix.compose(position, rotation, scale)); mesh.setMatrixAt(i, matrix.compose(position, rotation, scale));
poolPosition.set(post.x - Math.sign(post.x) * 1.4, 0.05, post.z); const pooled = visible && full && post.lamp > 0.01;
pool.setMatrixAt(i, poolMatrix.compose(poolPosition, noRotation, scale)); lit ||= pooled;
state.setXY(i, flash[i]!, post.presence); position.set(post.x - side * 4.2, 0.05, post.z);
}); scale.setScalar(pooled ? 1 : 0);
pool.setMatrixAt(i, matrix.compose(position, noRotation, scale));
const cell = visible && post.entry >= 0 && post.section !== null ? letter(post.section, post.entry) : -1;
state.setXYZW(i, post.glint, post.presence, post.lamp, post.section === 'experience' ? 1 : 0);
plate.setXY(i, cell, post.number);
}
mesh.visible = any; mesh.visible = any;
pool.visible = any; pool.visible = lit;
if (!any) return; if (!any) return;
if (isMilestoneSection(section)) accent.set(SIGN_ACCENTS[section]);
pulseUniform.value = full ? clamp01(pulse) : 0;
mesh.instanceMatrix.needsUpdate = true; mesh.instanceMatrix.needsUpdate = true;
pool.instanceMatrix.needsUpdate = true; pool.instanceMatrix.needsUpdate = true;
state.needsUpdate = true; state.needsUpdate = true;
plate.needsUpdate = true;
}, },
}; };
} }

View File

@@ -1,132 +1,332 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { SectionId } from '@/content/types';
import { SceneResourceBag } from '@/scene/resources'; import { SceneResourceBag } from '@/scene/resources';
import { import {
MILESTONE_FLASH_MAX_RATE, createMilestoneRoad,
createMilestones, createMilestones,
MILESTONE_ASSIGN_Z,
MILESTONE_FAR_Z, MILESTONE_FAR_Z,
MILESTONE_FLASH_MAX_RATE,
MILESTONE_NEAR_Z, MILESTONE_NEAR_Z,
MILESTONE_PASS_Z, MILESTONE_PASS_Z,
MILESTONE_SIDE, MILESTONE_SIDE,
milestoneLayout, MILESTONE_SLOTS,
milestoneZ, MILESTONE_SPACING,
milestoneEntries,
milestoneRangeFade,
milestoneSlot,
type MilestonePost,
} from '@/scene/world/milestones'; } from '@/scene/world/milestones';
import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log'; import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log';
const sweep = (from: number, to: number, steps = 200): number[] => const DT = 1 / 60;
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
describe('mile marker layout', () => { /** Drives the road for `seconds`: progress and road speed (m/s) per frame. */
it('rolls every post toward the camera monotonically with progress, in order', () => { function drive(
for (const section of ['experience', 'profile'] as const) { road: ReturnType<typeof createMilestoneRoad>,
const count = MILESTONE_COUNTS[section]; seconds: number,
for (let i = 0; i < count; i += 1) { frame: (t: number) => { section: SectionId; p: number | null; speed: number },
let previous = Number.NEGATIVE_INFINITY; start = { distance: 0 },
for (const p of sweep(0, 1)) { each?: (posts: readonly MilestonePost[], t: number, p: number | null) => void,
const z = milestoneZ(p, i, count); ) {
expect(z).toBeGreaterThan(previous); for (let i = 0; i <= Math.round(seconds / DT); i += 1) {
previous = z; const t = i * DT;
const { section, p, speed } = frame(t);
start.distance += speed * DT;
const posts = road.step({ section, p, distance: start.distance, pulse: 0, deltaSeconds: DT });
each?.(posts, t, p);
} }
// At the camera's near edge exactly as its plate crosses the line. return start;
expect(milestoneZ(passPoint(i, count), i, count)).toBeCloseTo(MILESTONE_PASS_Z);
expect(MILESTONE_PASS_Z).toBeCloseTo((MILESTONE_FAR_Z + MILESTONE_NEAR_Z) / 2);
// Still on the near side of the camera (z ~25) and large in frame.
expect(MILESTONE_PASS_Z).toBeLessThan(20);
// Earlier posts are always nearer.
if (i > 0) expect(milestoneZ(0.5, i - 1, count)).toBeGreaterThan(milestoneZ(0.5, i, count));
} }
/** Progress at which each feature post (by entry) went past the pass line. */
function passes(
road: ReturnType<typeof createMilestoneRoad>,
seconds: number,
frame: (t: number) => { section: SectionId; p: number | null; speed: number },
) {
const seen: Array<{ entry: number; p: number | null; t: number }> = [];
let last: readonly number[] = [];
drive(road, seconds, frame, { distance: 0 }, (posts, t, p) => {
posts.forEach((post, k) => {
if (post.entry >= 0 && post.presence > 0 && (last[k] ?? Infinity) < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) {
seen.push({ entry: post.entry, p, t });
} }
}); });
last = posts.map((post) => post.z);
});
return seen;
}
it('stands on the free shoulder: right of the road in Experience, left in Profile', () => { describe('mile marker lattice', () => {
it('spaces the posts evenly along the road, inside the drawn range', () => {
expect(MILESTONE_NEAR_Z - MILESTONE_FAR_Z).toBe(MILESTONE_SPACING * MILESTONE_SLOTS);
for (const distance of [0, 17.3, 59.99, 60, 1234.5, 98765.4]) {
const zs = Array.from({ length: MILESTONE_SLOTS }, (_, k) => milestoneSlot(distance, k).z).sort((a, b) => a - b);
for (const z of zs) {
expect(z).toBeGreaterThanOrEqual(MILESTONE_FAR_Z);
expect(z).toBeLessThan(MILESTONE_NEAR_Z);
}
for (let i = 1; i < zs.length; i += 1) expect(zs[i]! - zs[i - 1]!).toBeCloseTo(MILESTONE_SPACING, 6);
}
// A post is born in the haze and leaves behind the camera, never in view.
expect(milestoneRangeFade(MILESTONE_FAR_Z)).toBe(0);
expect(milestoneRangeFade(MILESTONE_NEAR_Z - 0.01)).toBe(0);
expect(milestoneRangeFade(MILESTONE_PASS_Z)).toBe(1);
});
it('moves every post with the travel distance, metre for metre, and numbers them along the route', () => {
for (let k = 0; k < MILESTONE_SLOTS; k += 1) {
const a = milestoneSlot(1000, k);
const b = milestoneSlot(1007, k);
if (b.id === a.id) expect(b.z - a.z).toBeCloseTo(7, 6);
else expect(b.id).toBeGreaterThan(a.id);
}
// The farther post is the later one on the route.
const now = Array.from({ length: MILESTONE_SLOTS }, (_, k) => milestoneSlot(500, k)).sort((x, y) => x.z - y.z);
for (let i = 1; i < now.length; i += 1) expect(now[i]!.id).toBeLessThan(now[i - 1]!.id);
});
});
describe('mile markers on the road', () => {
it('keep streaming past while the reader is still (constant progress)', () => {
const road = createMilestoneRoad(true);
const state = drive(road, 2, () => ({ section: 'experience', p: 0.42, speed: 18 }));
const before = road.posts().map((post) => ({ ...post }));
drive(road, 1, () => ({ section: 'experience', p: 0.42, speed: 18 }), state);
const after = road.posts();
let moved = 0;
after.forEach((post, k) => {
const was = before[k]!;
// Each post either moved 18 m toward the camera or wrapped to the far end.
if (post.z > was.z) {
expect(post.z - was.z).toBeCloseTo(18 + 18 * DT, 1);
moved += 1;
} else {
expect(post.z).toBeLessThan(MILESTONE_FAR_Z + 20);
}
});
expect(moved).toBeGreaterThanOrEqual(MILESTONE_SLOTS - 1);
// Visible plain posts on the free shoulder.
const shown = after.filter((post) => post.presence > 0.5);
expect(shown.length).toBeGreaterThanOrEqual(2);
expect(shown.every((post) => post.x === MILESTONE_SIDE.experience)).toBe(true);
});
it('stands on the free shoulder: right in Experience, left in Profile, nowhere else', () => {
expect(MILESTONE_SIDE.experience).toBeGreaterThan(8); expect(MILESTONE_SIDE.experience).toBeGreaterThan(8);
expect(MILESTONE_SIDE.profile).toBeLessThan(-8); expect(MILESTONE_SIDE.profile).toBeLessThan(-8);
const posts = milestoneLayout('experience', 0.5).filter((post) => post.presence > 0); const road = createMilestoneRoad(true);
expect(posts.length).toBeGreaterThan(0); const state = drive(road, 3, () => ({ section: 'profile', p: 0.5, speed: 30 }));
expect(posts.every((post) => post.section === 'experience' && post.x === MILESTONE_SIDE.experience)).toBe(true); expect(road.posts().filter((post) => post.presence > 0).every((post) => post.x === MILESTONE_SIDE.profile)).toBe(true);
drive(road, 1.5, () => ({ section: 'infrastructure', p: null, speed: 30 }), state);
expect(road.posts().every((post) => post.presence === 0)).toBe(true);
const start = createMilestoneRoad(true);
drive(start, 3, () => ({ section: 'start', p: null, speed: 30 }));
expect(start.posts().every((post) => post.presence === 0)).toBe(true);
}); });
it('is hidden outside its chapters and fades at the chapter edges', () => { it('fades in and out over time at chapter changes instead of popping', () => {
for (const section of ['start', 'infrastructure', 'toolkit', 'contact'] as const) { const road = createMilestoneRoad(true);
expect(milestoneLayout(section, 0.5).every((post) => post.presence === 0)).toBe(true); const state = drive(road, 3, () => ({ section: 'start', p: null, speed: 18 }));
let first = -1;
drive(road, 0, () => ({ section: 'profile', p: 0.01, speed: 18 }), state, (posts) => {
first = Math.max(...posts.map((post) => post.presence));
});
expect(first).toBeLessThan(0.2);
drive(road, 1.2, () => ({ section: 'profile', p: 0.05, speed: 18 }), state);
expect(Math.max(...road.posts().map((post) => post.presence))).toBeGreaterThan(0.8);
// Into Experience: Profile's posts fade (no frame drops more than ~10%).
let previous = road.posts().map((post) => post.presence);
let biggestDrop = 0;
drive(road, 1, () => ({ section: 'experience', p: 0.01, speed: 18 }), state, (posts) => {
posts.forEach((post, k) => {
if (post.x === MILESTONE_SIDE.profile) biggestDrop = Math.max(biggestDrop, previous[k]! - post.presence);
});
previous = posts.map((post) => post.presence);
});
expect(biggestDrop).toBeLessThan(0.12);
expect(road.posts().filter((post) => post.presence > 0).every((post) => post.x === MILESTONE_SIDE.experience)).toBe(true);
});
it('sends each log entry past the camera about when its plate crosses the line (reading pace)', () => {
for (const [section, rate, speed] of [
['experience', 0.025, 40],
['experience', 0.012, 18],
['profile', 0.03, 60],
] as const) {
const count = MILESTONE_COUNTS[section];
const road = createMilestoneRoad(true);
const seen = passes(road, 1 / rate, (t) => ({ section, p: Math.min(1, t * rate), speed }));
expect(seen.map((pass) => pass.entry)).toEqual(Array.from({ length: count }, (_, i) => i));
for (const pass of seen) {
expect(Math.abs(pass.p! - passPoint(pass.entry, count))).toBeLessThan(0.3 / count);
}
} }
expect(milestoneLayout('experience', null).every((post) => post.presence === 0)).toBe(true);
expect(milestoneLayout('experience', 0).every((post) => post.presence === 0)).toBe(true);
expect(milestoneLayout('profile', 1).every((post) => post.presence === 0)).toBe(true);
const inside = milestoneLayout('profile', 0.5);
expect(inside.filter((post) => post.section === 'experience').every((post) => post.presence === 0)).toBe(true);
expect(inside.some((post) => post.section === 'profile' && post.presence === 1)).toBe(true);
}); });
it('is symmetric in reverse: the same progress gives the same frame either way', () => { it('sends the entry the reader stopped on, once, and keeps the shoulder moving', () => {
const ps = sweep(0, 1); const road = createMilestoneRoad(true);
const forward = ps.map((p) => milestoneLayout('experience', p)); // A jump that lands early in entry 2 of 4, then nothing.
const backward = [...ps].reverse().map((p) => milestoneLayout('experience', p)).reverse(); const p = passPoint(2, 4) - 0.08;
expect(backward).toEqual(forward); const seen = passes(road, 40, (t) => ({ section: 'experience', p: t < 0.3 ? (t / 0.3) * p : p, speed: 18 }));
expect(seen.map((pass) => pass.entry)).toEqual([2]);
expect(seen[0]!.t).toBeLessThan(15);
});
it('runs the road forward when scrolling back: rewound entries come up the road again', () => {
const road = createMilestoneRoad(true);
const rate = 0.03;
const back = 1 / rate;
const progress = (t: number) => (t < back ? t * rate : Math.max(0, 1 - (t - back) * rate));
const seen: Array<{ entry: number; reverse: boolean }> = [];
let last: number[] = [];
let ids: number[] = [];
drive(road, back * 2, (t) => ({ section: 'experience', p: progress(t), speed: 30 }), { distance: 0 }, (posts, t) => {
posts.forEach((post, k) => {
// Never toward the horizon, except the wrap to the far end (a new post).
if (last[k] !== undefined && ids[k] === post.number) expect(post.z).toBeGreaterThanOrEqual(last[k]!);
if (post.entry >= 0 && post.presence > 0 && (last[k] ?? Infinity) < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) {
seen.push({ entry: post.entry, reverse: t >= back });
}
});
last = posts.map((post) => post.z);
ids = posts.map((post) => post.number);
});
expect(seen.filter((pass) => !pass.reverse).map((pass) => pass.entry)).toEqual([0, 1, 2, 3]);
// The last entry has only just gone by; the others come back in reverse order.
expect(seen.filter((pass) => pass.reverse).map((pass) => pass.entry)).toEqual([2, 1, 0]);
});
it('only hands an entry to a post still far up the road, in the haze', () => {
const road = createMilestoneRoad(true);
const born = new Map<number, number>();
drive(road, 30, (t) => ({ section: 'experience', p: Math.min(1, t * 0.03), speed: 30 }), { distance: 0 }, (posts) => {
posts.forEach((post) => {
if (post.entry >= 0 && !born.has(post.number)) born.set(post.number, post.z);
});
});
expect(born.size).toBeGreaterThan(0);
for (const z of born.values()) expect(z).toBeLessThan(MILESTONE_ASSIGN_Z);
});
});
describe('mile marker light', () => {
it('warms a feature lamp as it approaches and leaves plain posts dark', () => {
const road = createMilestoneRoad(true);
const lamps = new Map<number, Array<{ z: number; lamp: number }>>();
drive(road, 20, () => ({ section: 'experience', p: passPoint(1, 4) - 0.1, speed: 18 }), { distance: 0 }, (posts) => {
for (const post of posts) {
if (post.entry < 0) expect(post.lamp).toBe(0);
else if (post.presence > 0) lamps.set(post.number, [...(lamps.get(post.number) ?? []), { z: post.z, lamp: post.lamp }]);
}
});
const track = [...lamps.values()][0]!;
const approach = track.filter((sample) => sample.z < MILESTONE_PASS_Z);
for (let i = 1; i < approach.length; i += 1) expect(approach[i]!.lamp).toBeGreaterThanOrEqual(approach[i - 1]!.lamp);
expect(approach.at(-1)!.lamp).toBeGreaterThan(0.8);
});
it('glints once as a feature post passes, never twice inside 0.6 s, and never during a jump', () => {
const road = createMilestoneRoad(true);
const count = MILESTONE_COUNTS.experience;
let glints: number[] = [];
let previous = 0;
drive(road, 1 / 0.02, (t) => ({ section: 'experience', p: Math.min(1, t * 0.02), speed: 30 }), { distance: 0 }, (posts, t) => {
const level = Math.max(...posts.map((post) => post.glint));
if (level === 1 && previous < 1) glints.push(t);
previous = level;
});
expect(glints.length).toBe(count);
for (let i = 1; i < glints.length; i += 1) expect(glints[i]! - glints[i - 1]!).toBeGreaterThanOrEqual(0.6);
// A navigator jump: the whole chapter in 0.6 s at full road speed.
const jump = createMilestoneRoad(true);
glints = [];
let peak = 0;
const state = drive(jump, 4, () => ({ section: 'profile', p: 0.02, speed: 18 }));
drive(jump, 0.6, (t) => ({ section: 'experience', p: Math.min(1, t / 0.6), speed: 120 }), state, (posts) => {
peak = Math.max(peak, ...posts.map((post) => post.glint));
});
expect(peak).toBe(0);
expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5);
});
it('low tier and touch: no glint and no pulse swell', () => {
const road = createMilestoneRoad(false);
let peak = 0;
let lamp = 0;
for (let i = 0; i < 60 * 30; i += 1) {
const posts = road.step({ section: 'experience', p: Math.min(1, i / (60 * 30)), distance: i * 0.5, pulse: 1, deltaSeconds: DT });
peak = Math.max(peak, ...posts.map((post) => post.glint));
lamp = Math.max(lamp, ...posts.map((post) => post.lamp));
}
expect(peak).toBe(0);
expect(lamp).toBeGreaterThan(0.5);
expect(lamp).toBeLessThanOrEqual(1);
}); });
}); });
describe('mile marker mesh', () => { describe('mile marker mesh', () => {
it('draws seven instances in one mesh, visible only in Experience and Profile', () => { it('draws the lattice in one mesh, visible only in Experience and Profile', () => {
const resources = new SceneResourceBag(); const resources = new SceneResourceBag();
const markers = createMilestones(resources, true); const markers = createMilestones(resources, true);
expect(markers.mesh.count).toBe(7); expect(markers.mesh.count).toBe(MILESTONE_SLOTS);
expect(markers.pool.count).toBe(7); expect(markers.pool.count).toBe(MILESTONE_SLOTS);
markers.update('start', null, 0, 1 / 60); markers.update({ section: 'start', p: null, distance: 0, pulse: 0, deltaSeconds: DT });
expect(markers.mesh.visible).toBe(false); expect(markers.mesh.visible).toBe(false);
expect(markers.pool.visible).toBe(false); expect(markers.pool.visible).toBe(false);
markers.update('experience', 0.4, 0.5, 1 / 60); let featured = false;
expect(markers.mesh.visible).toBe(true); let pooled = false;
expect(markers.pool.visible).toBe(true); for (let i = 1; i < 60 * 12; i += 1) {
markers.update('infrastructure', null, 0, 1 / 60); markers.update({ section: 'experience', p: 0.4, distance: i * 0.3, pulse: 0.5, deltaSeconds: DT });
expect(markers.mesh.visible).toBe(false); featured ||= markers.posts().some((post) => post.entry >= 0);
expect(markers.pool.visible).toBe(false); pooled ||= markers.pool.visible;
resources.dispose();
});
it('flashes a post as its plate crosses the line, either way, and decays in 300 ms', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, true);
const second = passPoint(1, 4);
markers.update('experience', second - 0.02, 0, 1 / 60);
markers.update('experience', second - 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(0);
markers.update('experience', second + 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(1);
for (let i = 0; i < 20; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(0);
// Back the other way (after the 0.6 s gap) flashes again.
for (let i = 0; i < 30; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
markers.update('experience', second - 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(1);
resources.dispose();
});
it('does not flash posts swept past by a navigator jump or a fling', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, true);
// A navigator long-scroll crosses the whole chapter in about 0.6 s.
let peak = 0;
for (let frame = 0; frame <= 36; frame += 1) {
markers.update('experience', frame / 36, 0, 1 / 60);
peak = Math.max(peak, ...markers.flashes());
} }
expect(peak).toBe(0); expect(markers.mesh.visible).toBe(true);
// Parked, then a single wheel tick at reading pace still flashes the post it crosses. expect(featured).toBe(true);
for (let i = 0; i < 90; i += 1) markers.update('experience', passPoint(3, 4) + 0.01, 0, 1 / 60); expect(pooled).toBe(true);
markers.update('experience', passPoint(3, 4) - 0.01, 0, 1 / 60); for (let i = 0; i < 60; i += 1) {
expect(markers.flashes()[3]).toBe(1); markers.update({ section: 'infrastructure', p: null, distance: 300 + i * 0.3, pulse: 0, deltaSeconds: DT });
expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5); }
expect(markers.mesh.visible).toBe(false);
expect(markers.pool.visible).toBe(false);
resources.dispose(); resources.dispose();
}); });
it('never flashes on the low tier or touch', () => { it('never lights pools on the low tier', () => {
const resources = new SceneResourceBag(); const resources = new SceneResourceBag();
const markers = createMilestones(resources, false); const markers = createMilestones(resources, false);
const first = passPoint(0, 3); let pooled = false;
markers.update('profile', first - 0.01, 1, 1 / 60); let featured = false;
markers.update('profile', first + 0.01, 1, 1 / 60); for (let i = 1; i < 60 * 15; i += 1) {
expect(markers.flashes().every((level) => level === 0)).toBe(true); markers.update({ section: 'profile', p: 0.5, distance: i * 0.3, pulse: 1, deltaSeconds: DT });
pooled ||= markers.pool.visible;
featured ||= markers.posts().some((post) => post.entry >= 0 && post.lamp > 0.5);
}
expect(markers.mesh.visible).toBe(true); expect(markers.mesh.visible).toBe(true);
expect(featured).toBe(true);
expect(pooled).toBe(false);
resources.dispose(); resources.dispose();
}); });
}); });
describe('mile marker plates', () => {
it('letters the feature plates from the inert input snapshot, and survives without one', () => {
const content = JSON.parse(readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8')) as unknown;
const page = document.implementation.createHTMLDocument('plates');
const script = page.createElement('script');
script.id = 'portfolio-inputs';
script.type = 'application/json';
script.textContent = JSON.stringify({ content });
page.body.append(script);
const items = milestoneEntries('experience', page);
expect(items).toHaveLength(MILESTONE_COUNTS.experience);
expect(items[1]).toMatchObject({ compactDate: 'JUN→AUG 2025', caption: 'CAMERA CONSULTANȚILOR FISCALI' });
expect(milestoneEntries('profile', page)).toHaveLength(MILESTONE_COUNTS.profile);
script.textContent = '{not json';
expect(milestoneEntries('experience', page)).toEqual([]);
expect(milestoneEntries('experience', document.implementation.createHTMLDocument('none'))).toEqual([]);
});
});