Compare commits
11 Commits
f9b515c3a0
...
redesign/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9eddc33ee | ||
|
|
f42af6f3b1 | ||
|
|
6c5fce7adc | ||
|
|
749fc96d41 | ||
|
|
a505a31f31 | ||
|
|
2be15980ee | ||
|
|
4b1b416667 | ||
|
|
3fb62f96dd | ||
|
|
88ea4f76c4 | ||
|
|
fbcea0bb2c | ||
|
|
75108562a4 |
@@ -135,3 +135,47 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
|||||||
- **Fix:** every `pow()` base in the scene shaders is now non-negative on its face. Traffic uses `clamp(vUv.y, 0, 1)`. The 31 other sites in 15 world files get `abs()` for even exponents (where results are identical), and `max(…, 0)` or `clamp(…, 0, 1)` elsewhere (identical wherever the old result was defined). Examples are speed-wake `1 - vUv.y`, expressway `facing`, billboard/dawn/districts fresnel `1 - |dot|`, road film and cones, skyline, yard and terrain. No CPU-side change and no allocations.
|
- **Fix:** every `pow()` base in the scene shaders is now non-negative on its face. Traffic uses `clamp(vUv.y, 0, 1)`. The 31 other sites in 15 world files get `abs()` for even exponents (where results are identical), and `max(…, 0)` or `clamp(…, 0, 1)` elsewhere (identical wherever the old result was defined). Examples are speed-wake `1 - vUv.y`, expressway `facing`, billboard/dawn/districts fresnel `1 - |dot|`, road film and cones, skyline, yard and terrain. No CPU-side change and no allocations.
|
||||||
- **Test:** tests/unit/shader-pow-safety.test.ts scans src for `pow(` (comments stripped). It fails unless the base is a single abs/max/clamp/smoothstep call, optionally scaled by a positive literal. It includes the traffic regression.
|
- **Test:** tests/unit/shader-pow-safety.test.ts scans src for `pow(` (comments stripped). It fails unless the base is a single abs/max/clamp/smoothstep call, optionally scaled by a positive literal. It includes the traffic regression.
|
||||||
- **Checks:** 973 tests (83 files), vue-tsc clean, build:preview violations []. After the fix the unguarded probe gave 0 black frames in 21.6k frames (seeds 11 and 19 confined, seed 7 full range). Probe rAF p50 16.7 / p95 33.3 ms, the same as the pre-fix probe runs (the readback inflates p95).
|
- **Checks:** 973 tests (83 files), vue-tsc clean, build:preview violations []. After the fix the unguarded probe gave 0 black frames in 21.6k frames (seeds 11 and 19 confined, seed 7 full range). Probe rAF p50 16.7 / p95 33.3 ms, the same as the pre-fix probe runs (the readback inflates p95).
|
||||||
|
|
||||||
|
## Owner picks from remaining-options.md (2A, 3A, 4, 6A, 7A, 8 B1)
|
||||||
|
- **8 B1, Thoreau closer (f4d9cb0):** the end letterbox no longer types name, title and location. It shows "There is more day to dawn. The sun is but a morning star." (Thoreau, *Walden*) in a serif italic system stack, with each word fading in at sunrise. Content lives in the content JSON (`closer: {quote, attribution}`, schema and types updated). Reduced motion shows it static. Screenshots: closer-*.webp.
|
||||||
|
- **4, speedometer (70b30fa, Fable):** the gantry's left bay holds a 240° SVG arc dial in the chapter accent, with a spring needle and a readout for speed, gear and TRIP, plus BRAKE, OVERDRIVE and ENGINE OFF lamps. It does an ignition sweep, cruises at 88, climbs to about 160 while scrolling, goes to overdrive on navigator jumps, flares on the high-beam flash and parks at 000/P at the destination. It is a button that toggles km/h and mph (saved in localStorage) and shows a route detail line on hover. It is hidden below 1280 px, snaps without animating under reduced motion, rides the existing frame loop, and the dial is a lazy 1.81 KB gz chunk (deferred-non-video). Initial JS +1.30 KB gz. Weak spots: the BRAKE lamp is brief, and under reduced motion the digits can stay at 088 while scrolling. Screenshots: speedo-*.webp.
|
||||||
|
- **6A scene nits + 7A phones (3602eeb, Opus):** the yard aims from the live camera every frame, so it no longer slides in at chapter entry; any future camera-rig change must keep `camera.position` in world space. Container stacks are squared to the road, with the far stack cut to 3 tiers. The billboard holds until the EXIT 05 deck has passed. Relay rungs and pylon lamps are two-layer, with a violet core under the tonemap shoulder and an additive halo. The high-beam flare stays in the lamp's own colour and peaks at 120–250 m. Phones get exposure ×1.15, lamp gain ×1.3, a brighter mobile poster (mean 62 → 71.5), one large portrait transmitter, nearer overtakes, and a staging rule. The rule promises a pull-out within 30 s of each chapter entry (mean under 20 s) and no gap without a lane change longer than 55 s; it is looser than the 15 s target because of the 6-vehicle pool. Initial JS +12 B (hash churn only). Screenshots: nits-*.webp, phone-*.webp.
|
||||||
|
- **2, first frame on a cold GPU: A and B both built and measured, owner kept A.**
|
||||||
|
- **A, compile in the background (26e5ff0):** `shader-warmup.ts` compiles into the composer's scene target with hidden objects made visible, then awaits `compileAsync` with a 2.5 s timeout. Measured against baseline (medians, loaded machine, relative only): first scene frame within noise; the frame-0 main-thread task drops 1737 → 340 ms; the next frame drops 177 → 98 ms. Cons: total link time is unchanged (it just moves off the main thread), it relies on three internals (guarded by a browser spec), about 330 ms remains for the post-processing programs, and three keeps polling after a timeout.
|
||||||
|
- **B, compile in slices (240c2cd, not merged, kept on `polish/compile-slices`):** spreads the warm-up over frames with a 40 ms budget and no task over about 220 ms; adds a `'warming'` render state and a prefetch of the scene chunk. First scene frame came 0.6–1.0 s later than baseline, and total compile stall rose by about 0.8 s. It also needs a record/replay hook on the renderer and a hand-kept copy of three's program grouping. The B agent also noted that 21 browser tests fail at baseline 71465fa on this machine (context-loss, fault-matrix, static-fallback and journey-integration); this is not caused by A or B.
|
||||||
|
- **3A, payoffs in the card gaps (7510856, Fable):** the real gap model is `route.css` (the open road after each panel is the section's padding-end plus the next section's margin-start), so the gap always falls at section progress of about 0.9–1.0. The new `journey/card-gaps.ts` exports `CARD_GAP = {from 0.9, peak 0.94, to 0.96}`, and a test proves that window holds for chapter spans of 140–330 vh. The district relight moved from 0.35–0.65 (behind the panel) to 0.90–0.94, and it shares the cutover step. The last yard lift peaks at 0.94. Relay station beams now go live together at 0.90–0.94 through `stationLive()`. The gap is widened from `clamp(220px,44vh,480px)` to `clamp(240px,48vh,520px)`, which adds 252 px of page height at 1440×900. No new chunks or shaders. Screenshots: gaps-*.webp.
|
||||||
|
- **Merged state (fbcea0b):** vue-tsc clean, 1021 tests, build:preview violations [].
|
||||||
|
|
||||||
|
## Fix: nav labels clipped between 1280 and 1440 px (4b1b416)
|
||||||
|
- Between 1280 and 1440 px the chapter tab labels were cut off. They now stay whole across that range.
|
||||||
|
|
||||||
|
## Nav bar: the edge-lit LCD (owner pick after seven rounds)
|
||||||
|
- **How it was chosen:** 17 bar options were built behind `html[data-nav-bg]` on throwaway `polish/navbg*` branches (Fable: 1, 2, 4–6, 12, 13; Opus: 3, 7–11, 14–17).
|
||||||
|
- The owner reviewed them in a standalone preview page that fakes the drive signals live.
|
||||||
|
- Round 4's ambient motion was rejected for "meaningful" motion that stands on its own and reacts to the drive.
|
||||||
|
- V14–V16 crossed V3 (instrument panel) with V8 (light-guide glass). The owner then specified V17 from V15:
|
||||||
|
- a quieter LCD grid;
|
||||||
|
- V16's arrival droplet;
|
||||||
|
- slower, narrower route glints;
|
||||||
|
- a lower wave that is a smooth PS3-style ribbon at low speed and a smooth visualizer at high speed, with a hue cycle above 80 % speed that eases back slowly.
|
||||||
|
- **Shipped as the only bar, with no attribute:**
|
||||||
|
- `styles/nav-bar.css` (`.nav-lcd` with 15 parts, `.nav-wave`), using `html:root` in place of the variant attribute so specificity stays 0,1,1.
|
||||||
|
- `composables/useNavBarSignals.ts` mirrors the speedometer onto the bar. It writes only when values change, writes speed onto the LCD rather than the nav, and runs the hue tiers with hysteresis (on 0.80/0.88/0.95, off 0.76/0.85/0.92, stepping down one tier per 700 ms, then a 4 s settle). It also fires the one-shot `data-nav-shift`.
|
||||||
|
- `journey/nav-wave.ts` is a lazy 2D canvas: repainted at 30 Hz, paused when hidden, a still frame under reduced motion, hidden under forced colours.
|
||||||
|
- V1–V16 and the Fable bridge are not shipped.
|
||||||
|
- **Review fixes:**
|
||||||
|
- the wave now re-reads the chapter accent;
|
||||||
|
- per-frame engine writes are gone, and speed is written onto the LCD (−1.7 ms/frame of style recalc);
|
||||||
|
- the shift attribute clears after 1.6 s;
|
||||||
|
- the page-wide observer is removed;
|
||||||
|
- the wave repaints on resize;
|
||||||
|
- route glints are hidden below 768 px;
|
||||||
|
- unmount cleanup is complete;
|
||||||
|
- a slow wave test was rewritten.
|
||||||
|
- **Checks:**
|
||||||
|
- Pixel diffs against the approved preview build: 0 px in 21 scenes, apart from speedometer noise that also appears between two runs of the preview build itself, and the intended mobile glint removal.
|
||||||
|
- Contrast ≥6.38:1 at the worst frame. At most 1.5 flashes per second.
|
||||||
|
- 1029 tests, vue-tsc clean, build:preview violations [].
|
||||||
|
- **Known cost:** at rest it is identical to the old bar. While scrolling the mean frame is about 32 ms against 24–26 ms for the old bar, mostly from LCD restyling on speed changes. Updating the bargraph at 30 Hz would cut this, but was left out so the approved look is untouched.
|
||||||
|
- **Also fixed:** `tests/integration/prepare-assets.test.ts` never removed its `portfolio-assets-test-*` temp folders; about 10k of them (8 GB) had piled up in /tmp. It now removes them after the run.
|
||||||
|
- Recording: docs/overnight/polish/navbar-final.webp.
|
||||||
|
|||||||
BIN
docs/overnight/polish/gaps-relay-before-after.webp
Normal file
BIN
docs/overnight/polish/gaps-relay-before-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/overnight/polish/gaps-relight-before-after.webp
Normal file
BIN
docs/overnight/polish/gaps-relight-before-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
BIN
docs/overnight/polish/gaps-yard-before-after.webp
Normal file
BIN
docs/overnight/polish/gaps-yard-before-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/overnight/polish/navbar-final.webp
Normal file
BIN
docs/overnight/polish/navbar-final.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 KiB |
@@ -50,6 +50,8 @@ export const classifyArtifact = (path, source = '') => {
|
|||||||
if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
// SPEEDOMETER: the gantry dial's model and DOM driver, loaded after mount.
|
// SPEEDOMETER: the gantry dial's model and DOM driver, loaded after mount.
|
||||||
if (/^assets\/speedometer-dial-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
if (/^assets\/speedometer-dial-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
|
// NAV BAR: the lower-bezel wave canvas, loaded after the bar mounts.
|
||||||
|
if (/^assets\/nav-wave-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import './styles/title-navigation.css';
|
|||||||
import './styles/wayfinding.css';
|
import './styles/wayfinding.css';
|
||||||
import './styles/route.css';
|
import './styles/route.css';
|
||||||
import './styles/gantry.css';
|
import './styles/gantry.css';
|
||||||
|
import './styles/nav-bar.css';
|
||||||
import './styles/cards.css';
|
import './styles/cards.css';
|
||||||
import './styles/maintenance.css';
|
import './styles/maintenance.css';
|
||||||
import './styles/print.css';
|
import './styles/print.css';
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
|||||||
>
|
>
|
||||||
<!-- Closing quote, revealed word by word in the outro letterbox at sunrise. -->
|
<!-- Closing quote, revealed word by word in the outro letterbox at sunrise. -->
|
||||||
<span v-if="closer" class="journey-world__credit">
|
<span v-if="closer" class="journey-world__credit">
|
||||||
<span class="journey-world__quote"><span
|
<span class="journey-world__quote"><template
|
||||||
v-for="(word, index) in closer.quote.split(' ')"
|
v-for="(word, index) in closer.quote.split(' ')"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
><span
|
||||||
class="journey-world__word"
|
class="journey-world__word"
|
||||||
:style="{ '--i': index }"
|
:style="{ '--i': index }"
|
||||||
>{{ word }}</span></span>
|
>{{ word }}</span>{{ ' ' }}</template></span>
|
||||||
<span class="journey-world__by">{{ closer.attribution }}</span>
|
<span class="journey-world__by">{{ closer.attribution }}</span>
|
||||||
</span>
|
</span>
|
||||||
<picture v-if="!posterFailed">
|
<picture v-if="!posterFailed">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { BeamSample } from "@/journey/headlight-beam";
|
|||||||
import type { JourneySectionsBinding } from "@/composables/useJourneySections";
|
import type { JourneySectionsBinding } from "@/composables/useJourneySections";
|
||||||
import { useSpeedometer } from "@/composables/useSpeedometer";
|
import { useSpeedometer } from "@/composables/useSpeedometer";
|
||||||
import { SIGN_ACCENTS } from "@/journey/sign-routes";
|
import { SIGN_ACCENTS } from "@/journey/sign-routes";
|
||||||
|
import { useNavBarSignals } from "@/composables/useNavBarSignals";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||||
@@ -25,6 +26,14 @@ const props = defineProps<{
|
|||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
/** The bar's LCD layers (styles/nav-bar.css), in paint order: layers on the
|
||||||
|
* same z-index stack in this order. */
|
||||||
|
const LCD_PARTS = [
|
||||||
|
"fill-l", "fill-r", "unlit-l", "unlit-r", "ghost",
|
||||||
|
"glint", "glint-2", "fibre-glint", "fibre-glint-2",
|
||||||
|
"caret", "indicator", "test", "brake", "shimmer", "drop",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const navigationLabels: Readonly<Record<SectionId, string>> = {
|
const navigationLabels: Readonly<Record<SectionId, string>> = {
|
||||||
start: "Start",
|
start: "Start",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
@@ -159,6 +168,7 @@ const speedometer = useSpeedometer({
|
|||||||
return `${route} \u00b7 ${exit} \u00b7 ${ahead}`;
|
return `${route} \u00b7 ${exit} \u00b7 ${ahead}`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
useNavBarSignals(speedo, activeIndex);
|
||||||
const speedoLabel = computed(() => (speedometer.units.value === "mph"
|
const speedoLabel = computed(() => (speedometer.units.value === "mph"
|
||||||
? "Speedometer in miles per hour. Switch to kilometres per hour"
|
? "Speedometer in miles per hour. Switch to kilometres per hour"
|
||||||
: "Speedometer in kilometres per hour. Switch to miles per hour"));
|
: "Speedometer in kilometres per hour. Switch to miles per hour"));
|
||||||
@@ -172,6 +182,8 @@ const navigate = (event: MouseEvent): void => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
|
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
|
||||||
|
<span class="nav-lcd" aria-hidden="true"><i v-for="part in LCD_PARTS" :key="part" :class="`nav-lcd__${part}`"></i></span>
|
||||||
|
<span class="nav-wave" aria-hidden="true"></span>
|
||||||
<button
|
<button
|
||||||
ref="speedo"
|
ref="speedo"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
208
src/composables/useNavBarSignals.ts
Normal file
208
src/composables/useNavBarSignals.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* THE NAV BAR'S SIGNALS. The bar's lights (styles/nav-bar.css) and its wave
|
||||||
|
* (journey/nav-wave.ts) read the drive from custom properties and
|
||||||
|
* attributes on .site-navigation, so a static preview page can fake every
|
||||||
|
* one of them. This module only copies data the app already computes; it
|
||||||
|
* adds no frame loop:
|
||||||
|
*
|
||||||
|
* --nav-speed, --nav-throttle 0..1, mirrored from the speedometer's own
|
||||||
|
* --speed/--throttle writes (>= 1280 px).
|
||||||
|
* These change every frame while driving,
|
||||||
|
* so they go on .nav-lcd and .nav-wave,
|
||||||
|
* their only readers: on the nav they would
|
||||||
|
* restyle everything in it each frame
|
||||||
|
* (about 2 ms, against 0.7 ms on the LCD)
|
||||||
|
* data-nav-engine off | on | parked (the speedometer's)
|
||||||
|
* data-nav-brake, data-nav-jump present while braking / on an explicit drive
|
||||||
|
* --nav-here-x, --nav-next-x, --nav-from-x
|
||||||
|
* centre of the current, next and previous
|
||||||
|
* exit, in nav px
|
||||||
|
* data-nav-shift next | prev for SHIFT_MS after a chapter change
|
||||||
|
* data-nav-audio present while the soundtrack plays unmuted
|
||||||
|
* data-nav-hue 1 | 2 | 3: how fast the wave's hue cycle
|
||||||
|
* runs, from --nav-speed with hysteresis (on
|
||||||
|
* at 0.80 / 0.88 / 0.95, off below 0.76 /
|
||||||
|
* 0.85 / 0.92). It climbs at once and steps
|
||||||
|
* down one tier per 0.7 s; then 0 for 4 s
|
||||||
|
* while the colour settles back; absent
|
||||||
|
* otherwise
|
||||||
|
*
|
||||||
|
* Once the bar is mounted it also loads the wave (a lazy chunk). Reduced
|
||||||
|
* motion and forced colours are the stylesheet's and the wave's to handle:
|
||||||
|
* the signals still describe the drive, and the layers that read them
|
||||||
|
* either stand still or are not drawn.
|
||||||
|
*/
|
||||||
|
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue';
|
||||||
|
import type { NavWave } from '@/journey/nav-wave';
|
||||||
|
|
||||||
|
/** Long enough for the longest one-shot (the drop's ghost, 1.5 s). */
|
||||||
|
const SHIFT_MS = 1600;
|
||||||
|
const VARS = ['--nav-here-x', '--nav-next-x', '--nav-from-x'];
|
||||||
|
const LIVE_VARS = ['--nav-speed', '--nav-throttle'];
|
||||||
|
const ATTRS = ['data-nav-engine', 'data-nav-brake', 'data-nav-jump', 'data-nav-shift', 'data-nav-audio', 'data-nav-hue'];
|
||||||
|
/** The hue cycle: the speed each tier comes on at, and drops out below. */
|
||||||
|
const HUE_ON = [0.8, 0.88, 0.95];
|
||||||
|
const HUE_OFF = [0.76, 0.85, 0.92];
|
||||||
|
/** The least time on a tier before the next step down, and how long '0'
|
||||||
|
* (the colour settling back) lasts. */
|
||||||
|
const HUE_STEP_MS = 700;
|
||||||
|
const HUE_SETTLE_MS = 4000;
|
||||||
|
|
||||||
|
/** The hue tier (0..3) for a speed, given the current one: hysteresis, so
|
||||||
|
* a needle hovering on a threshold does not flicker the tier. */
|
||||||
|
export function hueTier(tier: number, speed: number): number {
|
||||||
|
let next = tier;
|
||||||
|
while (next < 3 && speed >= HUE_ON[next]!) next += 1;
|
||||||
|
while (next > 0 && speed < HUE_OFF[next - 1]!) next -= 1;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavBarSignals(anchor: Ref<HTMLElement | null>, activeIndex: Ref<number>): void {
|
||||||
|
let nav: HTMLElement | null = null;
|
||||||
|
/** Where the per-frame pair goes: the bar's LCD and wave. */
|
||||||
|
let live: HTMLElement[] = [];
|
||||||
|
let tier = 0;
|
||||||
|
let speedNow = 0;
|
||||||
|
let tierAt = 0;
|
||||||
|
let hueTimer = 0;
|
||||||
|
let shiftTimer = 0;
|
||||||
|
let shiftFrame = 0;
|
||||||
|
let wave: NavWave | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
/** Last value written per property or attribute (null: absent), so the
|
||||||
|
* speedometer's per-frame writes only touch the nav when a value changes. */
|
||||||
|
const written = new Map<string, string | null>();
|
||||||
|
let speedObserver: MutationObserver | null = null;
|
||||||
|
let controlsObserver: MutationObserver | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const set = (name: string, value: string): void => {
|
||||||
|
if (written.get(name) === value) return;
|
||||||
|
written.set(name, value);
|
||||||
|
nav?.style.setProperty(name, value);
|
||||||
|
};
|
||||||
|
const setLive = (name: string, value: string): void => {
|
||||||
|
if (written.get(name) === value) return;
|
||||||
|
written.set(name, value);
|
||||||
|
for (const element of live) element.style.setProperty(name, value);
|
||||||
|
};
|
||||||
|
const attr = (name: string, value: string | null): void => {
|
||||||
|
if (written.get(name) === value) return;
|
||||||
|
written.set(name, value);
|
||||||
|
if (value === null) nav?.removeAttribute(name);
|
||||||
|
else nav?.setAttribute(name, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = (from: number): void => {
|
||||||
|
if (!nav) return;
|
||||||
|
const box = nav.getBoundingClientRect();
|
||||||
|
if (box.width === 0) return;
|
||||||
|
// The nav scales at rest (--nav-rest-scale): measure in its own px.
|
||||||
|
const scale = nav.offsetWidth / box.width;
|
||||||
|
const items = nav.querySelectorAll('li');
|
||||||
|
const centre = (index: number): number => {
|
||||||
|
const rect = items[Math.max(0, Math.min(items.length - 1, index))]?.getBoundingClientRect();
|
||||||
|
return rect === undefined ? 0 : (rect.left - box.left + rect.width / 2) * scale;
|
||||||
|
};
|
||||||
|
const here = centre(activeIndex.value);
|
||||||
|
set('--nav-here-x', `${here.toFixed(1)}px`);
|
||||||
|
set('--nav-next-x', `${centre(activeIndex.value + 1).toFixed(1)}px`);
|
||||||
|
set('--nav-from-x', `${(from < 0 ? here : centre(from)).toFixed(1)}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The hue tier. Up at once; down one tier per HUE_STEP_MS (a timer
|
||||||
|
// carries it on once the needle has settled and stopped writing), then
|
||||||
|
// '0' for HUE_SETTLE_MS, then the attribute goes.
|
||||||
|
const stepHue = (): void => {
|
||||||
|
window.clearTimeout(hueTimer);
|
||||||
|
const wanted = hueTier(tier, speedNow);
|
||||||
|
if (wanted === tier) return;
|
||||||
|
const now = performance.now();
|
||||||
|
if (wanted < tier && now - tierAt < HUE_STEP_MS) {
|
||||||
|
hueTimer = window.setTimeout(stepHue, tierAt + HUE_STEP_MS - now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tier = wanted > tier ? wanted : tier - 1;
|
||||||
|
tierAt = now;
|
||||||
|
attr('data-nav-hue', String(tier));
|
||||||
|
if (tier > 0) hueTimer = window.setTimeout(stepHue, HUE_STEP_MS);
|
||||||
|
else hueTimer = window.setTimeout(() => { if (tier === 0) attr('data-nav-hue', null); }, HUE_SETTLE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mirrorSpeedo = (): void => {
|
||||||
|
const speedo = anchor.value;
|
||||||
|
if (!speedo) return;
|
||||||
|
const speed = speedo.style.getPropertyValue('--speed') || '0';
|
||||||
|
setLive('--nav-speed', speed);
|
||||||
|
speedNow = Number.parseFloat(speed) || 0;
|
||||||
|
if (hueTier(tier, speedNow) !== tier) stepHue();
|
||||||
|
setLive('--nav-throttle', speedo.style.getPropertyValue('--throttle') || '0');
|
||||||
|
attr('data-nav-engine', speedo.dataset.engine ?? 'off');
|
||||||
|
attr('data-nav-brake', speedo.hasAttribute('data-brake') ? '' : null);
|
||||||
|
attr('data-nav-jump', speedo.hasAttribute('data-jump') ? '' : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mirrorControls = (): void => {
|
||||||
|
const controls = document.querySelector<HTMLElement>('.drive-controls');
|
||||||
|
attr('data-nav-audio', controls?.dataset.driveState === 'running' && controls.dataset.muted === 'false' ? '' : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A chapter change: drop the one-shot now, then re-measure and restart it
|
||||||
|
// in a task after the next frame has rendered. That frame renders without
|
||||||
|
// the attribute, so the CSS animations restart even when changes come
|
||||||
|
// faster than SHIFT_MS, and the measuring reads a clean layout instead of
|
||||||
|
// forcing one inside the journey's frame callback.
|
||||||
|
watch(activeIndex, (index, previous) => {
|
||||||
|
if (!nav) return;
|
||||||
|
window.clearTimeout(shiftTimer);
|
||||||
|
window.cancelAnimationFrame(shiftFrame);
|
||||||
|
attr('data-nav-shift', null);
|
||||||
|
const direction = index > previous ? 'next' : 'prev';
|
||||||
|
shiftFrame = window.requestAnimationFrame(() => {
|
||||||
|
shiftTimer = window.setTimeout(() => {
|
||||||
|
measure(previous);
|
||||||
|
attr('data-nav-shift', direction);
|
||||||
|
shiftTimer = window.setTimeout(() => attr('data-nav-shift', null), SHIFT_MS);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nav = anchor.value?.closest<HTMLElement>('.site-navigation') ?? null;
|
||||||
|
if (!nav || !anchor.value) return;
|
||||||
|
live = [...nav.querySelectorAll<HTMLElement>(':scope > .nav-lcd, :scope > .nav-wave')];
|
||||||
|
speedObserver = new MutationObserver(mirrorSpeedo);
|
||||||
|
speedObserver.observe(anchor.value, { attributes: true, attributeFilter: ['style', 'data-engine', 'data-brake', 'data-jump'] });
|
||||||
|
// The drive controls mount after the nav; watch the body for their state.
|
||||||
|
controlsObserver = new MutationObserver(mirrorControls);
|
||||||
|
controlsObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['data-drive-state', 'data-muted'] });
|
||||||
|
resizeObserver = new ResizeObserver(() => measure(-1));
|
||||||
|
resizeObserver.observe(nav);
|
||||||
|
mirrorSpeedo();
|
||||||
|
mirrorControls();
|
||||||
|
measure(-1);
|
||||||
|
import('@/journey/nav-wave').then((module) => {
|
||||||
|
if (!disposed && nav && !wave) wave = module.mountNavWave(nav);
|
||||||
|
}, () => { /* No wave: the band stays dark, the bar works without it. */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
speedObserver?.disconnect();
|
||||||
|
controlsObserver?.disconnect();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
speedObserver = controlsObserver = null;
|
||||||
|
resizeObserver = null;
|
||||||
|
window.clearTimeout(shiftTimer);
|
||||||
|
window.cancelAnimationFrame(shiftFrame);
|
||||||
|
window.clearTimeout(hueTimer);
|
||||||
|
wave?.dispose();
|
||||||
|
wave = null;
|
||||||
|
for (const name of VARS) nav?.style.removeProperty(name);
|
||||||
|
for (const element of live) for (const name of LIVE_VARS) element.style.removeProperty(name);
|
||||||
|
live = [];
|
||||||
|
for (const name of ATTRS) nav?.removeAttribute(name);
|
||||||
|
written.clear();
|
||||||
|
nav = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
61
src/journey/card-gaps.ts
Normal file
61
src/journey/card-gaps.ts
Normal 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 });
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JourneySnapshot } from './types';
|
import type { JourneySnapshot } from './types';
|
||||||
|
import { CARD_GAP } from './card-gaps';
|
||||||
import { createRewindLatch, sectionLocalProgress } from './route-log';
|
import { createRewindLatch, sectionLocalProgress } from './route-log';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,8 +13,8 @@ import { createRewindLatch, sectionLocalProgress } from './route-log';
|
|||||||
*/
|
*/
|
||||||
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
|
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
|
||||||
|
|
||||||
/** Upper bounds of each step, in section-local progress. */
|
/** 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: 0.9 } as const;
|
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
|
* The clock reads live Bucharest time again from here. The window keeps its
|
||||||
|
|||||||
397
src/journey/nav-wave.ts
Normal file
397
src/journey/nav-wave.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* THE NAV BAR'S WAVE (a lazy chunk: composables/useNavBarSignals.ts loads
|
||||||
|
* it once the bar mounts). The lower bezel's line, drawn on a small canvas
|
||||||
|
* inside .nav-wave, so the band's LCD pixel mask and the sign's window
|
||||||
|
* cut-out (styles/nav-bar.css) apply to it.
|
||||||
|
*
|
||||||
|
* It reads two signals, so a static preview drives it by setting them:
|
||||||
|
* --nav-speed 0..1 the wave's energy: inline on .nav-wave (where
|
||||||
|
* the app writes it each frame) or on the nav
|
||||||
|
* data-nav-hue 0..3 the hue cycle's pace, on the nav; absent is off
|
||||||
|
* Its colours come from CSS (--bar-wave, --bar-hue-1..4), re-read when the
|
||||||
|
* dawn or the chapter's accent changes.
|
||||||
|
*
|
||||||
|
* Low speed: a ribbon in the manner of a console's menu waves. Three long
|
||||||
|
* sines travel at different paces, two one way and one the other, under a
|
||||||
|
* slow swell, so the line drifts and folds over itself calmly and never
|
||||||
|
* repeats or scrolls as one piece. Speed quickens the drift at once.
|
||||||
|
* High speed: a spectrum. The axis sinks to the band's floor and the line
|
||||||
|
* becomes a smooth envelope through bins that jump up fast and fall back
|
||||||
|
* slower (attack and release), grouped into travelling swells so its peaks
|
||||||
|
* read as waves rather than noise; a burst of acceleration kicks them all.
|
||||||
|
* The two blend continuously in between. The line never leaves its band.
|
||||||
|
* The hue cycle and its slow return follow data-nav-hue: in over 1.2 s,
|
||||||
|
* out over 4 s, the pace eased.
|
||||||
|
*
|
||||||
|
* One rAF loop while motion is allowed and the canvas is laid out (the
|
||||||
|
* browser pauses it in a hidden tab). Reduced motion, or data-motion not
|
||||||
|
* 'true', draws one still frame and redraws only when an input changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The spectrum's bins, px apart; the envelope is a smooth curve through them. */
|
||||||
|
const BIN = 18;
|
||||||
|
/** The wave repaints at 30 Hz; the model still steps every frame. */
|
||||||
|
const DRAW_INTERVAL = 1 / 30;
|
||||||
|
const LINE_STEP = 3; // px between line samples; the spectrum's peaks are 18 px apart
|
||||||
|
/** The hue palette's period along the bar, px. */
|
||||||
|
const HUE_PERIOD = 1440;
|
||||||
|
/** Hue tiers' pace, cycles per second: 1 slow, 2 fast, 3 both; 0 (settling) slow. */
|
||||||
|
const HUE_RATE = [1 / 8, 1 / 8, 1 / 3.4, 1 / 8 + 1 / 3.4];
|
||||||
|
/** The line's clearance from the band's edges, px. */
|
||||||
|
const EDGE = 1.6;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
/** The ribbon: [wavelength px, cycles per second (sign is direction), weight, phase]. */
|
||||||
|
const RIBBON: ReadonlyArray<readonly [number, number, number, number]> = [
|
||||||
|
[430, 0.09, 0.55, 0.3],
|
||||||
|
[265, -0.13, 0.4, 1.9],
|
||||||
|
[150, 0.2, 0.22, 4.1],
|
||||||
|
];
|
||||||
|
/** The ribbon's soft limit: full where the strands meet, never clipped flat. */
|
||||||
|
const RIBBON_DRIVE = 1.5;
|
||||||
|
|
||||||
|
const smoothstep = (a: number, b: number, v: number): number => {
|
||||||
|
const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
const ease = (dt: number, tau: number): number => 1 - Math.exp(-dt / tau);
|
||||||
|
const clamp01 = (v: number): number => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
||||||
|
|
||||||
|
export interface WaveFrame {
|
||||||
|
/** Smoothed speed, 0..1. */
|
||||||
|
speed: number;
|
||||||
|
/** 0 ribbon .. 1 spectrum: the blend, and how far the axis has sunk. */
|
||||||
|
spectrum: number;
|
||||||
|
/** 0 standard colour .. 1 palette, eased. */
|
||||||
|
hueMix: number;
|
||||||
|
/** The palette's offset along the bar, in periods (0..1). */
|
||||||
|
hueOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaveModel {
|
||||||
|
/** Advance by dt seconds toward a raw speed and a hue tier (-1: absent). */
|
||||||
|
step(dt: number, speed: number, hueTier: number): WaveFrame;
|
||||||
|
/** The still frame (reduced motion): the ribbon at rest at this speed's
|
||||||
|
* amplitude, whatever the speed, in the standard colour. */
|
||||||
|
still(speed: number): WaveFrame;
|
||||||
|
/** Fit the spectrum's bins to a width in px. */
|
||||||
|
resize(width: number): void;
|
||||||
|
/** The axis's y (px from the band's top), for a band of height h. */
|
||||||
|
axisY(h: number): number;
|
||||||
|
/** The line's y (px from the band's top) at x, for a band of height h. */
|
||||||
|
lineY(x: number, h: number): number;
|
||||||
|
/** A bin's level, 0..1. */
|
||||||
|
level(index: number): number;
|
||||||
|
readonly binCount: number;
|
||||||
|
readonly frame: WaveFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small seeded generator, so the visualizer is the same on every load. */
|
||||||
|
function random(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWaveModel(seed = 17): WaveModel {
|
||||||
|
const rnd = random(seed);
|
||||||
|
const frame: WaveFrame = { speed: 0.37, spectrum: 0, hueMix: 0, hueOffset: 0 };
|
||||||
|
/** The ribbon's own clock, in seconds of drift: it runs faster with speed. */
|
||||||
|
let drift = 0;
|
||||||
|
/** The spectrum's swells' clock. */
|
||||||
|
let swell = 0;
|
||||||
|
let lastSpeed = 0.37;
|
||||||
|
let kick = 0;
|
||||||
|
let hueRate = 0;
|
||||||
|
let hueLinear = 0;
|
||||||
|
let levels = new Float32Array(0);
|
||||||
|
let target = new Float32Array(0);
|
||||||
|
let timer = new Float32Array(0);
|
||||||
|
|
||||||
|
const ribbon = (x: number): number => {
|
||||||
|
let v = 0;
|
||||||
|
for (const [length, rate, weight, offset] of RIBBON) v += weight * Math.sin((x * TAU) / length - TAU * rate * drift + offset);
|
||||||
|
// A slow swell along the bar, so the ribbon is fuller in places.
|
||||||
|
const swellAt = 0.82 + 0.18 * Math.sin((x * TAU) / 1900 + TAU * 0.03 * drift);
|
||||||
|
return (Math.tanh(RIBBON_DRIVE * v) / Math.tanh(RIBBON_DRIVE * 1.2)) * swellAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The spectrum at x: Catmull-Rom through the bins, 0..1. */
|
||||||
|
const envelope = (x: number): number => {
|
||||||
|
const n = levels.length;
|
||||||
|
if (n === 0) return 0;
|
||||||
|
const at = x / BIN;
|
||||||
|
const i = Math.floor(at);
|
||||||
|
const t = at - i;
|
||||||
|
const p = (k: number): number => levels[Math.min(n - 1, Math.max(0, k))]!;
|
||||||
|
const p0 = p(i - 1), p1 = p(i), p2 = p(i + 1), p3 = p(i + 2);
|
||||||
|
const v = p1 + 0.5 * t * (p2 - p0 + t * (2 * p0 - 5 * p1 + 4 * p2 - p3 + t * (3 * (p1 - p2) + p3 - p0)));
|
||||||
|
return clamp01(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const model: WaveModel = {
|
||||||
|
frame,
|
||||||
|
get binCount() { return levels.length; },
|
||||||
|
resize(width) {
|
||||||
|
const count = Math.max(2, Math.ceil(width / BIN) + 1);
|
||||||
|
if (count === levels.length) return;
|
||||||
|
levels = new Float32Array(count);
|
||||||
|
target = new Float32Array(count);
|
||||||
|
timer = new Float32Array(count).map(() => rnd() * 0.2);
|
||||||
|
},
|
||||||
|
step(dt, speed, hueTier) {
|
||||||
|
const raw = clamp01(speed);
|
||||||
|
frame.speed += (raw - frame.speed) * ease(dt, 0.06);
|
||||||
|
const s = frame.speed;
|
||||||
|
frame.spectrum = smoothstep(0.5, 0.9, s);
|
||||||
|
// The ribbon drifts faster with speed, from a calm 1x at rest to 5x flat out.
|
||||||
|
drift = (drift + dt * (1 + 4 * s * s)) % 10000;
|
||||||
|
swell = (swell + dt * (0.6 + 1.6 * s)) % 10000;
|
||||||
|
|
||||||
|
// A rise in speed kicks the spectrum; it settles over a quarter second.
|
||||||
|
kick = Math.min(0.6, kick * (1 - ease(dt, 0.25)) + Math.max(0, raw - lastSpeed) * 1.5);
|
||||||
|
lastSpeed = raw;
|
||||||
|
|
||||||
|
// Bins re-roll their targets, faster with energy; each rises fast
|
||||||
|
// (attack) and falls back slower (release). Targets are shaped by two
|
||||||
|
// travelling swells, so the peaks gather into waves.
|
||||||
|
const energy = smoothstep(0.3, 0.9, s);
|
||||||
|
const pace = 1.5 - 0.9 * energy;
|
||||||
|
for (let i = 0; i < levels.length; i += 1) {
|
||||||
|
timer[i] = timer[i]! - dt;
|
||||||
|
if (timer[i]! <= 0) {
|
||||||
|
const shape = 0.5 + 0.3 * Math.sin((i * TAU) / 11 - swell * 2.1) + 0.2 * Math.sin((i * TAU) / 5.3 + swell * 3.3);
|
||||||
|
target[i] = Math.min(1, energy * (0.06 + Math.pow(rnd(), 1.5)) * (0.3 + 0.9 * shape) + kick);
|
||||||
|
timer[i] = (0.05 + 0.13 * rnd()) * pace;
|
||||||
|
}
|
||||||
|
const level = levels[i]!;
|
||||||
|
const want = target[i]!;
|
||||||
|
levels[i] = level + (want - level) * ease(dt, want > level ? 0.035 : 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hue: in over 1.2 s, out over 4 s; the pace eases between tiers.
|
||||||
|
const on = hueTier >= 1;
|
||||||
|
hueLinear = on ? Math.min(1, hueLinear + dt / 1.2) : Math.max(0, hueLinear - dt / 4);
|
||||||
|
frame.hueMix = hueLinear * hueLinear * (3 - 2 * hueLinear);
|
||||||
|
const wantRate = hueTier >= 0 ? HUE_RATE[Math.min(3, hueTier)]! : hueLinear > 0 ? HUE_RATE[0]! : 0;
|
||||||
|
hueRate += (wantRate - hueRate) * ease(dt, 0.8);
|
||||||
|
frame.hueOffset = (frame.hueOffset + hueRate * dt) % 1;
|
||||||
|
return frame;
|
||||||
|
},
|
||||||
|
still(speed) {
|
||||||
|
frame.speed = clamp01(speed);
|
||||||
|
lastSpeed = frame.speed;
|
||||||
|
// Always the calm ribbon: a silent spectrum would be a flat line.
|
||||||
|
frame.spectrum = 0;
|
||||||
|
drift = 0;
|
||||||
|
kick = 0;
|
||||||
|
levels.fill(0);
|
||||||
|
target.fill(0);
|
||||||
|
hueLinear = 0;
|
||||||
|
hueRate = 0;
|
||||||
|
frame.hueMix = 0;
|
||||||
|
return frame;
|
||||||
|
},
|
||||||
|
axisY(h) {
|
||||||
|
// Centred for the ribbon; on the floor for the spectrum, so its peaks
|
||||||
|
// have the whole band to rise into.
|
||||||
|
return h / 2 + (h / 2 - EDGE - 0.4) * frame.spectrum;
|
||||||
|
},
|
||||||
|
lineY(x, h) {
|
||||||
|
const m = frame.spectrum;
|
||||||
|
const half = h / 2 - EDGE;
|
||||||
|
const amp = half * (0.74 + 0.22 * smoothstep(0.3, 0.7, frame.speed));
|
||||||
|
// Each keeps more of itself through the blend than a straight mix, so
|
||||||
|
// the middle of the dial is lively rather than the two cancelling out.
|
||||||
|
const up = Math.pow(1 - m, 0.7) * amp * ribbon(x) + Math.pow(m, 0.8) * (h - 2 * EDGE - 0.4) * envelope(x);
|
||||||
|
return Math.min(h - EDGE, Math.max(EDGE, model.axisY(h) - up));
|
||||||
|
},
|
||||||
|
level(index) {
|
||||||
|
return levels[index] ?? 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rgb = [number, number, number];
|
||||||
|
|
||||||
|
function parseColour(value: string): Rgb | null {
|
||||||
|
const rgb = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(value);
|
||||||
|
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
||||||
|
const srgb = /color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(value);
|
||||||
|
if (srgb) return [Number(srgb[1]) * 255, Number(srgb[2]) * 255, Number(srgb[3]) * 255];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const css = ([r, g, b]: Rgb): string => `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)})`;
|
||||||
|
const mix = (a: Rgb, b: Rgb, t: number): Rgb => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||||
|
|
||||||
|
export interface NavWave {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountNavWave(nav: HTMLElement): NavWave | null {
|
||||||
|
const slot = nav.querySelector<HTMLElement>(':scope > .nav-wave');
|
||||||
|
if (!slot) return null;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.setAttribute('aria-hidden', 'true');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
slot.append(canvas);
|
||||||
|
|
||||||
|
const model = createWaveModel();
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const forced = window.matchMedia('(forced-colors: active)');
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let raf = 0;
|
||||||
|
let last = 0;
|
||||||
|
let sinceDraw = 0;
|
||||||
|
let standard: Rgb = [214, 160, 214];
|
||||||
|
let palette: Rgb[] = [];
|
||||||
|
|
||||||
|
// Colours stay in CSS (--bar-wave, --bar-hue-1..4); resolve them through
|
||||||
|
// the canvas's own color, only on mount and when the dawn or the accent
|
||||||
|
// (the chapter's --sign-accent, which --bar-wave mixes in) changes.
|
||||||
|
let accent = '';
|
||||||
|
const readColours = (): void => {
|
||||||
|
const read = (name: string): Rgb | null => {
|
||||||
|
canvas.style.color = `var(${name})`;
|
||||||
|
return parseColour(getComputedStyle(canvas).color);
|
||||||
|
};
|
||||||
|
standard = read('--bar-wave') ?? standard;
|
||||||
|
palette = [1, 2, 3, 4].map((n) => read(`--bar-hue-${n}`)).filter((c): c is Rgb => c !== null);
|
||||||
|
canvas.style.removeProperty('color');
|
||||||
|
accent = nav.style.getPropertyValue('--sign-accent');
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputs = (): [number, number] => {
|
||||||
|
const speed = Number.parseFloat(slot.style.getPropertyValue('--nav-speed') || nav.style.getPropertyValue('--nav-speed'));
|
||||||
|
const hue = nav.getAttribute('data-nav-hue');
|
||||||
|
return [Number.isFinite(speed) ? speed : 0.37, hue === null ? -1 : Number(hue) || 0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = (): void => {
|
||||||
|
if (width === 0 || height === 0) return;
|
||||||
|
const dpr = canvas.width / width;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
const f = model.frame;
|
||||||
|
let ink: string | CanvasGradient = css(standard);
|
||||||
|
if (f.hueMix > 0.002 && palette.length === 4) {
|
||||||
|
// The palette, one period per HUE_PERIOD px, drifting with the offset.
|
||||||
|
const start = -f.hueOffset * HUE_PERIOD;
|
||||||
|
const periods = Math.ceil((width - start) / HUE_PERIOD);
|
||||||
|
const gradient = ctx.createLinearGradient(start, 0, start + periods * HUE_PERIOD, 0);
|
||||||
|
for (let p = 0; p < periods; p += 1) {
|
||||||
|
for (let k = 0; k < 4; k += 1) gradient.addColorStop((p + k / 4) / periods, css(mix(standard, palette[k]!, f.hueMix)));
|
||||||
|
}
|
||||||
|
gradient.addColorStop(1, css(mix(standard, palette[0]!, f.hueMix)));
|
||||||
|
ink = gradient;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = ink;
|
||||||
|
ctx.strokeStyle = ink;
|
||||||
|
const axis = model.axisY(height);
|
||||||
|
// The axis, faint, so the ribbon reads as a function on a line; at speed
|
||||||
|
// it is the spectrum's floor.
|
||||||
|
ctx.globalAlpha = 0.16;
|
||||||
|
ctx.fillRect(0, axis - 0.5, width, 1);
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let x = 0; x <= width + LINE_STEP; x += LINE_STEP) {
|
||||||
|
const at = Math.min(x, width);
|
||||||
|
const y = model.lineY(at, height);
|
||||||
|
if (x === 0) ctx.moveTo(at, y);
|
||||||
|
else ctx.lineTo(at, y);
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 0.78;
|
||||||
|
ctx.lineWidth = 2.6;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
// A body between the axis and the line: faint for the ribbon, fuller
|
||||||
|
// for the spectrum, whose silhouette it fills in.
|
||||||
|
ctx.lineTo(width, axis);
|
||||||
|
ctx.lineTo(0, axis);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.globalAlpha = 0.16 + 0.22 * f.spectrum;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moving = (): boolean => nav.dataset.motion === 'true' && !reduce.matches && !forced.matches && width > 0;
|
||||||
|
|
||||||
|
const tick = (now: number): void => {
|
||||||
|
raf = 0;
|
||||||
|
if (!moving()) return;
|
||||||
|
const dt = last === 0 ? 1 / 60 : Math.min(0.05, Math.max(0, (now - last) / 1000));
|
||||||
|
last = now;
|
||||||
|
const [speed, hue] = inputs();
|
||||||
|
model.step(dt, speed, hue);
|
||||||
|
// Repaint at most 30 times a second: each repaint costs the page a
|
||||||
|
// canvas upload, and the eye reads the wave as smooth at that rate.
|
||||||
|
sinceDraw += dt;
|
||||||
|
if (sinceDraw >= DRAW_INTERVAL - 0.004) {
|
||||||
|
sinceDraw = 0;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
raf = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A still frame: the wave at its crest, the visualizer at rest.
|
||||||
|
const still = (): void => {
|
||||||
|
model.still(inputs()[0]);
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (): void => {
|
||||||
|
if (moving()) {
|
||||||
|
if (raf === 0) { last = 0; raf = window.requestAnimationFrame(tick); }
|
||||||
|
} else {
|
||||||
|
if (raf !== 0) { window.cancelAnimationFrame(raf); raf = 0; }
|
||||||
|
still();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resize = new ResizeObserver(([entry]) => {
|
||||||
|
if (!entry) return;
|
||||||
|
width = entry.contentRect.width;
|
||||||
|
height = entry.contentRect.height;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
model.resize(width);
|
||||||
|
// Resizing the canvas clears it: repaint now rather than on the loop's
|
||||||
|
// next 30 Hz tick, so a resize never shows an empty band.
|
||||||
|
if (raf === 0) update();
|
||||||
|
else draw();
|
||||||
|
});
|
||||||
|
resize.observe(canvas);
|
||||||
|
|
||||||
|
// Without motion the still frame follows the inputs; with motion the
|
||||||
|
// loop reads them itself and this only notices data-motion changing, and
|
||||||
|
// a new chapter's accent (an inline style read, so no style recalc unless
|
||||||
|
// it changed).
|
||||||
|
const navObserver = new MutationObserver(() => {
|
||||||
|
if (nav.style.getPropertyValue('--sign-accent') !== accent) readColours();
|
||||||
|
if (raf === 0 || !moving()) update();
|
||||||
|
});
|
||||||
|
navObserver.observe(nav, { attributes: true, attributeFilter: ['style', 'data-nav-hue', 'data-motion'] });
|
||||||
|
navObserver.observe(slot, { attributes: true, attributeFilter: ['style'] });
|
||||||
|
const dawnObserver = new MutationObserver(() => { readColours(); if (raf === 0) still(); });
|
||||||
|
dawnObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-dawn'] });
|
||||||
|
reduce.addEventListener('change', update);
|
||||||
|
forced.addEventListener('change', update);
|
||||||
|
|
||||||
|
readColours();
|
||||||
|
return {
|
||||||
|
dispose() {
|
||||||
|
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||||
|
raf = 0;
|
||||||
|
resize.disconnect();
|
||||||
|
navObserver.disconnect();
|
||||||
|
dawnObserver.disconnect();
|
||||||
|
reduce.removeEventListener('change', update);
|
||||||
|
forced.removeEventListener('change', update);
|
||||||
|
canvas.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
* pure. Scroll-driven, so scrolling back takes the masts offline again.
|
* 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. */
|
/** Chapter-local progress at which each mast comes online. */
|
||||||
export const RELAY_STOPS = [0.04, 0.22, 0.4, 0.58] as const;
|
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. */
|
/** 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). */
|
/** Beacon blink rate (photosensitivity: well under 3 Hz). */
|
||||||
export const BEACON_HZ = 0.5;
|
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). */
|
/** How many masts are online at chapter progress p (0 outside the chapter). */
|
||||||
export const relayOnline = (p: number | null): number =>
|
export const relayOnline = (p: number | null): number =>
|
||||||
p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length;
|
p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
* dims the plate and runs the crane back.
|
* dims the plate and runs the crane back.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Section-local progress of each stop (one per additional project). */
|
import { CARD_GAP } from './card-gaps';
|
||||||
export const YARD_STOPS = [0.1, 0.33, 0.56, 0.79] as const;
|
|
||||||
|
/** 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. */
|
/** The crane stands still this far either side of a stop while the spreader works. */
|
||||||
export const YARD_HOLD = 0.06;
|
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,
|
* 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.
|
* Non-decreasing in p.
|
||||||
*/
|
*/
|
||||||
export function craneZ(p: number): number {
|
export function craneZ(p: number): number {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import { CARD_GAP } from '@/journey/card-gaps';
|
||||||
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
|
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
|
* 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
|
* backup packets stream to an offsite silhouette on the far right, rain
|
||||||
* starts on the windshield, then the service re-lights in the infrastructure
|
* 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
|
* scrolling back up replays it in reverse. Only the distant lightning keeps
|
||||||
* its own (bounded) clock.
|
* its own (bounded) clock.
|
||||||
*
|
*
|
||||||
@@ -48,9 +50,9 @@ export function maintenanceTimeline(p: number): MaintenanceFrame {
|
|||||||
return {
|
return {
|
||||||
step: maintenanceStep(x),
|
step: maintenanceStep(x),
|
||||||
dim: smooth(0.1, 0.35, 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)),
|
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),
|
offsite: smooth(0.14, 0.36, x),
|
||||||
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, 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)),
|
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
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 type { SceneResourceBag } from '@/scene/resources';
|
||||||
import { CHAPTER_COMPOSITIONS, RELAY_NUDGE } from './composition';
|
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
|
* RELAY STATION, scene side. Four lattice transmitter masts, one per toolkit
|
||||||
* group, stand on the far shoulders, alternating sides (first on the right).
|
* 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
|
* 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
|
* bottom-up (one per technology) and a red beacon blinks at 0.5 Hz. The
|
||||||
* additive beam sweeps the sky every 6 s. Every beat is a pure function of
|
* 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.
|
* section-local progress, so scrolling back takes the masts offline again.
|
||||||
* One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a
|
* One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a
|
||||||
* technology flashes its rung. Lite (low tier, touch, data saver): all rungs
|
* 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);
|
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. */
|
/** 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 {
|
export interface RelayRung {
|
||||||
/** World height of the rung's centre. */
|
/** 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 dt = Math.max(0, deltaSeconds);
|
||||||
const live = section === 'toolkit' ? p : null;
|
const live = section === 'toolkit' ? p : null;
|
||||||
const masts = relayLayout(live, readRelay().counts, full, aspect);
|
const masts = relayLayout(live, readRelay().counts, full, aspect);
|
||||||
|
const beams = stationLive(live);
|
||||||
const changed = section !== lastSection;
|
const changed = section !== lastSection;
|
||||||
lastSection = section;
|
lastSection = section;
|
||||||
|
|
||||||
@@ -498,9 +501,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
|||||||
if (beamMesh !== null && beamState !== null) {
|
if (beamMesh !== null && beamState !== null) {
|
||||||
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
|
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
|
||||||
rotation.setFromAxisAngle(forward, sweep);
|
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)));
|
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();
|
rotation.identity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
|
import { CARD_GAP } from '@/journey/card-gaps';
|
||||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||||
import {
|
import {
|
||||||
CRANE_FROM_Z,
|
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)));
|
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
|
||||||
return t * t * (3 - 2 * t);
|
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 z = craneZ(live ?? 0);
|
||||||
const worldZ = group.position.z + z * Math.cos(yaw);
|
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));
|
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;
|
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);
|
const light = floodLight(warm);
|
||||||
flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0);
|
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;
|
pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0;
|
||||||
|
|||||||
@@ -419,6 +419,25 @@ html .site-navigation[data-enhanced='true'][data-docked='true']::before {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Narrow wide (1280-1439): eight exits share ~470 px, which squeezed each
|
||||||
|
label to ~58 px and broke "Experience" mid-word. A slightly narrower sign
|
||||||
|
window and a tighter type size give each exit ~64 px; words never split. */
|
||||||
|
@media (min-width: 1280px) and (max-width: 1439px) {
|
||||||
|
:root {
|
||||||
|
--window-w: 288px;
|
||||||
|
}
|
||||||
|
html .site-navigation ul {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) calc(var(--window-w) + 16px) repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
html .site-navigation a {
|
||||||
|
padding-inline: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: keep-all;
|
||||||
|
hyphens: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (min-width: 768px) and (max-width: 1099px) {
|
@media (min-width: 768px) and (max-width: 1099px) {
|
||||||
html .site-navigation a::before { display: none; }
|
html .site-navigation a::before { display: none; }
|
||||||
html .site-navigation a { font-size: 0.68rem; line-height: 1.05; }
|
html .site-navigation a { font-size: 0.68rem; line-height: 1.05; }
|
||||||
|
|||||||
471
src/styles/nav-bar.css
Normal file
471
src/styles/nav-bar.css
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
THE NAV BAR: an edge-lit LCD.
|
||||||
|
|
||||||
|
The bar is a dashboard display lit from its top edge. A fibre runs along
|
||||||
|
the top and feeds a backlight into the plate; the dash draws its gauges
|
||||||
|
in LCD pixels in front of it:
|
||||||
|
- the bargraph (two pixel rows near the top): how hard the drive is
|
||||||
|
pulling, filling outward from both ends toward the sign's window, cool
|
||||||
|
to warm to red, with light flowing behind its pixels;
|
||||||
|
- the caret over the current exit, and an indicator by the next one
|
||||||
|
while the gantry announces it;
|
||||||
|
- a drop of light that runs from the old exit to the new one on a
|
||||||
|
change of chapter, and lands with a ripple;
|
||||||
|
- route glints drifting from the current exit to the next one;
|
||||||
|
- the brake columns at both ends, the self-test when the engine starts
|
||||||
|
and the fibre's shimmer while the soundtrack plays;
|
||||||
|
- the wave in the lower bezel (journey/nav-wave.ts, a canvas): a calm
|
||||||
|
ribbon at cruise, a spectrum at speed, with a hue cycle near the top
|
||||||
|
of the dial.
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
- The drive reaches the bar as custom properties and attributes on
|
||||||
|
.site-navigation (composables/useNavBarSignals.ts), so a static page
|
||||||
|
can fake every one: --nav-speed, --nav-throttle (0..1; the app
|
||||||
|
writes these two on .nav-lcd and .nav-wave instead, to keep the
|
||||||
|
per-frame restyle small),
|
||||||
|
data-nav-engine, data-nav-brake, data-nav-jump, data-nav-shift
|
||||||
|
(next|prev), data-nav-audio, data-nav-hue, --nav-here-x / --nav-next-x
|
||||||
|
/ --nav-from-x (px), html[data-gantry], html[data-underpass],
|
||||||
|
html[data-dawn].
|
||||||
|
- The moving parts live in two decorative spans (SiteNavigation.vue):
|
||||||
|
.nav-lcd carries a copy of gantry.css's mask, so the sign window, the
|
||||||
|
8 px bulge and the sign are untouched; .nav-wave is masked to the
|
||||||
|
wave's band.
|
||||||
|
- Light moves under static masks (pixels): only transform, translate,
|
||||||
|
scale and opacity animate, and the signals change custom properties,
|
||||||
|
never layout.
|
||||||
|
- State (the bargraph's level, the caret) is drawn in every mode;
|
||||||
|
animation and transitions run only with prefers-reduced-motion:
|
||||||
|
no-preference and data-motion='true'. Forced colours: no layers.
|
||||||
|
- The label band (numerals 21-30 px, labels 36-49 px at 72 px) is kept
|
||||||
|
clear: the light lives above it (0-20 px) and in the lower bezel.
|
||||||
|
- Specificity: these rules are written against `html:root
|
||||||
|
.site-navigation`. :root always matches; it gives the bar the one
|
||||||
|
class of weight over gantry.css's `html .site-navigation` rules that
|
||||||
|
the bar is tuned to win by.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ---------- Palette ---------- */
|
||||||
|
html:root {
|
||||||
|
--bar-mark: 191 174 211;
|
||||||
|
--bar-base-1: #110b1d;
|
||||||
|
--bar-base-2: #0c0815;
|
||||||
|
/* The wave's hue cycle at speed: neon pink, violet, cyan, coral. All
|
||||||
|
light enough to stand off the dark slab. */
|
||||||
|
--bar-hue-1: #ff6fb1;
|
||||||
|
--bar-hue-2: #a985ff;
|
||||||
|
--bar-hue-3: #55dcff;
|
||||||
|
--bar-hue-4: #ff8c6c;
|
||||||
|
}
|
||||||
|
html:root:is([data-dawn='sunrise'], [data-dawn='day']) {
|
||||||
|
--bar-mark: 255 206 150;
|
||||||
|
--bar-base-1: #161018;
|
||||||
|
--bar-base-2: #0f0a12;
|
||||||
|
/* Warmer with the sunrise: coral, amber, rose, lilac. */
|
||||||
|
--bar-hue-1: #ff8c6c;
|
||||||
|
--bar-hue-2: #ffc670;
|
||||||
|
--bar-hue-3: #ff70a6;
|
||||||
|
--bar-hue-4: #c39bff;
|
||||||
|
}
|
||||||
|
html:root .site-navigation {
|
||||||
|
--bar-on: 1;
|
||||||
|
--bar-light: var(--sign-accent, #ff83c2);
|
||||||
|
--bar-core: color-mix(in srgb, var(--bar-light) 45%, white);
|
||||||
|
--bar-cool: color-mix(in srgb, var(--bar-light) 78%, white);
|
||||||
|
--bar-glow: color-mix(in srgb, var(--bar-light) 70%, transparent);
|
||||||
|
--bar-warm: #ffd693;
|
||||||
|
--bar-red: #ff5a6e;
|
||||||
|
/* The backlight the plate sheds behind the LCD. */
|
||||||
|
--bar-spill: 30%;
|
||||||
|
--bar-dark: color-mix(in srgb, var(--bar-base-2) 80%, transparent);
|
||||||
|
/* Each half of the bargraph reaches from its outer end to the window. */
|
||||||
|
--bar-reach: calc(50cqw - var(--window-w) / 2 - 28px);
|
||||||
|
/* The drop's row: the middle of the bargraph. */
|
||||||
|
--bar-fy: 8px;
|
||||||
|
/* The wave's band in the lower bezel. The ribbon swings about its middle;
|
||||||
|
the spectrum rises from its floor to just under the labels. */
|
||||||
|
--bar-band-top: calc(var(--gantry-h) - 20px);
|
||||||
|
--bar-band-h: 18px;
|
||||||
|
/* The wave's standard colour (the canvas resolves it, and the palette). */
|
||||||
|
--bar-wave: color-mix(in srgb, var(--bar-cool) 55%, rgb(var(--bar-mark)));
|
||||||
|
}
|
||||||
|
html:root:is([data-dawn='sunrise'], [data-dawn='day']) .site-navigation { --bar-spill: 20%; }
|
||||||
|
html:root .site-navigation:is([data-nav-engine='off'], [data-nav-engine='parked']) { --bar-on: 0; }
|
||||||
|
/* The bargraph's level, and the fast run of light behind it that shows as
|
||||||
|
the drive picks up. They are resolved on .nav-lcd, not the nav: the
|
||||||
|
speed changes every frame while driving, and a custom property changing
|
||||||
|
on the nav restyles everything in it (about 2 ms a frame), where on the
|
||||||
|
LCD it stays inside the lights (see useNavBarSignals.ts). */
|
||||||
|
html:root .site-navigation > .nav-lcd {
|
||||||
|
--bar-level: calc(var(--bar-on) * (0.08 + var(--nav-speed, 0.37) * 0.6 + var(--nav-throttle, 0) * 0.32));
|
||||||
|
--bar-fast: clamp(0, var(--nav-speed, 0.37) * 1.5 + var(--nav-throttle, 0) - 0.45, 1);
|
||||||
|
}
|
||||||
|
html:root .site-navigation[data-nav-jump] > .nav-lcd { --bar-fast: 1; }
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
html:root .site-navigation { --bar-reach: calc(50cqw - 8px); }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* A 56 px bar: the band shrinks with it. */
|
||||||
|
html:root .site-navigation { --bar-band-top: calc(var(--gantry-h) - 16px); --bar-band-h: 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- The slab. The LCD's black matrix is full over the bargraph's
|
||||||
|
two pixel rows and only a faint etch below them. ---------- */
|
||||||
|
html:root .site-navigation:is(*, [data-enhanced='true'][data-docked='true'])::before {
|
||||||
|
background:
|
||||||
|
/* The fibre along the top edge, feeding the plate: a hot core, then a
|
||||||
|
prismatic fringe where it enters (warm over cool). */
|
||||||
|
linear-gradient(180deg, var(--bar-core) 0 1px, color-mix(in srgb, var(--bar-light) 55%, transparent) 1px 2px, rgb(255 120 150 / 22%) 2px 3px, rgb(110 200 255 / 18%) 3px 4px, transparent 4px) 0 0 / 100% 4px no-repeat,
|
||||||
|
/* The bargraph's rows: the full black matrix, the backlight showing
|
||||||
|
through 3 px pixels... */
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 3px, rgb(4 2 10 / 62%) 3px 4px) 0 4px / 100% 8px no-repeat,
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 3px, rgb(4 2 10 / 62%) 3px 4px) 0 4px / 100% 8px no-repeat,
|
||||||
|
/* ...and below them only a faint etch, over an even tint that keeps
|
||||||
|
the slab as dark as a full matrix would. */
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 3px, rgb(4 2 10 / 13%) 3px 4px) 0 12px / 100% calc(var(--gantry-h) - 14px) no-repeat,
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 3px, rgb(4 2 10 / 13%) 3px 4px) 0 12px / 100% calc(var(--gantry-h) - 14px) no-repeat,
|
||||||
|
linear-gradient(rgb(4 2 10 / 22%), rgb(4 2 10 / 22%)) 0 12px / 100% calc(var(--gantry-h) - 14px) no-repeat,
|
||||||
|
/* Recessed lower bezel with the light's hairline. */
|
||||||
|
linear-gradient(180deg, rgb(0 0 0 / 60%) 0 1px, color-mix(in srgb, var(--bar-light) 30%, transparent) 1px 2px) 0 calc(var(--gantry-h) - 2px) / 100% 2px no-repeat,
|
||||||
|
/* The backlight: fed at the top edge and both ends, falling off downward. */
|
||||||
|
radial-gradient(22% 140% at 0 0, color-mix(in srgb, var(--bar-light) 16%, transparent), transparent 70%) 0 0 / 100% var(--gantry-h) no-repeat,
|
||||||
|
radial-gradient(22% 140% at 100% 0, color-mix(in srgb, var(--bar-light) 16%, transparent), transparent 70%) 0 0 / 100% var(--gantry-h) no-repeat,
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--bar-light) var(--bar-spill), transparent) 2px, color-mix(in srgb, var(--bar-light) 9%, transparent) 16px, color-mix(in srgb, var(--bar-light) 3%, transparent) 40%, transparent 72%) 0 0 / 100% var(--gantry-h) no-repeat,
|
||||||
|
/* The unlit pixels themselves, and the panel. */
|
||||||
|
linear-gradient(rgb(var(--bar-mark) / 4%), rgb(var(--bar-mark) / 4%)),
|
||||||
|
linear-gradient(90deg, var(--bar-base-1), var(--bar-base-2) 50%, var(--bar-base-1));
|
||||||
|
box-shadow: 0 1px 0 rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
html:root .site-navigation:is(*, [data-enhanced='true'][data-docked='true'])::before {
|
||||||
|
background: Canvas;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- The layers ---------- */
|
||||||
|
.nav-lcd,
|
||||||
|
.nav-wave { display: none; }
|
||||||
|
@media (forced-colors: none) {
|
||||||
|
html:root .site-navigation > .nav-lcd {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1; /* After the bar's ::before in tree order: over it, under the labels. */
|
||||||
|
inset: 0 0 auto;
|
||||||
|
block-size: calc(var(--gantry-h) + var(--bulge));
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
contain: strict;
|
||||||
|
container-type: inline-size;
|
||||||
|
/* Same cut-out as the beam (gantry.css): the sign window stays clear. */
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat,
|
||||||
|
linear-gradient(#000 0 0) 50% 0 / calc(var(--window-w) + 56px) 100% no-repeat,
|
||||||
|
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
|
||||||
|
-webkit-mask-composite: xor, source-over;
|
||||||
|
mask:
|
||||||
|
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat,
|
||||||
|
linear-gradient(#000 0 0) 50% 0 / calc(var(--window-w) + 56px) calc(var(--gantry-h) + var(--bulge)) no-repeat,
|
||||||
|
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
|
||||||
|
mask-composite: exclude, add;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > i {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > i::before,
|
||||||
|
html:root .site-navigation > .nav-lcd > i::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Exit numerals at full strength, so moving light never takes them below 4.5:1. */
|
||||||
|
html:root .site-navigation a::before { opacity: 1; }
|
||||||
|
|
||||||
|
/* The bargraph: two rows of LCD pixels, tinted cool, warm, red along the
|
||||||
|
scale, filling from each outer end toward the window. The light
|
||||||
|
flowing in the plate behind them is what lights them. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r {
|
||||||
|
z-index: 1;
|
||||||
|
inset: 0;
|
||||||
|
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect;
|
||||||
|
background: linear-gradient(90deg, var(--bar-cool) 0, var(--bar-cool) calc(var(--bar-reach) * 0.6), var(--bar-warm) calc(var(--bar-reach) * 0.78), var(--bar-red) calc(var(--bar-reach) * 0.92));
|
||||||
|
clip-path: inset(0 50% 0 0);
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r {
|
||||||
|
background: linear-gradient(270deg, var(--bar-cool) 0, var(--bar-cool) calc(var(--bar-reach) * 0.6), var(--bar-warm) calc(var(--bar-reach) * 0.78), var(--bar-red) calc(var(--bar-reach) * 0.92));
|
||||||
|
clip-path: inset(0 0 0 50%);
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l::before, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l::after, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r::before, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r::after {
|
||||||
|
inset: 0 auto 0 -600px;
|
||||||
|
inline-size: calc(100% + 1200px);
|
||||||
|
background: rgb(255 252 246 / 85%);
|
||||||
|
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='a'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.025' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.064' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.123' stop-color='%23fff' stop-opacity='0.73'/><stop offset='0.169' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.55'/><stop offset='0.253' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.309' stop-color='%23fff' stop-opacity='0.97'/><stop offset='0.370' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.419' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.469' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.496' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.531' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.563' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.588' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.627' stop-color='%23fff' stop-opacity='0.27'/><stop offset='0.653' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.687' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.729' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.765' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.800' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.845' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.887' stop-color='%23fff' stop-opacity='0.25'/><stop offset='0.936' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.989' stop-color='%23fff' stop-opacity='0.40'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.48'/></linearGradient><rect width='600' height='8' fill='url(%23a)'/></svg>") 0 0 / 600px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='a'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.025' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.064' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.123' stop-color='%23fff' stop-opacity='0.73'/><stop offset='0.169' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.55'/><stop offset='0.253' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.309' stop-color='%23fff' stop-opacity='0.97'/><stop offset='0.370' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.419' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.469' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.496' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.531' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.563' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.588' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.627' stop-color='%23fff' stop-opacity='0.27'/><stop offset='0.653' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.687' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.729' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.765' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.800' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.845' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.887' stop-color='%23fff' stop-opacity='0.25'/><stop offset='0.936' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.989' stop-color='%23fff' stop-opacity='0.40'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.48'/></linearGradient><rect width='600' height='8' fill='url(%23a)'/></svg>") 0 0 / 600px 100% repeat-x;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l::after, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r::after {
|
||||||
|
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='440' height='8' preserveAspectRatio='none'><linearGradient id='b'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.048' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.133' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.177' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.230' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.272' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.333' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.404' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.443' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.511' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.553' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.612' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.696' stop-color='%23fff' stop-opacity='0.46'/><stop offset='0.756' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.805' stop-color='%23fff' stop-opacity='0.18'/><stop offset='0.853' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.891' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.966' stop-color='%23fff' stop-opacity='0.99'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.81'/></linearGradient><rect width='440' height='8' fill='url(%23b)'/></svg>") 0 0 / 440px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='440' height='8' preserveAspectRatio='none'><linearGradient id='b'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.048' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.133' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.177' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.230' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.272' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.333' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.404' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.443' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.511' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.553' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.612' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.696' stop-color='%23fff' stop-opacity='0.46'/><stop offset='0.756' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.805' stop-color='%23fff' stop-opacity='0.18'/><stop offset='0.853' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.891' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.966' stop-color='%23fff' stop-opacity='0.99'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.81'/></linearGradient><rect width='440' height='8' fill='url(%23b)'/></svg>") 0 0 / 440px 100% repeat-x;
|
||||||
|
opacity: calc(var(--bar-fast) * 0.8);
|
||||||
|
}
|
||||||
|
/* What covers the bargraph beyond its level (the unlit pixels), and the
|
||||||
|
peak pixel that lingers where the level last was. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-l, html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r {
|
||||||
|
z-index: 2;
|
||||||
|
inset: 0;
|
||||||
|
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect;
|
||||||
|
clip-path: inset(0 50% 0 0);
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r { clip-path: inset(0 0 0 50%); }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-l::before, html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::before {
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--bar-dark) 8px);
|
||||||
|
translate: calc(var(--bar-level) * var(--bar-reach)) 0;
|
||||||
|
--bar-dir: 1;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::before {
|
||||||
|
background: linear-gradient(270deg, transparent, var(--bar-dark) 8px);
|
||||||
|
translate: calc(var(--bar-level) * var(--bar-reach) * -1) 0;
|
||||||
|
--bar-dir: -1;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-l::after, html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::after {
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
inline-size: 4px;
|
||||||
|
background: rgb(255 250 240 / 95%);
|
||||||
|
translate: calc(var(--bar-level) * var(--bar-reach) - 8px) 0;
|
||||||
|
opacity: var(--bar-on);
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::after { inset: 0 0 0 auto; translate: calc((var(--bar-level) * var(--bar-reach) - 8px) * -1) 0; }
|
||||||
|
/* A chapter change: a drop of light runs through the bargraph from the
|
||||||
|
old exit to the new one, stretching as it goes and squashing as it
|
||||||
|
lands, and a ripple spreads where it lands. The pixels it ran through
|
||||||
|
hold a ghost of it for a moment. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__ghost { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; mask-composite: intersect, intersect; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__ghost::after {
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
inline-size: 120px;
|
||||||
|
block-size: 24px;
|
||||||
|
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--bar-cool) 75%, transparent));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__drop { z-index: 4; inset: 0; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__drop::before {
|
||||||
|
inset: calc(var(--bar-fy) - 5px) auto auto 0;
|
||||||
|
inline-size: 40px;
|
||||||
|
block-size: 10px;
|
||||||
|
background: radial-gradient(closest-side, #fff 30%, var(--bar-glow) 60%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__drop::after {
|
||||||
|
inset: calc(var(--bar-fy) - 12px) auto auto 0;
|
||||||
|
inline-size: 56px;
|
||||||
|
block-size: 24px;
|
||||||
|
translate: calc(var(--nav-here-x, 0px) - 28px) 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(closest-side, transparent 52%, var(--bar-glow) 70%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
/* The plate's own light: a short glint launched from the current exit
|
||||||
|
drifts slowly to the next one and fades there. One at rest, a second
|
||||||
|
from 42 % of the dial; through the LCD it barely lifts the pixels, in
|
||||||
|
the fibre it is a faint run. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__glint, html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2 { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; mask-composite: intersect, intersect; opacity: 0.5; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2 { z-index: 3; inset: 0 0 auto; block-size: 4px; opacity: 0.5; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2 { opacity: calc(0.5 * clamp(0, (var(--nav-speed, 0.37) - 0.42) * 4, 1)); }
|
||||||
|
html:root .site-navigation[data-nav-jump] > .nav-lcd > :is(.nav-lcd__glint-2, .nav-lcd__fibre-glint-2) { opacity: 0.5; }
|
||||||
|
:is(html:root .site-navigation > .nav-lcd > .nav-lcd__glint, html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2)::before {
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
inline-size: 28px;
|
||||||
|
block-size: 20px;
|
||||||
|
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--bar-cool) 40%, transparent) 55%, rgb(255 255 255 / 45%) 85%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
:is(html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2)::before {
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
inline-size: 28px;
|
||||||
|
block-size: 3px;
|
||||||
|
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--bar-light) 70%, transparent) 60%, rgb(255 255 255 / 70%) 88%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
/* The caret over the current exit, and the indicator by the next one
|
||||||
|
while the gantry announces it. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__caret { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; mask-composite: intersect, intersect; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__caret::before {
|
||||||
|
inset: 12px auto auto 0;
|
||||||
|
inline-size: 14px;
|
||||||
|
block-size: 8px;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
||||||
|
background: var(--bar-core);
|
||||||
|
translate: calc(var(--nav-here-x, -40px) - 7px) 0;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__indicator { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; mask-composite: intersect, intersect; opacity: 0; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__indicator::before {
|
||||||
|
inset: 12px auto auto 0;
|
||||||
|
inline-size: 20px;
|
||||||
|
block-size: 8px;
|
||||||
|
clip-path: polygon(0 0, 20% 0, 50% 50%, 20% 100%, 0 100%, 30% 50%, 0 0, 50% 0, 50% 0, 70% 0, 100% 50%, 70% 100%, 50% 100%, 80% 50%, 50% 0);
|
||||||
|
background: #74f0aa;
|
||||||
|
translate: calc(var(--nav-next-x, -60px) + 8px) 0;
|
||||||
|
}
|
||||||
|
html:root[data-gantry='incoming'] .site-navigation > .nav-lcd > .nav-lcd__indicator { opacity: 1; }
|
||||||
|
/* The self-test (all pixels on) as the engine starts, and the brake
|
||||||
|
columns at both ends. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__test { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect; background: color-mix(in srgb, var(--bar-cool) 80%, white); opacity: 0; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__brake {
|
||||||
|
z-index: 3;
|
||||||
|
inset: 0;
|
||||||
|
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect;
|
||||||
|
background: linear-gradient(90deg, var(--bar-red) 0 12px, transparent 12px calc(100% - 12px), var(--bar-red) calc(100% - 12px));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
html:root .site-navigation[data-nav-brake] > .nav-lcd > .nav-lcd__brake { opacity: 1; }
|
||||||
|
/* The fibre's shimmer, brighter while the soundtrack plays. */
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer { z-index: 3; inset: 0 0 auto; block-size: 2px; opacity: 0.45; }
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer::before, html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer::after {
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
inline-size: calc(100% + 600px);
|
||||||
|
background: #fff;
|
||||||
|
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='c'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.029' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.071' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.101' stop-color='%23fff' stop-opacity='0.92'/><stop offset='0.146' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.298' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.342' stop-color='%23fff' stop-opacity='0.89'/><stop offset='0.379' stop-color='%23fff' stop-opacity='0.75'/><stop offset='0.408' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.435' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.460' stop-color='%23fff' stop-opacity='0.93'/><stop offset='0.519' stop-color='%23fff' stop-opacity='0.04'/><stop offset='0.548' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.573' stop-color='%23fff' stop-opacity='0.90'/><stop offset='0.613' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.666' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.709' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.745' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.782' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.842' stop-color='%23fff' stop-opacity='0.05'/><stop offset='0.866' stop-color='%23fff' stop-opacity='0.34'/><stop offset='0.893' stop-color='%23fff' stop-opacity='0.26'/><stop offset='0.937' stop-color='%23fff' stop-opacity='0.42'/><stop offset='0.981' stop-color='%23fff' stop-opacity='0.08'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.11'/></linearGradient><rect width='600' height='8' fill='url(%23c)'/></svg>") 0 0 / 600px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='c'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.029' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.071' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.101' stop-color='%23fff' stop-opacity='0.92'/><stop offset='0.146' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.298' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.342' stop-color='%23fff' stop-opacity='0.89'/><stop offset='0.379' stop-color='%23fff' stop-opacity='0.75'/><stop offset='0.408' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.435' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.460' stop-color='%23fff' stop-opacity='0.93'/><stop offset='0.519' stop-color='%23fff' stop-opacity='0.04'/><stop offset='0.548' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.573' stop-color='%23fff' stop-opacity='0.90'/><stop offset='0.613' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.666' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.709' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.745' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.782' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.842' stop-color='%23fff' stop-opacity='0.05'/><stop offset='0.866' stop-color='%23fff' stop-opacity='0.34'/><stop offset='0.893' stop-color='%23fff' stop-opacity='0.26'/><stop offset='0.937' stop-color='%23fff' stop-opacity='0.42'/><stop offset='0.981' stop-color='%23fff' stop-opacity='0.08'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.11'/></linearGradient><rect width='600' height='8' fill='url(%23c)'/></svg>") 0 0 / 600px 100% repeat-x;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer::after { inset: 0 auto 0 -437px; inline-size: calc(100% + 437px); -webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='437' height='8' preserveAspectRatio='none'><linearGradient id='d'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.059' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.096' stop-color='%23fff' stop-opacity='0.96'/><stop offset='0.153' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.191' stop-color='%23fff' stop-opacity='0.50'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.88'/><stop offset='0.290' stop-color='%23fff' stop-opacity='0.72'/><stop offset='0.351' stop-color='%23fff' stop-opacity='0.41'/><stop offset='0.388' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.444' stop-color='%23fff' stop-opacity='0.35'/><stop offset='0.516' stop-color='%23fff' stop-opacity='0.07'/><stop offset='0.559' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.630' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.664' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.702' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.743' stop-color='%23fff' stop-opacity='0.98'/><stop offset='0.779' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.827' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.871' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.924' stop-color='%23fff' stop-opacity='0.26'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.00'/></linearGradient><rect width='437' height='8' fill='url(%23d)'/></svg>") 0 0 / 437px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='437' height='8' preserveAspectRatio='none'><linearGradient id='d'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.059' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.096' stop-color='%23fff' stop-opacity='0.96'/><stop offset='0.153' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.191' stop-color='%23fff' stop-opacity='0.50'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.88'/><stop offset='0.290' stop-color='%23fff' stop-opacity='0.72'/><stop offset='0.351' stop-color='%23fff' stop-opacity='0.41'/><stop offset='0.388' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.444' stop-color='%23fff' stop-opacity='0.35'/><stop offset='0.516' stop-color='%23fff' stop-opacity='0.07'/><stop offset='0.559' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.630' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.664' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.702' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.743' stop-color='%23fff' stop-opacity='0.98'/><stop offset='0.779' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.827' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.871' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.924' stop-color='%23fff' stop-opacity='0.26'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.00'/></linearGradient><rect width='437' height='8' fill='url(%23d)'/></svg>") 0 0 / 437px 100% repeat-x; }
|
||||||
|
html:root .site-navigation[data-nav-audio] > .nav-lcd > .nav-lcd__shimmer { opacity: 1; }
|
||||||
|
|
||||||
|
/* The wave's band, in LCD pixels along the lower bezel. The canvas
|
||||||
|
(journey/nav-wave.ts) draws the line; the mask stands still, so the
|
||||||
|
line moves under the pixels. */
|
||||||
|
html:root .site-navigation > .nav-wave {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
block-size: calc(var(--gantry-h) + var(--bulge));
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
contain: strict;
|
||||||
|
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 var(--bar-band-top) / 100% var(--bar-band-h) no-repeat, linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat, linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
|
||||||
|
-webkit-mask-composite: source-in, source-in, source-in, xor;
|
||||||
|
mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 var(--bar-band-top) / 100% var(--bar-band-h) no-repeat, linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat, linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
|
||||||
|
mask-composite: intersect, intersect, intersect, exclude;
|
||||||
|
}
|
||||||
|
html:root .site-navigation > .nav-wave > canvas {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--bar-band-top) 0 auto 0;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: var(--bar-band-h);
|
||||||
|
}
|
||||||
|
/* Under 768 px the exits fold into the menu: no caret, no indicator, and
|
||||||
|
nothing to travel between, so no drop and no glints (they would only
|
||||||
|
pulse at the bar's left edge). */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
html:root .site-navigation > .nav-lcd > .nav-lcd__caret, html:root .site-navigation > .nav-lcd > .nav-lcd__indicator, html:root .site-navigation > .nav-lcd > .nav-lcd__drop, html:root .site-navigation > .nav-lcd > .nav-lcd__glint, html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2 { display: none; }
|
||||||
|
}
|
||||||
|
/* A pixel caret over the exit under the pointer or focus. */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
html:root .site-navigation li::before {
|
||||||
|
content: '' / '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 12px auto auto calc(50% - 6px);
|
||||||
|
inline-size: 12px;
|
||||||
|
block-size: 8px;
|
||||||
|
background: linear-gradient(rgb(var(--bar-mark) / 85%), rgb(var(--bar-mark) / 85%));
|
||||||
|
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 0 / 100% 100% no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 0 / 100% 100% no-repeat; mask-composite: intersect, intersect;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
||||||
|
opacity: 0;
|
||||||
|
translate: 0 -4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
html:root .site-navigation li:has(a:is(:hover, :focus-visible)):not([data-route='here'])::before { opacity: 1; translate: 0 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Motion ---------- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
|
||||||
|
/* The light flowing behind the bargraph: outward on the left, inward on
|
||||||
|
the right; the fast run fades in with the speed. */
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-l::before { animation: bar-flow-in 4.6s linear infinite; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-l::after { animation: bar-flow-in-fast 1.25s linear infinite; transition: opacity 500ms ease; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-r::before { animation: bar-flow-out 4.6s linear -2.3s infinite; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-r::after { animation: bar-flow-out-fast 1.25s linear -0.6s infinite; transition: opacity 500ms ease; }
|
||||||
|
/* The level eases to the speed and hunts a little about it; with the
|
||||||
|
engine off it falls away in steps, and the peak pixel follows late. */
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-l::before, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-r::before {
|
||||||
|
transition: translate 320ms cubic-bezier(0.3, 0.7, 0.4, 1);
|
||||||
|
animation: bar-hunt 6.7s steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
html:root .site-navigation[data-motion='true']:is([data-nav-engine='off'], [data-nav-engine='parked']) > .nav-lcd > :is(.nav-lcd__unlit-l, .nav-lcd__unlit-r)::before { transition-duration: 1.6s; transition-timing-function: steps(10, end); }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-l::after, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-r::after { transition: translate 1.6s steps(12, end) 500ms, opacity 600ms ease; }
|
||||||
|
html:root .site-navigation[data-motion='true'][data-nav-engine='on'] > .nav-lcd > .nav-lcd__test { animation: bar-self-test 1.5s linear both; }
|
||||||
|
/* The drop, its ripple, and the ghost in the pixels behind it. */
|
||||||
|
html:root .site-navigation[data-motion='true'][data-nav-shift] > .nav-lcd > .nav-lcd__drop::before { animation: bar-drop 900ms both; }
|
||||||
|
html:root .site-navigation[data-motion='true'][data-nav-shift] > .nav-lcd > .nav-lcd__drop::after { animation: bar-ripple 900ms cubic-bezier(0.2, 0.7, 0.3, 1) 600ms both; }
|
||||||
|
html:root .site-navigation[data-motion='true'][data-nav-shift] > .nav-lcd > .nav-lcd__ghost::after { animation: bar-ghost 1500ms both; }
|
||||||
|
/* The route glints, slow; they stop at the next exit. */
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__glint::before, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fibre-glint::before { animation: bar-glint 5.8s cubic-bezier(0.45, 0.05, 0.35, 1) infinite; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__glint-2::before, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fibre-glint-2::before { animation: bar-glint 5.8s cubic-bezier(0.45, 0.05, 0.35, 1) -2.9s infinite; }
|
||||||
|
:is(html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__glint-2, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fibre-glint-2) { transition: opacity 900ms ease; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__caret::before { transition: translate 820ms cubic-bezier(0.34, 1.3, 0.55, 1) 420ms; }
|
||||||
|
html:root[data-gantry='incoming'] .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__indicator { animation: bar-blink 760ms steps(1, end) infinite; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__brake { transition: opacity 600ms steps(3, end); }
|
||||||
|
html:root .site-navigation[data-motion='true'][data-nav-brake] > .nav-lcd > .nav-lcd__brake { transition: opacity 100ms; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__shimmer { transition: opacity 900ms ease; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__shimmer::before { animation: bar-shimmer 19.3s linear infinite; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__shimmer::after { animation: bar-shimmer-back 27.1s linear infinite; }
|
||||||
|
html:root .site-navigation[data-motion='true'] > :is(.nav-lcd, .nav-wave) { transition: opacity 900ms ease; }
|
||||||
|
html:root[data-underpass='under'] .site-navigation > :is(.nav-lcd, .nav-wave) { opacity: 0.68; }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
html:root .site-navigation[data-motion='true'] li::before { transition: opacity 160ms steps(2, end), translate 360ms steps(2, end); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bar-flow-in { from { transform: translateX(0); } to { transform: translateX(600px); } }
|
||||||
|
@keyframes bar-flow-in-fast { from { transform: translateX(0); } to { transform: translateX(440px); } }
|
||||||
|
@keyframes bar-flow-out { from { transform: translateX(0); } to { transform: translateX(-600px); } }
|
||||||
|
@keyframes bar-flow-out-fast { from { transform: translateX(0); } to { transform: translateX(-440px); } }
|
||||||
|
@keyframes bar-hunt {
|
||||||
|
0%, 22%, 50%, 70%, 100% { transform: none; }
|
||||||
|
6% { transform: translateX(calc(var(--bar-dir) * 4px)); }
|
||||||
|
12% { transform: translateX(calc(var(--bar-dir) * -4px)); }
|
||||||
|
38% { transform: translateX(calc(var(--bar-dir) * 12px)); }
|
||||||
|
42% { transform: translateX(calc(var(--bar-dir) * 4px)); }
|
||||||
|
80% { transform: translateX(calc(var(--bar-dir) * 4px)); }
|
||||||
|
}
|
||||||
|
/* One pulse, 1.5 s: on at 8 %, off from 55 % to 62 %. */
|
||||||
|
@keyframes bar-self-test {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
8%, 55% { opacity: 1; }
|
||||||
|
62%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
/* About 1.3 Hz, far under the 3 Hz flash threshold. */
|
||||||
|
@keyframes bar-blink {
|
||||||
|
0%, 52%, 100% { opacity: 0.1; }
|
||||||
|
6%, 44% { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes bar-shimmer { to { transform: translateX(-600px); } }
|
||||||
|
@keyframes bar-shimmer-back { to { transform: translateX(437px); } }
|
||||||
|
@keyframes bar-drop {
|
||||||
|
0% { opacity: 0; transform: translateX(calc(var(--nav-from-x, 0px) - 20px)) scale(0.4, 1); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); }
|
||||||
|
18% { opacity: 1; transform: translateX(calc(var(--nav-from-x, 0px) - 20px)) scale(1.6, 1.4); animation-timing-function: cubic-bezier(0.7, 0, 0.25, 1); }
|
||||||
|
66% { opacity: 1; transform: translateX(calc(var(--nav-here-x, 0px) - 20px)) scale(2.6, 0.8); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.4); }
|
||||||
|
80% { opacity: 1; transform: translateX(calc(var(--nav-here-x, 0px) - 20px)) scale(0.8, 1.2); }
|
||||||
|
100% { opacity: 0; transform: translateX(calc(var(--nav-here-x, 0px) - 20px)) scale(1); }
|
||||||
|
}
|
||||||
|
/* The ripple, spread flat: it widens along the bar but never grows down
|
||||||
|
into the exit numerals. */
|
||||||
|
@keyframes bar-ripple {
|
||||||
|
0% { opacity: 0; transform: scale(0.2); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: scale(1.9, 1); }
|
||||||
|
}
|
||||||
|
@keyframes bar-ghost {
|
||||||
|
0%, 12% { opacity: 0; transform: translateX(calc(var(--nav-from-x, 0px) - 120px)); animation-timing-function: cubic-bezier(0.7, 0, 0.25, 1); }
|
||||||
|
40% { opacity: 0.8; transform: translateX(calc(var(--nav-here-x, 0px) - 120px)); }
|
||||||
|
100% { opacity: 0; transform: translateX(calc(var(--nav-here-x, 0px) - 120px)); }
|
||||||
|
}
|
||||||
|
@keyframes bar-glint {
|
||||||
|
0% { opacity: 0; transform: translateX(calc(var(--nav-here-x, 0px) - 14px)) scaleX(0.4); }
|
||||||
|
18% { opacity: 1; }
|
||||||
|
72% { opacity: 0.85; transform: translateX(calc(var(--nav-next-x, 0px) - 14px)) scaleX(1); }
|
||||||
|
100% { opacity: 0; transform: translateX(calc(var(--nav-next-x, 0px) - 14px)) scaleX(1); }
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
/* ---------- Chapter openers over the road ---------- */
|
/* ---------- Chapter openers over the road ---------- */
|
||||||
@media screen {
|
@media screen {
|
||||||
.reading-stage .portfolio-section {
|
.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);
|
padding-block-end: clamp(150px, 20vh, 230px);
|
||||||
}
|
}
|
||||||
.reading-stage .portfolio-section:not([data-layout='project-stage']) {
|
.reading-stage .portfolio-section:not([data-layout='project-stage']) {
|
||||||
|
|||||||
@@ -583,7 +583,6 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.journey-world__word + .journey-world__word { margin-inline-start: 0.28em; }
|
|
||||||
.journey-world__by {
|
.journey-world__by {
|
||||||
color: rgb(255 226 196 / 68%);
|
color: rgb(255 226 196 / 68%);
|
||||||
font: 600 0.66rem/1.2 var(--mono, ui-monospace, monospace);
|
font: 600 0.66rem/1.2 var(--mono, ui-monospace, monospace);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { BarcodeFormat, MultiFormatWriter } from '@zxing/library';
|
import { BarcodeFormat, MultiFormatWriter } from '@zxing/library';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
||||||
import { hashFile } from '../../scripts/lib/hash-file.mjs';
|
import { hashFile } from '../../scripts/lib/hash-file.mjs';
|
||||||
@@ -22,7 +22,16 @@ import { generateSocialPreview } from '../../scripts/generate-social-preview.mjs
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
interface Candidate { id: string; fileName: string; sha256: string; }
|
interface Candidate { id: string; fileName: string; sha256: string; }
|
||||||
|
|
||||||
const createRoot = async (): Promise<string> => mkdtemp(join(tmpdir(), 'portfolio-assets-test-'));
|
const roots: string[] = [];
|
||||||
|
const createRoot = async (): Promise<string> => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'portfolio-assets-test-'));
|
||||||
|
roots.push(root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
const writeJson = async (path: string, value: unknown): Promise<void> => {
|
const writeJson = async (path: string, value: unknown): Promise<void> => {
|
||||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe('release budgets', () => {
|
|||||||
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
|
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
|
||||||
expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video');
|
expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video');
|
||||||
expect(classifyArtifact('assets/speedometer-dial-c.js', 'createSpeedometerDial')).toBe('deferred-non-video');
|
expect(classifyArtifact('assets/speedometer-dial-c.js', 'createSpeedometerDial')).toBe('deferred-non-video');
|
||||||
|
expect(classifyArtifact('assets/nav-wave-c.js', 'mountNavWave')).toBe('deferred-non-video');
|
||||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ describe("SceneCanvas", () => {
|
|||||||
"There is more day to dawn. The sun is but a morning star.",
|
"There is more day to dawn. The sun is but a morning star.",
|
||||||
);
|
);
|
||||||
expect(words[3]!.attributes("style")).toContain("--i: 3");
|
expect(words[3]!.attributes("style")).toContain("--i: 3");
|
||||||
|
// Real spaces between the word spans, so copy and screen readers get words.
|
||||||
|
expect(wrapper.get(".journey-world__quote").element.textContent).toBe(
|
||||||
|
"There is more day to dawn. The sun is but a morning star. ",
|
||||||
|
);
|
||||||
expect(wrapper.get(".journey-world__by").text()).toBe("Henry David Thoreau, Walden");
|
expect(wrapper.get(".journey-world__by").text()).toBe("Henry David Thoreau, Walden");
|
||||||
expect(wrapper.get(".journey-world").element.contains(wrapper.get(".journey-world__credit").element)).toBe(true);
|
expect(wrapper.get(".journey-world").element.contains(wrapper.get(".journey-world__credit").element)).toBe(true);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
|||||||
75
tests/unit/card-gaps.test.ts
Normal file
75
tests/unit/card-gaps.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CARD_CLEAR_BAND, CARD_GAP, CARD_GAP_OPEN_ROAD_VH, CARD_GAP_SPANS_VH, CARD_TITLE_VH, gapWindow } from '@/journey/card-gaps';
|
||||||
|
import { MAINTENANCE_BOUNDS, maintenanceStep } from '@/journey/maintenance-progress';
|
||||||
|
import { RELAY_STOPS, stationLive } from '@/journey/relay-progress';
|
||||||
|
import { plateLit, spreaderDrop, YARD_HOLD, YARD_STOPS } from '@/journey/yard-progress';
|
||||||
|
import { maintenanceTimeline } from '@/scene/world/maintenance-window';
|
||||||
|
import { relayPresence } from '@/scene/world/relay-masts';
|
||||||
|
|
||||||
|
const sweep = (from: number, to: number, steps = 200): number[] =>
|
||||||
|
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
|
||||||
|
const spans = sweep(CARD_GAP_SPANS_VH.min, CARD_GAP_SPANS_VH.max, 40);
|
||||||
|
|
||||||
|
describe('card gaps', () => {
|
||||||
|
it('matches the open road the stylesheet leaves between chapters', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/styles/route.css'), 'utf8');
|
||||||
|
const rule = /\.reading-stage \.portfolio-section \{[^}]*margin-block: clamp\(\d+px, (\d+)vh, \d+px\) 0;\s*padding-block-end: clamp\(\d+px, (\d+)vh, \d+px\);/u.exec(css);
|
||||||
|
expect(rule).not.toBeNull();
|
||||||
|
expect(Number(rule![1]) + Number(rule![2])).toBe(CARD_GAP_OPEN_ROAD_VH);
|
||||||
|
expect(CARD_TITLE_VH).toBeGreaterThan(0);
|
||||||
|
expect(CARD_CLEAR_BAND.top).toBeLessThan(CARD_CLEAR_BAND.bottom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the static window inside the measured gap for every chapter span', () => {
|
||||||
|
for (const span of spans) {
|
||||||
|
const window = gapWindow(span);
|
||||||
|
expect(window.from).toBeLessThanOrEqual(CARD_GAP.from);
|
||||||
|
expect(window.to).toBeGreaterThanOrEqual(CARD_GAP.to);
|
||||||
|
// The static peak sits where the gap is fully in frame, whatever the span.
|
||||||
|
expect(CARD_GAP.peak).toBeGreaterThan(window.from);
|
||||||
|
expect(CARD_GAP.peak).toBeLessThan(window.to);
|
||||||
|
}
|
||||||
|
expect(CARD_GAP.from).toBeLessThan(CARD_GAP.peak);
|
||||||
|
expect(CARD_GAP.peak).toBeLessThan(CARD_GAP.to);
|
||||||
|
expect(CARD_GAP.to).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relights the district and cuts over in the gap', () => {
|
||||||
|
expect(maintenanceTimeline(CARD_GAP.from).relight).toBe(0);
|
||||||
|
expect(maintenanceTimeline(CARD_GAP.from - 0.01).relight).toBe(0);
|
||||||
|
expect(maintenanceTimeline(CARD_GAP.peak).relight).toBe(1);
|
||||||
|
expect(maintenanceTimeline(CARD_GAP.from).packetPhase).toBe(2);
|
||||||
|
expect(MAINTENANCE_BOUNDS.verify).toBe(CARD_GAP.from);
|
||||||
|
expect(maintenanceStep(CARD_GAP.peak)).toBe('done');
|
||||||
|
// The peak of the relight (its steepest frame) lies inside the window.
|
||||||
|
const ps = sweep(0, 1, 1000);
|
||||||
|
const slopes = ps.map((p, i) => (i === 0 ? 0 : maintenanceTimeline(p).relight - maintenanceTimeline(ps[i - 1]!).relight));
|
||||||
|
const steepest = ps[slopes.indexOf(Math.max(...slopes))]!;
|
||||||
|
expect(steepest).toBeGreaterThan(CARD_GAP.from);
|
||||||
|
expect(steepest).toBeLessThan(CARD_GAP.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands the last yard lift in the gap, with the spreader down and the plate lit before the yard fades', () => {
|
||||||
|
const last = YARD_STOPS[YARD_STOPS.length - 1]!;
|
||||||
|
expect(last).toBe(CARD_GAP.peak);
|
||||||
|
expect(spreaderDrop(last)).toBe(1);
|
||||||
|
expect(plateLit(last, YARD_STOPS.length - 1)).toBe(1);
|
||||||
|
expect(last + YARD_HOLD).toBeGreaterThan(CARD_GAP.to);
|
||||||
|
// The spreader is still up (no work-lamp strike yet) as the gap comes fully into frame.
|
||||||
|
expect(spreaderDrop(CARD_GAP.from)).toBeLessThan(0.6);
|
||||||
|
expect(plateLit(CARD_GAP.from, YARD_STOPS.length - 1)).toBe(0);
|
||||||
|
for (let i = 1; i < YARD_STOPS.length; i += 1) expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings the relay station live in the gap, after every mast is online and before they fade', () => {
|
||||||
|
expect(stationLive(null)).toBe(0);
|
||||||
|
expect(stationLive(CARD_GAP.from)).toBe(0);
|
||||||
|
expect(stationLive(CARD_GAP.peak)).toBe(1);
|
||||||
|
expect(Math.max(...RELAY_STOPS)).toBeLessThan(CARD_GAP.from);
|
||||||
|
for (const p of sweep(CARD_GAP.from, CARD_GAP.to)) expect(relayPresence(p)).toBe(1);
|
||||||
|
const forward = sweep(0, 1).map(stationLive);
|
||||||
|
forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { defineComponent, h, nextTick, shallowRef } from 'vue';
|
import { defineComponent, h, nextTick, shallowRef } from 'vue';
|
||||||
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
|
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
|
||||||
|
import { CARD_GAP } from '@/journey/card-gaps';
|
||||||
import type { JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
import {
|
import {
|
||||||
createMaintenanceDirection,
|
createMaintenanceDirection,
|
||||||
@@ -48,8 +49,8 @@ describe('maintenance progress', () => {
|
|||||||
expect(maintenanceStep(0.35)).toBe('transfer');
|
expect(maintenanceStep(0.35)).toBe('transfer');
|
||||||
expect(maintenanceStep(0.649)).toBe('transfer');
|
expect(maintenanceStep(0.649)).toBe('transfer');
|
||||||
expect(maintenanceStep(0.65)).toBe('verify');
|
expect(maintenanceStep(0.65)).toBe('verify');
|
||||||
expect(maintenanceStep(0.899)).toBe('verify');
|
expect(maintenanceStep(CARD_GAP.from - 0.001)).toBe('verify');
|
||||||
expect(maintenanceStep(0.9)).toBe('done');
|
expect(maintenanceStep(CARD_GAP.from)).toBe('done');
|
||||||
expect(maintenanceStep(1)).toBe('done');
|
expect(maintenanceStep(1)).toBe('done');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ describe('maintenance progress', () => {
|
|||||||
expect(maintenanceClock(0, live)).toBe('03:12');
|
expect(maintenanceClock(0, live)).toBe('03:12');
|
||||||
expect(maintenanceClock(0.1, live)).toBe('03:12');
|
expect(maintenanceClock(0.1, live)).toBe('03:12');
|
||||||
expect(maintenanceClock(0.5, live)).toBe('03:32');
|
expect(maintenanceClock(0.5, live)).toBe('03:32');
|
||||||
expect(maintenanceClock(0.9, live)).toBe('03:51');
|
expect(maintenanceClock(CARD_GAP.from, live)).toBe('03:51');
|
||||||
expect(maintenanceClock(0.999, live)).toBe('03:51');
|
expect(maintenanceClock(0.999, live)).toBe('03:51');
|
||||||
expect(maintenanceClock(1, live)).toBe('14:07');
|
expect(maintenanceClock(1, live)).toBe('14:07');
|
||||||
let previous = -1;
|
let previous = -1;
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ describe('the 03:12 window timeline', () => {
|
|||||||
expect(maintenanceTimeline(0.1).packetPhase).toBe(0);
|
expect(maintenanceTimeline(0.1).packetPhase).toBe(0);
|
||||||
expect(maintenanceTimeline(0.36).packetPhase).toBe(1);
|
expect(maintenanceTimeline(0.36).packetPhase).toBe(1);
|
||||||
expect(maintenanceTimeline(0.6).packetPhase).toBe(1);
|
expect(maintenanceTimeline(0.6).packetPhase).toBe(1);
|
||||||
expect(maintenanceTimeline(0.66).packetPhase).toBe(1);
|
expect(maintenanceTimeline(0.65).packetPhase).toBe(1);
|
||||||
expect(maintenanceTimeline(0.89).packetPhase).toBe(2);
|
expect(maintenanceTimeline(0.9).packetPhase).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,8 +111,8 @@ describe('maintenance window meshes', () => {
|
|||||||
it('drops rain, packets and lightning on the lite tier but keeps the power-down', () => {
|
it('drops rain, packets and lightning on the lite tier but keeps the power-down', () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const window = createMaintenanceWindow(resources);
|
const window = createMaintenanceWindow(resources);
|
||||||
let state = window.update(0.5, 1, 0.016, 'lite');
|
let state = window.update(0.95, 1, 0.016, 'lite');
|
||||||
for (let i = 0; i < 200; i += 1) state = window.update(0.5, 1, 0.05, 'lite');
|
for (let i = 0; i < 200; i += 1) state = window.update(0.95, 1, 0.05, 'lite');
|
||||||
expect(state.rain).toBe(0);
|
expect(state.rain).toBe(0);
|
||||||
expect(state.flash).toBe(0);
|
expect(state.flash).toBe(0);
|
||||||
expect(state.lightning).toBe(0);
|
expect(state.lightning).toBe(0);
|
||||||
|
|||||||
19
tests/unit/nav-bar-hue.test.ts
Normal file
19
tests/unit/nav-bar-hue.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { hueTier } from '@/composables/useNavBarSignals';
|
||||||
|
|
||||||
|
describe('nav bar hue tier', () => {
|
||||||
|
it('comes on at 80, 88 and 95 % of the dial', () => {
|
||||||
|
expect(hueTier(0, 0.79)).toBe(0);
|
||||||
|
expect(hueTier(0, 0.8)).toBe(1);
|
||||||
|
expect(hueTier(0, 0.88)).toBe(2);
|
||||||
|
expect(hueTier(0, 1)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a tier until the speed falls below its own floor', () => {
|
||||||
|
expect(hueTier(1, 0.77)).toBe(1);
|
||||||
|
expect(hueTier(1, 0.75)).toBe(0);
|
||||||
|
expect(hueTier(3, 0.93)).toBe(3);
|
||||||
|
expect(hueTier(3, 0.9)).toBe(2);
|
||||||
|
expect(hueTier(3, 0.4)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
tests/unit/nav-wave.test.ts
Normal file
103
tests/unit/nav-wave.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createWaveModel } from '@/journey/nav-wave';
|
||||||
|
|
||||||
|
const run = (model: ReturnType<typeof createWaveModel>, seconds: number, speed: number, hue = -1): void => {
|
||||||
|
for (let t = 0; t < seconds; t += 1 / 60) model.step(1 / 60, speed, hue);
|
||||||
|
};
|
||||||
|
const line = (model: ReturnType<typeof createWaveModel>, h: number, width = 1440, step = 3): number[] => {
|
||||||
|
const ys: number[] = [];
|
||||||
|
for (let x = 0; x <= width; x += step) ys.push(model.lineY(x, h));
|
||||||
|
return ys;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('nav bar wave', () => {
|
||||||
|
it('never leaves its band, at any speed or band height', () => {
|
||||||
|
// One expectation per band rather than per sample: half a million
|
||||||
|
// expect() calls ran past the timeout on a loaded machine.
|
||||||
|
for (const h of [18, 14]) {
|
||||||
|
const model = createWaveModel();
|
||||||
|
model.resize(1440);
|
||||||
|
let low = Infinity;
|
||||||
|
let high = -Infinity;
|
||||||
|
for (const speed of [0, 0.37, 0.7, 1, 0.4, 1]) {
|
||||||
|
for (let k = 0; k < 90; k += 1) {
|
||||||
|
model.step(1 / 60, speed, -1);
|
||||||
|
for (const y of line(model, h, 1440, 6)) {
|
||||||
|
low = Math.min(low, y);
|
||||||
|
high = Math.max(high, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(low).toBeGreaterThanOrEqual(1.6);
|
||||||
|
expect(high).toBeLessThanOrEqual(h - 1.6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a smooth ribbon at cruise that drifts rather than standing still', () => {
|
||||||
|
const model = createWaveModel();
|
||||||
|
model.resize(1440);
|
||||||
|
run(model, 2, 0.37);
|
||||||
|
expect(model.frame.spectrum).toBe(0);
|
||||||
|
const before = line(model, 18);
|
||||||
|
// Smooth: samples 3 px apart move by well under a pixel.
|
||||||
|
for (let i = 1; i < before.length; i += 1) expect(Math.abs(before[i]! - before[i - 1]!)).toBeLessThan(0.6);
|
||||||
|
// It travels: the highest crest moves along the bar within a second,
|
||||||
|
// gently (no more than a third of the bar).
|
||||||
|
const crest = (ys: number[]): number => ys.indexOf(Math.min(...ys)) * 3;
|
||||||
|
const a = crest(before);
|
||||||
|
run(model, 1, 0.37);
|
||||||
|
const moved = Math.abs(crest(line(model, 18)) - a);
|
||||||
|
expect(moved).toBeGreaterThan(6);
|
||||||
|
expect(moved).toBeLessThan(480);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a spectrum flat out: peaks rising from the floor into the band', () => {
|
||||||
|
const model = createWaveModel();
|
||||||
|
model.resize(1440);
|
||||||
|
run(model, 2, 1);
|
||||||
|
expect(model.frame.spectrum).toBeGreaterThan(0.99);
|
||||||
|
const h = 18;
|
||||||
|
expect(model.axisY(h)).toBeGreaterThan(h - 2.5);
|
||||||
|
const ys = line(model, h);
|
||||||
|
const tall = ys.filter((y) => y < h / 2).length;
|
||||||
|
expect(tall).toBeGreaterThan(ys.length / 5);
|
||||||
|
// Well-defined peaks: many local maxima, not one blob.
|
||||||
|
let peaks = 0;
|
||||||
|
for (let i = 1; i < ys.length - 1; i += 1) if (ys[i]! < ys[i - 1]! && ys[i]! <= ys[i + 1]! && ys[i]! < h - 6) peaks += 1;
|
||||||
|
expect(peaks).toBeGreaterThan(20);
|
||||||
|
// Smooth, not blocky: no vertical cliffs between samples 3 px apart.
|
||||||
|
for (let i = 1; i < ys.length; i += 1) expect(Math.abs(ys[i]! - ys[i - 1]!)).toBeLessThan(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a change of speed within a fraction of a second', () => {
|
||||||
|
const model = createWaveModel();
|
||||||
|
model.resize(800);
|
||||||
|
run(model, 1, 0.37);
|
||||||
|
run(model, 0.2, 1);
|
||||||
|
expect(model.frame.speed).toBeGreaterThan(0.9);
|
||||||
|
run(model, 0.2, 0.37);
|
||||||
|
expect(model.frame.speed).toBeLessThan(0.45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings the hue in over 1.2 s and lets it go over 4 s', () => {
|
||||||
|
const model = createWaveModel();
|
||||||
|
model.resize(800);
|
||||||
|
run(model, 1.25, 1, 3);
|
||||||
|
expect(model.frame.hueMix).toBe(1);
|
||||||
|
run(model, 2, 0.4, 0);
|
||||||
|
expect(model.frame.hueMix).toBeGreaterThan(0.1);
|
||||||
|
expect(model.frame.hueMix).toBeLessThan(0.9);
|
||||||
|
run(model, 2.1, 0.4, -1);
|
||||||
|
expect(model.frame.hueMix).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a still frame for reduced motion', () => {
|
||||||
|
const model = createWaveModel();
|
||||||
|
model.resize(800);
|
||||||
|
run(model, 1, 1, 3);
|
||||||
|
const frame = model.still(1);
|
||||||
|
expect(frame.hueMix).toBe(0);
|
||||||
|
expect(frame.spectrum).toBe(0);
|
||||||
|
expect(model.level(10)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
YARD_STOPS,
|
YARD_STOPS,
|
||||||
yardStopsPassed,
|
yardStopsPassed,
|
||||||
} from '@/journey/yard-progress';
|
} from '@/journey/yard-progress';
|
||||||
|
import { CARD_GAP } from '@/journey/card-gaps';
|
||||||
import { DECK_PASS_Z } from '@/journey/underpass';
|
import { DECK_PASS_Z } from '@/journey/underpass';
|
||||||
import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass';
|
import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ const sweep = (steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i
|
|||||||
|
|
||||||
describe('night freight progress', () => {
|
describe('night freight progress', () => {
|
||||||
it('has four ordered stops and stacks far to near', () => {
|
it('has four ordered stops and stacks far to near', () => {
|
||||||
expect(YARD_STOPS).toEqual([0.1, 0.33, 0.56, 0.79]);
|
expect(YARD_STOPS).toEqual([0.12, 0.38, 0.64, CARD_GAP.peak]);
|
||||||
for (let i = 1; i < YARD_STOPS.length; i += 1) {
|
for (let i = 1; i < YARD_STOPS.length; i += 1) {
|
||||||
expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
|
expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
|
||||||
expect(STACK_ZS[i]!).toBeGreaterThan(STACK_ZS[i - 1]!);
|
expect(STACK_ZS[i]!).toBeGreaterThan(STACK_ZS[i - 1]!);
|
||||||
@@ -39,7 +40,9 @@ describe('night freight progress', () => {
|
|||||||
previous = z;
|
previous = z;
|
||||||
}
|
}
|
||||||
expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z);
|
expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z);
|
||||||
expect(craneZ(1)).toBeCloseTo(CRANE_TO_Z);
|
// The last lift is the chapter's final beat (CARD_GAP): the crane stays parked over the near stack to the boundary.
|
||||||
|
expect(craneZ(1)).toBeCloseTo(STACK_ZS[STACK_ZS.length - 1]!);
|
||||||
|
expect(CRANE_TO_Z).toBeGreaterThan(STACK_ZS[STACK_ZS.length - 1]!);
|
||||||
YARD_STOPS.forEach((stop, i) => {
|
YARD_STOPS.forEach((stop, i) => {
|
||||||
for (const d of [-YARD_HOLD, -0.02, 0, 0.02, YARD_HOLD]) expect(craneZ(stop + d)).toBeCloseTo(STACK_ZS[i]!);
|
for (const d of [-YARD_HOLD, -0.02, 0, 0.02, YARD_HOLD]) expect(craneZ(stop + d)).toBeCloseTo(STACK_ZS[i]!);
|
||||||
});
|
});
|
||||||
@@ -63,10 +66,10 @@ describe('night freight progress', () => {
|
|||||||
expect(plateLit(stop - 0.05, i)).toBe(0);
|
expect(plateLit(stop - 0.05, i)).toBe(0);
|
||||||
});
|
});
|
||||||
expect(spreaderDrop(0.215)).toBe(0);
|
expect(spreaderDrop(0.215)).toBe(0);
|
||||||
expect(spreaderDrop(0.95)).toBe(0);
|
expect(spreaderDrop(0.51)).toBe(0);
|
||||||
expect(plateLit(null, 0)).toBe(0);
|
expect(plateLit(null, 0)).toBe(0);
|
||||||
expect(plateLit(0.5, 9)).toBe(0);
|
expect(plateLit(0.5, 9)).toBe(0);
|
||||||
expect([0, 0.1, 0.2, 0.33, 0.6, 0.8, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
|
expect([0, 0.12, 0.2, 0.38, 0.7, 0.95, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is symmetric in reverse: the same progress gives the same frame either way', () => {
|
it('is symmetric in reverse: the same progress gives the same frame either way', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user