Merge branch 'polish/card-gaps' into redesign/overnight

This commit is contained in:
2026-10-12 19:22:21 +03:00
15 changed files with 193 additions and 28 deletions

61
src/journey/card-gaps.ts Normal file
View File

@@ -0,0 +1,61 @@
import { ACTIVATION_LINE_VH } from './section-controller';
/**
* CARD GAPS: the open road between chapter cards. A chapter opens with its
* title standing over the road, then one reading panel, then (route.css) a
* stretch of open road before the next title: the section's bottom padding
* plus the next section's top margin. Section-local progress runs 0 with a
* chapter's heading on the activation line to 1 with the next heading there,
* so the gap after a chapter's panel always sits at the END of its progress,
* and it is the one stretch where a scene payoff can be watched rather than
* read over. The payoffs (the district relight, the last yard lift, the relay
* beams) key off CARD_GAP instead of their own tuned numbers, so this window
* is the single source of truth. Pure and tiny: shared by the DOM
* (maintenance steps) and the lazy scene chunk.
*/
/** Open road below a panel, in vh: the section's bottom padding plus the next section's top margin (route.css). */
export const CARD_GAP_OPEN_ROAD_VH = 20 + 48;
/** The next chapter's title stack: its heading stands this far above its panel (about 197 px; vh at the 900 px design height). */
export const CARD_TITLE_VH = 22;
/** The viewport band a payoff must have clear of panels, as fractions of the viewport height. */
export const CARD_CLEAR_BAND = { top: 0.3, bottom: 0.85 } as const;
export interface GapWindow {
/** The previous panel has cleared the band (section-local progress). */
from: number;
/** The open road is centred in the viewport. */
peak: number;
/** The next panel reaches the band. */
to: number;
}
/**
* Where the open road after a chapter's panel fills the clear band, for a
* chapter whose heading-to-heading span is `spanVh` viewport heights. Not
* called per frame: payoffs use the static CARD_GAP below, which this
* validates in tests.
*/
export function gapWindow(spanVh: number): GapWindow {
const line = ACTIVATION_LINE_VH * 100;
// Panel edges relative to the next heading, in vh: the previous panel's bottom and the next panel's top.
const previousBottom = -CARD_GAP_OPEN_ROAD_VH;
const nextTop = CARD_TITLE_VH;
return {
from: 1 + (previousBottom + line - CARD_CLEAR_BAND.top * 100) / spanVh,
peak: 1 + ((previousBottom + nextTop) / 2 + line - 50) / spanVh,
to: 1 + (nextTop + line - CARD_CLEAR_BAND.bottom * 100) / spanVh,
};
}
/** Chapter spans (vh) the static window must hold for: the shortest chapter on a tall screen to the tallest on a short one. */
export const CARD_GAP_SPANS_VH = { min: 140, max: 330 } as const;
/**
* The gap window every payoff keys off, in section-local progress. Valid for
* all spans in CARD_GAP_SPANS_VH (tests/unit/card-gaps.test.ts proves it):
* payoffs begin once the previous panel has cleared the band and complete
* with the road centred, before the chapter's set dressing fades for the
* boundary (`to`).
*/
export const CARD_GAP: GapWindow = Object.freeze({ from: 0.9, peak: 0.94, to: 0.96 });

View File

@@ -1,4 +1,5 @@
import type { JourneySnapshot } from './types';
import { CARD_GAP } from './card-gaps';
import { createRewindLatch, sectionLocalProgress } from './route-log';
/**
@@ -12,8 +13,8 @@ import { createRewindLatch, sectionLocalProgress } from './route-log';
*/
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
/** Upper bounds of each step, in section-local progress. */
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: 0.9 } as const;
/** Upper bounds of each step, in section-local progress. Cutover lands where the card clears the road (CARD_GAP). */
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: CARD_GAP.from } as const;
/**
* The clock reads live Bucharest time again from here. The window keeps its

View File

@@ -6,6 +6,8 @@
* pure. Scroll-driven, so scrolling back takes the masts offline again.
*/
import { CARD_GAP } from './card-gaps';
/** Chapter-local progress at which each mast comes online. */
export const RELAY_STOPS = [0.04, 0.22, 0.4, 0.58] as const;
/** A mast's rungs light bottom-up over this much chapter progress after its stop. */
@@ -13,6 +15,17 @@ export const RELAY_RAMP = 0.12;
/** Beacon blink rate (photosensitivity: well under 3 Hz). */
export const BEACON_HZ = 0.5;
/**
* 0..1: the station goes live, every online mast's beam sweeping the sky
* together, in the open road after the Toolkit card (CARD_GAP), where the
* masts are in frame. Scrolling back takes the beams down again.
*/
export function stationLive(p: number | null): number {
if (p === null) return 0;
const t = Math.min(1, Math.max(0, (p - CARD_GAP.from) / (CARD_GAP.peak - CARD_GAP.from)));
return t * t * (3 - 2 * t);
}
/** How many masts are online at chapter progress p (0 outside the chapter). */
export const relayOnline = (p: number | null): number =>
p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length;

View File

@@ -9,8 +9,10 @@
* dims the plate and runs the crane back.
*/
/** Section-local progress of each stop (one per additional project). */
export const YARD_STOPS = [0.1, 0.33, 0.56, 0.79] as const;
import { CARD_GAP } from './card-gaps';
/** Section-local progress of each stop (one per additional project); the last lift lands in the open road after the card. */
export const YARD_STOPS = [0.12, 0.38, 0.64, CARD_GAP.peak] as const;
/** The crane stands still this far either side of a stop while the spreader works. */
export const YARD_HOLD = 0.06;
/**
@@ -40,7 +42,9 @@ const smooth = (from: number, to: number, value: number): number => {
/**
* Crane z for progress p: parked over stack i within YARD_HOLD of stop i,
* easing between stacks, from CRANE_FROM_Z at 0 to CRANE_TO_Z at 1.
* easing between stacks from CRANE_FROM_Z at 0; it would run on to
* CRANE_TO_Z after the last hold, but the last stop sits in the card gap
* (CARD_GAP.peak) so the crane stays parked to the chapter boundary.
* Non-decreasing in p.
*/
export function craneZ(p: number): number {

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { CARD_GAP } from '@/journey/card-gaps';
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
/**
@@ -7,7 +8,8 @@ import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-pro
* window opens over the city: one district powers down lamp by lamp, amber
* backup packets stream to an offsite silhouette on the far right, rain
* starts on the windshield, then the service re-lights in the infrastructure
* accent. Everything is a pure function of section-local scroll progress, so
* accent at cutover, in the open road after the card (CARD_GAP), where the
* skyline is in frame. Everything is a pure function of section-local scroll progress, so
* scrolling back up replays it in reverse. Only the distant lightning keeps
* its own (bounded) clock.
*
@@ -48,9 +50,9 @@ export function maintenanceTimeline(p: number): MaintenanceFrame {
return {
step: maintenanceStep(x),
dim: smooth(0.1, 0.35, x),
relight: smooth(0.35, 0.65, x),
relight: smooth(CARD_GAP.from, CARD_GAP.peak, x),
rain: MAINTENANCE_RAIN_PEAK * smooth(0.1, 0.3, x) * (1 - smooth(0.7, 0.9, x)),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - 0.67) / 0.21),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - (CARD_GAP.from - 0.21)) / 0.21),
offsite: smooth(0.14, 0.36, x),
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, x)),
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),

View File

@@ -1,7 +1,8 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit } from '@/journey/relay-progress';
import { CARD_GAP } from '@/journey/card-gaps';
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit, stationLive } from '@/journey/relay-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { CHAPTER_COMPOSITIONS, RELAY_NUDGE } from './composition';
@@ -18,8 +19,9 @@ type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
* RELAY STATION, scene side. Four lattice transmitter masts, one per toolkit
* group, stand on the far shoulders, alternating sides (first on the right).
* As the chapter scrolls each comes online in turn: its lamp rungs ignite
* bottom-up (one per technology), a red beacon blinks at 0.5 Hz and a thin
* additive beam sweeps the sky every 6 s. Every beat is a pure function of
* bottom-up (one per technology) and a red beacon blinks at 0.5 Hz. The
* station goes live in the open road after the card (CARD_GAP): every
* online mast's thin additive beam sweeps the sky every 6 s. Every beat is a pure function of
* section-local progress, so scrolling back takes the masts offline again.
* One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a
* technology flashes its rung. Lite (low tier, touch, data saver): all rungs
@@ -133,7 +135,7 @@ const smooth = (from: number, to: number, value: number): number => {
const halfWidthAt = (y: number): number => THREE.MathUtils.lerp(BASE_HALF, TOP_HALF, y / MAST_HEIGHT);
/** The chapter fades the station in and out at its edges. */
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(CARD_GAP.to, 1, p));
export interface RelayRung {
/** World height of the rung's centre. */
@@ -453,6 +455,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
const dt = Math.max(0, deltaSeconds);
const live = section === 'toolkit' ? p : null;
const masts = relayLayout(live, readRelay().counts, full, aspect);
const beams = stationLive(live);
const changed = section !== lastSection;
lastSection = section;
@@ -498,9 +501,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
if (beamMesh !== null && beamState !== null) {
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
rotation.setFromAxisAngle(forward, sweep);
const shown = drawn && mast.level > 0 ? mast.scale : 0;
const shown = drawn && mast.level > 0 && beams > 0 ? mast.scale : 0;
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
beamState.setXYZ(m, mast.level, 0, mast.presence);
beamState.setXYZ(m, mast.level * beams, 0, mast.presence);
rotation.identity();
}

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { CARD_GAP } from '@/journey/card-gaps';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import {
CRANE_FROM_Z,
@@ -434,7 +435,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
return t * t * (3 - 2 * t);
};
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live));
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(CARD_GAP.to, 1, live));
const z = craneZ(live ?? 0);
const worldZ = group.position.z + z * Math.cos(yaw);
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
@@ -461,7 +462,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
});
stackState.needsUpdate = true;
warm = floodWarmup(warm, full && live !== null && live < 0.93, deltaSeconds);
warm = floodWarmup(warm, full && live !== null && live < CARD_GAP.to, deltaSeconds);
const light = floodLight(warm);
flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0);
pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0;

View File

@@ -9,7 +9,8 @@
/* ---------- Chapter openers over the road ---------- */
@media screen {
.reading-stage .portfolio-section {
margin-block: clamp(220px, 44vh, 480px) 0;
/* CARD GAPS: with the padding below, the open road between chapters (journey/card-gaps.ts). */
margin-block: clamp(240px, 48vh, 520px) 0;
padding-block-end: clamp(150px, 20vh, 230px);
}
.reading-stage .portfolio-section:not([data-layout='project-stage']) {