Alex ChiciloBack to the console

Source · read from disk at build

Files

Source

The code that draws the radar, counts the hail days and runs the terminal on alexchicilo.com, read from this repository when the site was built and highlighted on the server. Not a rewrite, not an excerpt: the files as they are on disk.

26 files6,011 linesBuilt

FilesJump to a file

src/shaders/radar.frag.glsl.ts

GLSL in TypeScript · 87 lines · 3.4 KB

/**
 * Sweep pass (pass 2): the baked static layers, the 81° phosphor wedge and
 * the 1.5px leading edge; in Calibration mode, a spoke test pattern instead of the
 * wedge. See fullscreen.vert.glsl.ts for the import strategy.
 *
 * Coordinates are canvas CSS px with y down, rebuilt from gl_FragCoord, so every
 * uniform is in the same units as the DOM and the label layout:
 *   uSize     canvas size            uDpr     device pixel ratio
 *   uCam      camera now: NW corner x, y and px per degree of latitude
 *   uBakeCam  the camera the static layers were baked with
 *   uDen      Denver under uCam      uRing    bearing ring radius
 *   uAz       beam bearing (radians, 0 = north, clockwise)
 *   uA0       intro start bearing    uReveal  radians of static layers drawn in from uA0
 *   uSignal   --accent-rgb / 255     uWedge   wedge alpha at 70% and at the leading edge
 *   uCalib    1 = Calibration test pattern
 *
 * The bake is drawn once for one camera. While the camera moves between framings,
 * each pixel maps back through the bake's camera (one scale, one offset), so the
 * static layers glide with the field and are re-baked crisp when it settles.
 * Comments inside the GLSL string ship to the browser, so they stay few and short.
 *
 * Output is premultiplied alpha over a transparent canvas; CSS paints the sky.
 */
export const RADAR_FRAG = /* glsl */ `#version 300 es
precision highp float;

uniform sampler2D uBake;
uniform vec2 uSize;
uniform float uDpr;
uniform vec3 uCam;
uniform vec3 uBakeCam;
uniform vec2 uDen;
uniform float uRing;
uniform float uAz;
uniform float uA0;
uniform float uReveal;
uniform vec3 uSignal;
uniform vec2 uWedge;
uniform float uCalib;

out vec4 outColor;

const float TAU = 6.28318530718;
const float WEDGE = 0.225; // 81 degrees, as a fraction of a turn

// Coverage of a 1px radial spoke every stepRad radians, r px from Denver.
float spoke(float bearing, float r, float stepRad) {
  float off = abs(mod(bearing + 0.5 * stepRad, stepRad) - 0.5 * stepRad) * r;
  return clamp(1.0 - off * uDpr, 0.0, 1.0);
}

void main() {
  vec2 p = vec2(gl_FragCoord.x, uSize.y * uDpr - gl_FragCoord.y) / uDpr;

  vec2 uv = (uBakeCam.xy + (p - uCam.xy) * (uBakeCam.z / uCam.z)) / uSize;
  float inside = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
  vec4 base = texture(uBake, uv) * inside;

  vec2 v = p - uDen;
  float r = length(v);
  // Past the ring only the static layers draw. Neighbours branch alike: no divergence.
  if (r > uRing && uReveal >= TAU) {
    outColor = base;
    return;
  }
  float bearing = atan(v.x, -v.y);
  float inRing = 1.0 - smoothstep(uRing - 1.0, uRing, r);

  float drawn = fract((bearing - uA0) / TAU) * TAU;
  base *= uReveal >= TAU ? 1.0 : 1.0 - smoothstep(uReveal - 0.06, uReveal, drawn);

  float behind = fract((uAz - bearing) / TAU);
  float u = 1.0 - behind / WEDGE;
  float wedge = behind < WEDGE
    ? (u < 0.7 ? uWedge.x * u / 0.7 : mix(uWedge.x, uWedge.y, (u - 0.7) / 0.3))
    : 0.0;

  vec2 dir = vec2(sin(uAz), -cos(uAz));
  float off = abs(v.x * dir.y - v.y * dir.x) * uDpr;
  float line = clamp(0.75 * uDpr + 0.5 - off, 0.0, 1.0) * step(0.0, dot(v, dir)) * mix(0.95, 0.15, r / uRing);

  float pattern = max(spoke(bearing, r, TAU / 12.0) * 0.3, spoke(bearing, r, TAU / 36.0) * 0.12);
  float a = mix(max(wedge, line), max(pattern, line), uCalib) * inRing;

  outColor = base * (1.0 - a) + vec4(uSignal * a, a);
}
`;

src/shaders/fullscreen.vert.glsl.ts

GLSL in TypeScript · 20 lines · 0.9 KB

/**
 * Fullscreen triangle for the sweep pass and the knockout pass.
 *
 * Shader import strategy: every shader is a TypeScript string
 * module named `<name>.<stage>.glsl.ts` exporting one UPPER_SNAKE template string,
 * prefixed with a `glsl` block comment for editor highlighting. No loader, no
 * dependency. Import it without the .ts suffix:
 * `import { FULLSCREEN_VERT } from "@/shaders/fullscreen.vert.glsl";`
 * It resolves the same way in Turbopack, tsc and Vitest. GLSL ES 3.00 (WebGL2).
 *
 * One oversized triangle from gl_VertexID alone: no vertex buffer, and no diagonal
 * seam where two triangles of a quad would meet. Fragment shaders work from
 * gl_FragCoord, so nothing is interpolated.
 */
export const FULLSCREEN_VERT = /* glsl */ `#version 300 es
void main() {
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
`;

src/shaders/points.vert.glsl.ts

GLSL in TypeScript · 90 lines · 3.4 KB

/**
 * Echoes (pass 3): one point per deduplicated 2026 NWS hail report location,
 * plotted where it fell. See fullscreen.vert.glsl.ts for the import strategy.
 *
 * Attributes come straight from the packed EchoBuffer (radar-data.ts): lon, lat,
 * size in inches (0 = not reported), day of year. Position, bearing from Denver and
 * phosphor intensity are computed here from the camera and beam uniforms, so the
 * buffer is uploaded once and a frame costs a handful of uniform writes.
 *   uCam, uNW, uLonK  the projection: NW corner (px), its lon/lat, cos 39°
 *   uAz, uA0, uSwept  beam bearing; intro start; radians swept so far (echoes past it are unlit)
 *   uDay              the playhead: echoes reported after it are hidden
 *   uFlareDay, uFlare echoes on the scrubbed day flare 1 → 0 over 300ms
 *   uSurge            a storm's onset: every echo flares, 1 → 0 over SURGE_MS, and swells by half
 *   uPointer          echoes within 64px of the pointer brighten (they never move)
 *   uRadius           r = x + y · size; uCalib = every echo at full intensity
 *   uMaxPoint         the driver's largest point sprite, device px
 *
 * Intensity is phosphor decay: I = 0.22 + 0.78·e^(−5·age), brightest just behind
 * the beam. The 4r halo shows only while I > 0.4, so most of a revolution draws a
 * core-sized sprite (~16× fewer fragments than always sizing for the halo).
 */
export const POINTS_VERT = /* glsl */ `#version 300 es
layout(location = 0) in vec2 a_lonLat;
layout(location = 1) in float a_size;
layout(location = 2) in float a_day;

uniform vec2 uSize;
uniform float uDpr;
uniform vec3 uCam;
uniform vec2 uNW;
uniform float uLonK;
uniform vec2 uDen;
uniform float uAz;
uniform float uA0;
uniform float uSwept;
uniform float uDay;
uniform float uFlareDay;
uniform float uFlare;
uniform float uSurge;
uniform vec2 uPointer;
uniform vec2 uRadius;
uniform float uCalib;
uniform float uLight;
uniform vec3 uEmber;
uniform float uMaxPoint;

out vec3 vRgb;
out float vCoreA;
out float vHaloA;
out float vCore;
out float vAA;
out vec4 vStone; // hailstones only (particles.vert): 0 here

const float TAU = 6.28318530718;
const float GLOW_PX = 64.0; // pointer glow reach; echoes never move, they are where hail fell

void main() {
  vec2 p = uCam.xy + vec2((a_lonLat.x - uNW.x) * uLonK, uNW.y - a_lonLat.y) * uCam.z;
  vec2 v = p - uDen;
  float bearing = atan(v.x, -v.y);

  float lit = step(fract((bearing - uA0) / TAU) * TAU, uSwept);
  float shown = max(step(a_day, uDay + 0.001) * lit, uCalib);
  if (shown < 0.5) {
    gl_Position = vec4(2.0, 2.0, 2.0, 1.0); // outside the clip volume
    gl_PointSize = 0.0;
    return;
  }

  float I = 0.22 + 0.78 * exp(-5.0 * fract((uAz - bearing) / TAU));
  if (abs(a_day - uFlareDay) < 0.5) I = max(I, uFlare);
  I = max(I, 0.9 * (1.0 - smoothstep(0.0, GLOW_PX, length(p - uPointer))));
  I = max(I, uSurge);
  I = mix(I, 1.0, uCalib);

  float r = (uRadius.x + uRadius.y * a_size) * (1.0 + 0.5 * uSurge);
  float haloA = 0.13 * I * smoothstep(0.38, 0.42, I);
  float reach = haloA > 0.0 ? 4.0 * r : r;
  float sprite = min((2.0 * reach + 2.0) * uDpr, uMaxPoint); // + 1px a side for AA
  gl_PointSize = sprite;
  gl_Position = vec4(p.x / uSize.x * 2.0 - 1.0, 1.0 - p.y / uSize.y * 2.0, 0.0, 1.0);

  vRgb = uEmber;
  vCoreA = uLight > 0.5 ? min(1.0, I + 0.18) : I;
  vHaloA = haloA;
  vCore = r * uDpr / sprite; // in point-coord units (the sprite is 1)
  vAA = 0.75 / sprite;
  vStone = vec4(0.0);
}
`;

src/shaders/points.frag.glsl.ts

GLSL in TypeScript · 75 lines · 2.7 KB

/**
 * Point sprites for echoes and particles (passes 3–4).
 * See fullscreen.vert.glsl.ts for the import strategy.
 *
 * An echo is an antialiased core disc inside a faint halo disc that fills the
 * sprite (4r). A dust particle sets vCore ≥ 0.71, so its core covers the whole
 * sprite and it renders as the spec's tiny square. A hailstone (the storm,
 * particles.vert) sets vStone: its disc sits at the head, off the sprite's centre
 * along its fall, with a hot core and a soft glow, and a streak tapers back from it
 * to the tail. Echoes and dust leave vStone at 0. Output is premultiplied alpha.
 *
 * Behind copy every point fades fully by the knockout field: knock() is the same
 * function as in knockout.frag (keep the two in step), at full strength here where
 * the sweep only takes uCap of it, so no echo or particle ever sits behind text.
 */
export const POINTS_FRAG = /* glsl */ `#version 300 es
precision highp float;

uniform vec2 uSize;
uniform float uDpr;
uniform vec4 uKnock[4];
uniform vec4 uClip[4];
uniform int uKnockCount;
uniform vec3 uShape;

in vec3 vRgb;
in float vCoreA;
in float vHaloA;
in float vCore;
in float vAA;
in vec4 vStone; // head offset (xy), glow radius (z), hot core (w); point-coord units

out vec4 outColor;

float box(vec2 p, vec4 rect, float radius) {
  vec2 ext = rect.zw * 0.5;
  radius = min(radius, min(ext.x, ext.y));
  vec2 q = abs(p - rect.xy - ext) - ext + radius;
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}

float knock(vec2 p) {
  float k = 0.0;
  for (int i = 0; i < 4; i++) {
    if (i >= uKnockCount) break;
    float fade = 1.0 - smoothstep(0.0, uShape.x, box(p, uKnock[i], uShape.y));
    float inside = smoothstep(0.0, uShape.z, -box(p, uClip[i], 0.0));
    k = max(k, fade * inside);
  }
  return k;
}

void main() {
  vec2 q = gl_PointCoord - 0.5;
  vec2 head = vStone.xy;
  float d = length(q - head);
  float core = 1.0 - smoothstep(vCore - vAA, vCore + vAA, d);
  vec3 rgb = vRgb;
  float a;
  if (vStone.z > 0.0) {
    float glow = 1.0 - smoothstep(vCore, vStone.z, d);
    // Streak: the segment from the tail (-head) to the head, narrowing and fading toward the tail.
    float t = clamp(dot(q + head, head) / max(2.0 * dot(head, head), 1e-6), 0.0, 1.0);
    float w = vCore * mix(0.3, 0.8, t);
    float streak = (1.0 - smoothstep(w - vAA, w + vAA, length(q + head - 2.0 * head * t))) * t;
    a = vCoreA * max(core, max(0.3 * glow * glow, 0.7 * streak));
    rgb = mix(rgb, vec3(step(0.0, vStone.w)), abs(vStone.w) * (1.0 - smoothstep(0.0, 0.75 * vCore, d)));
  } else {
    float halo = 1.0 - smoothstep(0.5 - vAA, 0.5, length(q));
    a = vCoreA * core + vHaloA * halo * (1.0 - core);
  }
  a *= 1.0 - knock(vec2(gl_FragCoord.x, uSize.y * uDpr - gl_FragCoord.y) / uDpr);
  outColor = vec4(rgb * a, a);
}
`;

src/shaders/particles.vert.glsl.ts

GLSL in TypeScript · 132 lines · 5.0 KB

/**
 * Hail particles (pass 4), stateless. See fullscreen.vert.glsl.ts
 * for the import strategy. particlePosition() and stoneLook() in radar-render.ts
 * are the CPU twins (2D fallback and tests); keep them in step.
 *
 * A particle is only its seed (x, y, depth in [0, 1)). The CPU integrates three
 * numbers per frame, uFlow = (storm-scaled frame clock, scroll-velocity fall,
 * westerly drift), and every position follows from seed + uFlow: there is no
 * per-particle state to upload, and a still frame (uFlow = 0) is the seeded field.
 * Fall speed is 0.18 + 0.45·depth px per frame.
 *
 * The pointer parts the field: inside 110px a particle is pushed out along the
 * radius by up to 26px with a (1 − d²/r²)² falloff.
 *
 * Calm, a particle is dust: a tiny square. The "hail" egg (uStorm 1 → 0) turns the
 * field into hailstones (STONE in radar-render.ts): a 4–6px disc with a hot core
 * and a soft glow, trailing a streak four frames of its own motion long. The
 * sprite is then centred midway along the streak, so the stone leads, and
 * points.frag draws the shape from vStone. The buffer holds 3× uBase seeds; the
 * extras (gl_VertexID ≥ uBase) each hold at full strength until the storm falls to
 * their own threshold, so the storm is densest at its onset and thins out.
 *
 * Lines of small copy (uLines, COPY_LINE in radar-render.ts) hide the particles
 * that cross them, so a dot never reads as punctuation in a line of text:
 * lineKeep() is 0 within uLineShape.xy px of a line's box (along it, across it)
 * and 1 again uLineShape.z px further out. A stone is tested at its head and at
 * its streak's tail. lineKeep() in radar-render.ts is its CPU twin.
 */
export const PARTICLES_VERT = /* glsl */ `#version 300 es
layout(location = 0) in vec3 a_seed;

uniform vec2 uSize;
uniform float uDpr;
uniform vec3 uFlow;
uniform vec2 uPointer;
uniform float uStorm;
uniform float uBase;
uniform float uCount;     // seeds drawn this frame
uniform float uMaxPoint;  // the driver's largest point sprite, device px
uniform float uLight;
uniform vec3 uInk;
uniform vec3 uEmber;
uniform vec4 uLines[64];  // MAX_COPY_LINES: x, y, width, height in CSS px
uniform int uLineCount;
uniform vec3 uLineShape;  // pad along the line, pad across it, feather (px)

out vec3 vRgb;
out float vCoreA;
out float vHaloA;
out float vCore;
out float vAA;
out vec4 vStone;

const float REPEL_R2 = 12100.0; // 110px squared
const float REPEL_PX = 26.0;
const float GLOW = 2.2;         // STONE.glow, in stone radii
const float STREAK = 4.0;       // STONE.streakFrames

float lineKeep(vec2 p) {
  float keep = 1.0;
  for (int i = 0; i < 64; i++) {
    if (i >= uLineCount) break;
    vec4 line = uLines[i];
    vec2 ext = line.zw * 0.5;
    vec2 q = abs(p - line.xy - ext) - ext - uLineShape.xy;
    float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
    keep = min(keep, smoothstep(0.0, uLineShape.z, d));
  }
  return keep;
}

vec4 clip(vec2 p) {
  return vec4(p.x / uSize.x * 2.0 - 1.0, 1.0 - p.y / uSize.y * 2.0, 0.0, 1.0);
}

void main() {
  float z = a_seed.z;
  float s = uStorm;
  float span = uSize.y + 8.0;
  vec2 p = vec2(
    mod(a_seed.x * uSize.x + uFlow.z, uSize.x),
    mod(a_seed.y * span + (0.18 + 0.45 * z) * uFlow.x + uFlow.y, span) - 4.0
  );

  vec2 d = p - uPointer;
  float d2 = dot(d, d);
  if (d2 < REPEL_R2 && d2 > 0.0) {
    float k = 1.0 - d2 / REPEL_R2;
    p += d * inversesqrt(d2) * REPEL_PX * k * k;
  }

  // Dust, or a stone as far as the storm has formed it (STONE: 4-6px, alpha 0.7-1).
  float form = smoothstep(0.0, 0.25, s);
  float size = mix(0.8 + 1.3 * z + s, 4.0 + 2.0 * z, form);
  float a = mix(uLight > 0.5 ? 0.16 + 0.28 * z : 0.10 + 0.30 * z, 0.7 + 0.3 * z, form);
  float id = float(gl_VertexID);
  if (id >= uBase) {
    float rank = (id - uBase) / max(1.0, uCount - uBase);
    a *= smoothstep(0.7 * rank, 0.7 * rank + 0.3, s);
  }
  vRgb = mix(uInk, uEmber, smoothstep(0.0, 0.15, s));
  vHaloA = 0.0;

  if (form <= 0.0) {
    // Under one device pixel the rasterizer still lights a whole one: dim instead.
    a *= min(1.0, size * uDpr) * lineKeep(p);
    gl_PointSize = max(1.0, size * uDpr);
    gl_Position = clip(p);
    vCoreA = min(1.0, a);
    vCore = 1.0;
    vAA = 0.001;
    vStone = vec4(0.0);
    return;
  }

  // Velocity per frame as the controller integrates it (FLOW), and the streak behind it.
  vec2 vel = vec2(0.06 + 1.2 * s, (0.18 + 0.45 * z) * (1.0 + 5.0 * s));
  vec2 dir = normalize(vel);
  float len = STREAK * length(vel) * form;
  a *= min(lineKeep(p), lineKeep(p - dir * len));

  float r = 0.5 * size;
  float sprite = min(len + 2.0 * GLOW * r + 2.0, uMaxPoint / uDpr); // CSS px, + 1px a side for AA
  gl_PointSize = sprite * uDpr;
  gl_Position = clip(p - dir * (0.5 * len));
  vCoreA = min(1.0, a);
  vCore = r / sprite;
  vAA = 0.75 / (sprite * uDpr);
  // Head offset from the sprite's centre, glow radius, hot core (+ toward white in the dark, - toward black in clear sky).
  vStone = vec4(dir * (0.5 * len / sprite), GLOW * r / sprite, uLight > 0.5 ? -0.3 : 0.55);
}
`;

src/shaders/knockout.frag.glsl.ts

GLSL in TypeScript · 54 lines · 2.1 KB

/**
 * Knockout pass: fades the sweep behind up to four copy rects
 * ([data-knockout]: the role, CTA row and lede in 01, the readouts in 02), so the
 * copy reads on the field with no panel or box.
 * See fullscreen.vert.glsl.ts for the import strategy.
 *
 * The field is a rounded rect (corner radius uShape.y) with a smoothstep falloff
 * over uShape.x px, so neither its edges nor its corners read as a panel. Each
 * rect's fade is clipped to its stage box with a uShape.z px ramp inside the box,
 * so it never darkens the frame margin. knock() is copied into points.frag (echoes
 * and particles fade by the same field) and mirrored by knockAt() in
 * radar-render.ts; keep the three in step.
 *
 * It runs right after the sweep, blended with (ZERO, ONE_MINUS_SRC_ALPHA), and at
 * uCap strength: the canvas keeps (1 − uCap·k) of the static layers, wedge and beam,
 * so the instrument still reads faintly behind the copy. The renderer scissors it
 * to the rects' bounds, so it only shades the pixels it can change.
 */
export const KNOCKOUT_FRAG = /* glsl */ `#version 300 es
precision highp float;

uniform vec2 uSize;
uniform float uDpr;
uniform vec4 uKnock[4];   // copy rects: x, y, width, height in CSS px
uniform vec4 uClip[4];    // each rect's stage box, same units
uniform int uKnockCount;
uniform vec3 uShape;      // feather, corner radius, clip ramp (px)
uniform float uCap;

out vec4 outColor;

float box(vec2 p, vec4 rect, float radius) {
  vec2 ext = rect.zw * 0.5;
  radius = min(radius, min(ext.x, ext.y));
  vec2 q = abs(p - rect.xy - ext) - ext + radius;
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}

float knock(vec2 p) {
  float k = 0.0;
  for (int i = 0; i < 4; i++) {
    if (i >= uKnockCount) break;
    float fade = 1.0 - smoothstep(0.0, uShape.x, box(p, uKnock[i], uShape.y));
    float inside = smoothstep(0.0, uShape.z, -box(p, uClip[i], 0.0));
    k = max(k, fade * inside);
  }
  return k;
}

void main() {
  vec2 p = vec2(gl_FragCoord.x, uSize.y * uDpr - gl_FragCoord.y) / uDpr;
  outColor = vec4(0.0, 0.0, 0.0, uCap * knock(p));
}
`;

src/components/radar/radar-controller.ts

TypeScript · 913 lines · 33.0 KB

/**
 * The radar controller: the RadarApi, the clock and the frame loop.
 * Loaded lazily by radar-canvas.tsx after load, on idle, once 01 or 02 is near.
 *
 * It owns all radar state (beam, playhead, flare, storm, framing, pointer, scroll
 * flow), turns it into one FrameState per frame and hands that to a renderer:
 * WebGL2 (radar-gl.ts), or canvas 2D (radar-fallback-2d.ts, a static frame) when
 * WebGL2 is missing. The renderer runs in the radar worker (radar-remote.ts) where
 * the browser can host WebGL2 there, so no GL call ever blocks this thread; else
 * on this thread. Everything the DOM tells it arrives on resize, font load,
 * scroll end or the bus; a frame reads only window.scrollY and its own clock.
 *
 * Budget: DPR capped at 2 / 1.5 / 1.0, the loop runs only while the
 * canvas intersects the viewport and the tab is visible, and STILL (reduced motion
 * or automation) draws single deterministic frames at bearing 115° on demand.
 */
import {
  emit,
  FLARE_MS,
  listen,
  MAX_KNOCKOUTS,
  sceneMeta,
  STILL_BEARING_DEG,
  STORM_MS,
  type Framing,
  type KnockoutRect,
  type LatLon,
  type RadarApi,
  type ScreenPoint,
} from "@/lib/contracts";
import { armHoverCut, beamCut, disposeBeamCut } from "@/lib/beam-cut";
import { inColorado, type Point } from "@/lib/geo";
import { deviceMemory, easingToken, isDesktop, isFinePointer, isStill, onMotionChange } from "@/lib/motion";
import {
  denverOf,
  fieldCamera,
  freeBand,
  mixCamera,
  PAGE_TRACK,
  parseDuration,
  ringRadius,
  sameCamera,
  scanCamera,
  toLonLat,
  trackDrift,
  trackTop,
  VIEWPORT_TRACK,
  type Camera,
  type Rect,
  type ScrollTrack,
  type Size,
} from "./radar-camera";
import type { RadarCanvasProps } from "./radar-data";
import { layoutLabels, type LabelLayout } from "./radar-label-layout";
import { copyLines, measure, visibleKnockouts, type Measure, type SceneMeasure } from "./radar-measure";
import { countBins, crossedCluster, echoBearings, PING_BIN_DEG } from "./radar-ping";
import { createRemoteRenderer } from "./radar-remote";
import {
  COPY_LINE,
  FLOW,
  MAX_COPY_LINES,
  particleCount,
  particleSeeds,
  parseRgb,
  smoothstep,
  STORM_DENSITY,
  SURGE_MS,
  TAU,
  type FrameState,
  type ParticleFlow,
  type RadarPalette,
  type RadarRenderer,
  type RendererEvents,
  type Rgb,
  type Surface,
} from "./radar-render";

/** What the labels layer renders; null until the radar has a layout. */
export type LabelFrame = {
  layout: LabelLayout;
  /**
   * False while the camera moves between framings, while the scene's copy has
   * scrolled off the position the layout was made for, during the intro's
   * draw-in, and while the GL context is lost.
   */
  visible: boolean;
  /** The MAX callout shows once the playhead reaches the largest report's day. */
  maxVisible: boolean;
};

export type RadarOptions = RadarCanvasProps & {
  /** The absolute layer spanning 01 and 02. */
  layer: HTMLElement;
  /** The canvas box (sticky on desktop). */
  box: HTMLElement;
  /** A hidden probe sized `width: var(--g); height: var(--fs-hud-min)`. */
  gauge: HTMLElement;
  onLabels(frame: LabelFrame | null): void;
};

/** What radar-canvas.tsx holds: the shared RadarApi plus teardown. */
export type RadarHandle = RadarApi & { destroy(): void };

/* ── Timing (the 01 intro and the sweep) ─────────────────────────────────── */

/** The intro turn starts pointing north-west, so it reaches the eastern plains early and fast. */
const INTRO_START_RAD = (-48 * Math.PI) / 180;
const REVEAL_MS = 600;
const TURN_DELAY_MS = 200;
const FRAME_MS = 1000 / 60;
const PING_GAP_MS = 400;
const BEARING_EVENT_MS = 1000;
/** A framing settles (the bake is redrawn crisp) after this much stillness. */
const SETTLE_MS = 150;
/**
 * setFraming takes raw scroll progress (continuous under Lenis). Without smooth
 * scroll the writer steps straight from 0 to 1 per detent; a step larger than this
 * glides over one detent (--t-snap, --ease-detent) instead of jumping. Stills cut.
 */
const FRAMING_STEP = 0.25;
const POINTER_LERP = 0.22;
/** The mockup's knockout padding around each copy rect. */
const KNOCK_PAD = { x: 6, y: 4 } as const;
/** Labels are laid out for a scene at rest; they fade once its copy has moved this far against the canvas. */
const LABEL_DRIFT_PX = 16;
const SCROLL_END_MS = 120;
const REMEASURE_MS = 100;

const DESKTOP_DPR = 2;
const MOBILE_DPR = 1.5;
const LOW_MEMORY_GB = 4;

type Motion = {
  introMs: number;
  revMs: number;
  snapMs: number;
  detent: (t: number) => number;
  out: (t: number) => number;
};

/** A box carried by a scroll track: x and size in viewport px, y as an offset from the track's top. */
type Carried = { x: number; width: number; height: number; offset: number };

/** A viewport rect (read at scrollY) as an offset from the track that carries it. */
function carry(box: Rect, track: ScrollTrack, scrollY: number): Carried {
  return { x: box.x, width: box.width, height: box.height, offset: box.y - trackTop(track, scrollY) };
}

/**
 * A knockout rect and the stage box its fade stays inside, carried by the page or
 * by the sticky scene they sit in (02 under its pin).
 */
type Knock = { rect: Carried; clip: Carried | null; track: ScrollTrack };

/** A line of small copy (COPY_LINE) and the track that carries it: the page, a sticky scene, or the viewport (the rail). */
type Line = { box: Carried; track: ScrollTrack };

/** No stage found for a copy rect: nothing clips its fade. */
const UNCLIPPED: Rect = { x: -1e5, y: -1e5, width: 2e5, height: 2e5 };

function readMotion(): Motion {
  const style = getComputedStyle(document.documentElement);
  const ms = (name: string, fallback: number) => parseDuration(style.getPropertyValue(name)) ?? fallback;
  // Fallbacks mirror the motion tokens in tokens.css and are used only if a token is missing.
  return {
    introMs: ms("--t-intro-turn", 1300),
    revMs: ms("--t-radar-rev", 6000),
    snapMs: ms("--t-snap", 600),
    detent: easingToken("--ease-detent", [0.75, 0, 0.2, 1]),
    out: easingToken("--ease-out", [0.16, 1, 0.3, 1]),
  };
}

function readPalette(): RadarPalette {
  const style = getComputedStyle(document.documentElement);
  const triplet = (name: string, fallback: Rgb) => parseRgb(style.getPropertyValue(name)) ?? fallback;
  const light = document.documentElement.dataset.theme === "light";
  return {
    signal: triplet("--accent-rgb", [0, 0, 0]),
    ember: triplet("--alert-rgb", [0, 0, 0]),
    ink: triplet("--fg-rgb", [0, 0, 0]),
    light,
  };
}

/**
 * The intro turn: one revolution with a t^2.2 ease-out. A pure ease-out ends at
 * zero speed, so the beam would stall before the steady 6 s revolution; blending
 * in a linear share equal to the steady speed's (intro / revolution) makes the
 * hand-off seamless: f(0) = 0, f(1) = 1, f′(1) = intro / rev.
 */
export function introTurn(k: number, introMs: number, revMs: number): number {
  const linear = Math.min(1, introMs / revMs);
  const t = Math.min(1, Math.max(0, k));
  return (1 - linear) * (1 - Math.pow(1 - t, 2.2)) + linear * t;
}

/** The renderer on this thread, for browsers that cannot run WebGL2 in a worker: WebGL2, else canvas 2D. */
async function createLocalRenderer(canvas: HTMLCanvasElement, events: RendererEvents): Promise<RadarRenderer | null> {
  const { createGlRenderer } = await import("./radar-gl");
  const gl = await createGlRenderer(canvas, events);
  if (gl) return gl;
  const { createFallbackRenderer } = await import("./radar-fallback-2d");
  return createFallbackRenderer(canvas);
}

export async function createRadar(canvas: HTMLCanvasElement, options: RadarOptions): Promise<RadarHandle | null> {
  let lost = false;
  // Context loss: stop drawing and hide the labels (no instrument, no readings);
  // on restore the renderer has rebuilt its GL objects, so re-bake and resume.
  // These run long after setup (renderer work is always a task or more away), so
  // they may use the state declared below.
  const rendererEvents: RendererEvents = {
    onLost() {
      lost = true;
      stopLoop();
      syncLabels(lastScrollY);
    },
    onRestored() {
      lost = false;
      needsBake = true;
      relabel(true);
      wake();
    },
    onRedraw() {
      wake();
    },
  };
  const renderer =
    (await createRemoteRenderer(canvas, rendererEvents)) ?? (await createLocalRenderer(canvas, rendererEvents));
  if (!renderer) return null;

  const { layer, box, gauge, echoes, largest, todayDoy, onLabels } = options;
  const span = layer.parentElement ?? layer;
  const motion = readMotion();
  const omega = TAU / motion.revMs;
  const nameEl = document.getElementById(sceneMeta("scan").headingId);
  /** The 2D fallback draws static frames only. */
  const animated = renderer.kind === "webgl2";
  let still = isStill() || !animated;

  /* State */
  let palette = readPalette();
  let measured: Measure | null = null;
  let surface: Surface | null = null;
  let scanCam: Camera | null = null;
  let fieldCam: Camera | null = null;
  let bakedFor: Camera | null = null;
  let needsBake = true;
  let lastCameraMove = 0;
  let mix = 0;
  let lastRaw: number | null = null;
  let tween: { from: number; to: number; start: number } | null = null;
  let calibration = document.documentElement.hasAttribute("data-calibration");
  let day = todayDoy;
  let flareDay = -1;
  let flareStart = -Infinity;
  let stormStart = -Infinity;
  let stormMs = STORM_MS;
  let stillStorm = false;
  let stillStormTimer = 0;
  let elapsed = 0;
  let bearing = INTRO_START_RAD;
  let introDone = still;
  let introCut = false;
  let flow: ParticleFlow = { fall: 0, extra: 0, drift: 0 };
  let lastScrollY = window.scrollY;
  let pointerView: Point | null = null;
  let pointer: Point | null = null;
  let knocks: Knock[] = [];
  let lines: Line[] = [];
  let baseParticles = 0;
  let onScreen = false;
  let lastBearingEvent = -Infinity;
  let lastPing = -Infinity;
  let destroyed = false;
  let raf = 0;
  let lastNow = 0;
  let drawRequested = false;
  let firstDraw = true;
  /** Layout is read at the start of the next drawn frame, never in the middle of one. */
  let remeasureDue = true;
  let remeasureTimer = 0;
  let hoverArmed = false;
  let stopHoverCut = () => {};

  /* Labels: one layout per settled framing, shown only while it still matches the page. */
  let labelLayout: LabelLayout | null = null;
  let labelFraming: Framing | null = null;
  let labelTrack: ScrollTrack | null = null;
  /** Set by a relayout or a Calibration toggle; the next settled framing lays the labels out again. */
  let labelsDirty = true;
  let published: LabelFrame | null = null;

  /* Cluster ping: report counts per 2° of bearing from Denver, for echoes at or before the playhead. */
  const pingEchoes = echoBearings(echoes);
  const bins = new Uint16Array(360 / PING_BIN_DEG);
  let binsDay = Number.NaN;

  /* ── Geometry ─────────────────────────────────────────────────────────── */

  const mobile = () => !isDesktop();
  /** Viewport top of the canvas box at a scroll position: sticky math, no layout read. */
  const topAt = (scrollY: number) => (measured ? trackTop(measured.canvas, scrollY) : 0);
  const left = () => measured?.left ?? 0;

  /** A scene-relative rect (radar-measure.ts) in canvas px, with the page at the scene's rest position. */
  const atRest = (rect: Rect, scene: SceneMeasure): Rect => {
    const rest = scene.track.top;
    return {
      x: rect.x - left(),
      y: rect.y + trackTop(scene.track, rest) - topAt(rest),
      width: rect.width,
      height: rect.height,
    };
  };

  const zoneFor = (scene: SceneMeasure, columns: { from: number; to: number }) => {
    if (!measured) return null;
    const zone = freeBand(scene.content, { ...columns, count: measured.columns, gap: measured.gap }, scene.obstacles);
    return zone ? atRest(zone, scene) : null;
  };

  const camera = (): Camera | null => {
    if (!scanCam) return null;
    return fieldCam && mix > 0 ? mixCamera(scanCam, fieldCam, mix) : scanCam;
  };

  const settledFraming = (): Framing | null => (mix <= 0.001 ? "scan" : mix >= 0.999 ? "field" : null);

  /* ── Layout (resize, font load, layout shifts) ─────────────────────────── */

  const relayout = () => {
    const desktop = isDesktop();
    // Mobile: the canvas lives in 01 only, so the box takes 01's height; desktop: CSS (100svh, sticky).
    const scanSection = document.getElementById("scan");
    if (!desktop && scanSection) box.style.setProperty("height", `${scanSection.offsetHeight}px`);
    else box.style.removeProperty("height");
    measured = measure(layer, box, gauge, desktop);
    const size = measured.size;
    if (size.width === 0 || size.height === 0) return;
    const memory = deviceMemory();
    const lowMemory = memory !== null && memory <= LOW_MEMORY_GB;
    const cap = lowMemory ? 1 : desktop ? DESKTOP_DPR : MOBILE_DPR;
    const next: Surface = { ...size, dpr: Math.min(cap, window.devicePixelRatio || 1), mobile: !desktop };
    if (!surface || surface.width !== next.width || surface.height !== next.height || surface.dpr !== next.dpr || surface.mobile !== next.mobile) {
      surface = next;
      renderer.setSurface(next);
      baseParticles = particleCount(next, next.mobile, lowMemory);
      renderer.setParticles(particleSeeds(baseParticles * STORM_DENSITY));
    }
    const scan = measured.scan;
    const field = measured.field;
    const scanZone = scan ? zoneFor(scan, { from: 1, to: desktop ? 7 : measured.columns }) : null;
    scanCam = scanZone ? scanCamera(scanZone, size, !desktop) : null;
    const fieldZone = field && desktop ? zoneFor(field, { from: 6, to: 12 }) : null;
    fieldCam = fieldZone ? fieldCamera(fieldZone, size) : null;
    if (!desktop) mix = 0;
    needsBake = true;
    collectKnockouts();
    collectLines();
    relabel(true);
  };

  /* ── Labels ───────────────────────────────────────────────────────────── */

  const avoidFor = (scene: SceneMeasure): Rect[] => {
    const top = topAt(scene.track.top);
    const fixed = (measured?.fixedAvoid ?? []).map((rect) => ({ ...rect, x: rect.x - left(), y: rect.y - top }));
    return [...scene.obstacles.map((rect) => atRest(rect, scene)), ...fixed];
  };

  const maxVisible = () => calibration || (largest !== null && day >= largest.doy);
  /** The static layers have drawn in (the outline draws first, then the labels read). */
  const revealed = () => still || introDone || elapsed >= REVEAL_MS;

  const labelsVisible = (scrollY: number): boolean =>
    !lost &&
    measured !== null &&
    labelTrack !== null &&
    labelFraming !== null &&
    labelFraming === settledFraming() &&
    revealed() &&
    trackDrift(labelTrack, measured.canvas, scrollY) <= LABEL_DRIFT_PX;

  /** Publish the labels when their layout or visibility changed; React re-renders only then. */
  const syncLabels = (scrollY: number) => {
    if (!labelLayout) return;
    const visible = labelsVisible(scrollY);
    const maxShown = maxVisible();
    if (published?.layout === labelLayout && published.visible === visible && published.maxVisible === maxShown) return;
    published = { layout: labelLayout, visible, maxVisible: maxShown };
    onLabels(published);
  };

  /**
   * Lay the labels out for the settled framing. `force` marks the layout stale (a
   * relayout, a Calibration toggle); if the camera is between framings right now,
   * the layout is redone when it settles.
   */
  const relabel = (force = false) => {
    if (force) labelsDirty = true;
    const framing = settledFraming();
    if (!framing || (!labelsDirty && framing === labelFraming)) return;
    const scene = framing === "field" ? measured?.field : measured?.scan;
    const cam = framing === "field" ? fieldCam : scanCam;
    if (!scene || !cam || !surface || !measured) {
      // No camera for this framing (yet): whatever layout exists belongs elsewhere, so it stays hidden.
      labelFraming = null;
      return;
    }
    labelLayout = layoutLabels({
      camera: cam,
      size: surface,
      framing,
      mobile: surface.mobile,
      calibration,
      largest,
      unavailable: echoes === null,
      bounds: atRest(scene.content, scene),
      avoid: avoidFor(scene),
      fontPx: measured.hudPx,
    });
    labelFraming = framing;
    labelTrack = scene.track;
    labelsDirty = false;
  };

  /* ── Knockouts ────────────────────────────────────────────────────────── */

  const collectKnockouts = () => api.knockouts(visibleKnockouts(span));

  /** Every line of small copy in the span and the rail, carried like the knockouts so it moves with its copy per frame. */
  const collectLines = () => {
    const scrollY = window.scrollY;
    const { flow, fixed } = copyLines(isDesktop());
    lines = [
      ...flow.map((box) => {
        const track = trackFor(box, scrollY);
        return { box: carry(box, track, scrollY), track };
      }),
      ...fixed.map((box) => ({ box: carry(box, VIEWPORT_TRACK, scrollY), track: VIEWPORT_TRACK })),
    ];
  };

  /**
   * The stage content box (viewport px, now) of the scene a copy rect sits in,
   * grown by the copy padding: the rect may touch it, and its fade ramps out
   * inside it, so the frame margin is never darkened.
   */
  const stageAround = (rect: Rect, scrollY: number): Rect | null => {
    const cx = rect.x + rect.width / 2;
    const cy = rect.y + rect.height / 2;
    for (const scene of [measured?.scan, measured?.field]) {
      if (!scene) continue;
      const top = trackTop(scene.track, scrollY);
      const box = { ...scene.content, y: scene.content.y + top };
      if (cx >= box.x && cx <= box.x + box.width && cy >= box.y && cy <= box.y + box.height) {
        return { x: box.x - KNOCK_PAD.x, y: box.y - KNOCK_PAD.y, width: box.width + 2 * KNOCK_PAD.x, height: box.height + 2 * KNOCK_PAD.y };
      }
    }
    return null;
  };

  /**
   * Which track carries a copy rect: a sticky scene it currently sits in (02 under
   * its pin holds its copy still while the page scrolls), otherwise the page.
   */
  const trackFor = (rect: Rect, scrollY: number): ScrollTrack => {
    const middle = rect.y + rect.height / 2;
    for (const scene of [measured?.scan, measured?.field]) {
      if (!scene?.track.sticky) continue;
      const top = trackTop(scene.track, scrollY);
      if (middle >= top && middle < top + scene.track.height) return scene.track;
    }
    return PAGE_TRACK;
  };

  /* ── Frame ────────────────────────────────────────────────────────────── */

  const frameState = (now: number, cam: Camera, scrollY: number, size: Size): FrameState => {
    const top = topAt(scrollY);
    const x0 = left();
    const place = (box: Carried, track: ScrollTrack): Rect => ({
      x: box.x - x0,
      y: trackTop(track, scrollY) + box.offset - top,
      width: box.width,
      height: box.height,
    });
    const knockouts = knocks.map(({ rect, clip, track }) => ({
      rect: place(rect, track),
      clip: clip ? place(clip, track) : UNCLIPPED,
    }));
    // Only the lines over the canvas now (02's copy is below 01's at first), up to the shader's slots.
    const reach = COPY_LINE.padX + COPY_LINE.featherPx;
    const onCanvas: Rect[] = [];
    for (const { box, track } of lines) {
      if (onCanvas.length >= MAX_COPY_LINES) break;
      const rect = place(box, track);
      if (rect.x + rect.width + reach < 0 || rect.x - reach > size.width || rect.y + rect.height + reach < 0 || rect.y - reach > size.height) continue;
      onCanvas.push(rect);
    }
    if (still) {
      return {
        camera: cam,
        den: denverOf(cam),
        ring: ringRadius(cam),
        bearing: (STILL_BEARING_DEG * Math.PI) / 180,
        startBearing: INTRO_START_RAD,
        swept: Infinity,
        reveal: Infinity,
        day,
        flareDay: -1,
        flare: 0,
        storm: stillStorm ? 1 : 0,
        surge: 0,
        calibration,
        flow: { fall: 0, extra: 0, drift: 0 },
        pointer: null,
        knockouts,
        lines: onCanvas,
        particles: baseParticles,
      };
    }
    return {
      camera: cam,
      den: denverOf(cam),
      ring: ringRadius(cam),
      bearing,
      startBearing: INTRO_START_RAD,
      swept: introDone ? Infinity : Math.max(0, bearing - INTRO_START_RAD),
      reveal: introDone ? Infinity : TAU * motion.out(Math.min(1, elapsed / REVEAL_MS)),
      day,
      flareDay,
      flare: Math.max(0, 1 - (now - flareStart) / FLARE_MS),
      storm: Math.max(0, 1 - (now - stormStart) / stormMs),
      // The onset flare: on at once, easing out, and never longer than the storm itself.
      surge: 1 - smoothstep(0, Math.min(SURGE_MS, stormMs), now - stormStart),
      calibration,
      flow,
      pointer,
      knockouts,
      lines: onCanvas,
      particles: baseParticles,
    };
  };

  /** Advance the clock: beam, intro cut, cluster ping, particle flow, framing tween, pointer. */
  const step = (now: number, dt: number, scrollY: number) => {
    elapsed += dt;
    // Keep the bearing small: a float32 uniform loses a visible fraction of a degree after a day of turns.
    if (introDone && bearing >= TAU) bearing -= TAU;
    const before = bearing;
    const turn = elapsed - TURN_DELAY_MS;
    if (introDone) {
      bearing += dt * omega;
    } else if (turn < 0) {
      bearing = INTRO_START_RAD;
    } else if (turn < motion.introMs) {
      bearing = INTRO_START_RAD + TAU * introTurn(turn / motion.introMs, motion.introMs, motion.revMs);
    } else {
      bearing = INTRO_START_RAD + TAU + (turn - motion.introMs) * omega;
      introDone = true;
    }

    // The intro turn cuts the name once, as the beam crosses due south.
    if (!introCut && !introDone) {
      const south = INTRO_START_RAD + ((((Math.PI - INTRO_START_RAD) % TAU) + TAU) % TAU);
      if (before < south && bearing >= south) {
        introCut = true;
        const pivot = api.denScreen();
        if (nameEl && pivot) beamCut({ target: nameEl, pivot, bearingDeg: 180 });
      }
    }

    // Cluster ping: one tick when the beam enters a 2° bin holding ≥ 3 reports.
    if (echoes && document.documentElement.dataset.sound === "on" && now - lastPing >= PING_GAP_MS) {
      if (binsDay !== day) {
        countBins(pingEchoes, day, bins);
        binsDay = day;
      }
      if (crossedCluster(bins, before, bearing)) {
        emit("acx:sfx", { cue: "ping" });
        lastPing = now;
      }
    }

    // Particle flow: storm-scaled clock, scroll-velocity fall, westerly drift.
    const frames = dt / FRAME_MS;
    const storm = Math.max(0, 1 - (now - stormStart) / stormMs);
    const velocity = Math.abs(scrollY - lastScrollY) / Math.max(frames, 1e-3);
    lastScrollY = scrollY;
    flow = {
      fall: flow.fall + frames * (1 + FLOW.stormFall * storm),
      extra: flow.extra + Math.min(8, velocity) * 0.35 * frames,
      drift: flow.drift + (FLOW.drift + FLOW.stormDrift * storm) * frames,
    };

    if (tween) {
      const k = Math.min(1, (now - tween.start) / motion.snapMs);
      setMix(tween.from + (tween.to - tween.from) * motion.detent(k), now);
      if (k >= 1) tween = null;
    }

    // Pointer, eased so the particle field parts smoothly.
    if (pointerView && measured) {
      const target = { x: pointerView.x - left(), y: pointerView.y - topAt(scrollY) };
      pointer = pointer
        ? { x: pointer.x + (target.x - pointer.x) * POINTER_LERP, y: pointer.y + (target.y - pointer.y) * POINTER_LERP }
        : target;
    } else {
      pointer = null;
    }

    if (now - lastBearingEvent >= BEARING_EVENT_MS) {
      lastBearingEvent = now;
      emit("acx:bearing", { deg: Math.round((((bearing * 180) / Math.PI) % 360 + 360) % 360) });
    }
  };

  const setMix = (value: number, now: number) => {
    if (Math.abs(value - mix) < 1e-4) return;
    mix = value;
    lastCameraMove = now;
    relabel();
  };

  /** Re-bake once the camera has held still for SETTLE_MS, so the static layers are crisp again. */
  const settle = (now: number, cam: Camera) => {
    if (needsBake || !bakedFor || (!sameCamera(cam, bakedFor) && now - lastCameraMove >= SETTLE_MS)) {
      renderer.bake(cam);
      bakedFor = cam;
      needsBake = false;
      relabel();
    }
  };

  const render = (now: number, scrollY: number) => {
    const cam = camera();
    if (!cam || !surface) return;
    settle(now, cam);
    // False until the programs are built (they compile off the main thread): no
    // "ready" yet, so stills and tests wait for a real frame.
    const drew = renderer.draw(frameState(now, cam, scrollY, surface));
    syncLabels(scrollY);
    if (firstDraw && drew) {
      firstDraw = false;
      canvas.dataset.ready = "";
      if (still) emit("acx:bearing", { deg: STILL_BEARING_DEG });
    }
  };

  /* ── Loop ─────────────────────────────────────────────────────────────── */

  const running = () => !destroyed && !still && !lost && onScreen && !document.hidden;

  const loop = (now: number) => {
    raf = 0;
    if (!running()) return;
    const dt = lastNow ? Math.min(50, now - lastNow) : FRAME_MS;
    lastNow = now;
    if (remeasureDue) {
      remeasureDue = false;
      relayout();
    }
    // The frame's one layout-adjacent read; everything below works from this value.
    const scrollY = window.scrollY;
    // The clock starts with the first real frame, so the intro turn is never spent
    // while the programs are still compiling.
    if (!firstDraw) step(now, dt, scrollY);
    render(now, scrollY);
    raf = requestAnimationFrame(loop);
  };

  function stopLoop() {
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    lastNow = 0;
  }

  /**
   * Start the loop, or (STILL, the 2D fallback) draw one frame on the next animation
   * frame. Nothing draws while the canvas is off screen or the tab is hidden: the
   * IntersectionObserver and visibilitychange wake it again with a fresh frame.
   */
  function wake() {
    if (destroyed) return;
    if (running()) {
      if (!raf) {
        lastScrollY = window.scrollY;
        raf = requestAnimationFrame(loop);
      }
      return;
    }
    if (drawRequested || lost || !onScreen || document.hidden) return;
    drawRequested = true;
    requestAnimationFrame((now) => {
      drawRequested = false;
      if (destroyed || lost) return;
      if (remeasureDue) {
        remeasureDue = false;
        relayout();
      }
      lastScrollY = window.scrollY;
      render(now, lastScrollY);
    });
  }

  /** The hover cut: armed once, for a fine pointer with motion allowed; it disarms itself after one cut. */
  const armHover = () => {
    if (hoverArmed || still || !nameEl || !isFinePointer()) return;
    hoverArmed = true;
    stopHoverCut = armHoverCut(nameEl, () => api.denScreen());
  };

  /* ── Wiring ───────────────────────────────────────────────────────────── */

  const scheduleRemeasure = () => {
    window.clearTimeout(remeasureTimer);
    remeasureTimer = window.setTimeout(() => {
      remeasureDue = true;
      wake();
    }, REMEASURE_MS);
  };

  const resizeObserver = new ResizeObserver(scheduleRemeasure);
  resizeObserver.observe(box);
  resizeObserver.observe(layer);
  for (const id of ["scan", "observe"] as const) {
    const section = document.getElementById(id);
    if (section) resizeObserver.observe(section);
  }

  const intersection = new IntersectionObserver(([entry]) => {
    onScreen = entry?.isIntersecting ?? false;
    if (onScreen) wake();
    else stopLoop();
  });
  intersection.observe(canvas);

  const onVisibility = () => (document.hidden ? stopLoop() : wake());
  document.addEventListener("visibilitychange", onVisibility);

  const onPointerMove = (event: PointerEvent) => {
    if (event.pointerType === "touch") return;
    pointerView = { x: event.clientX, y: event.clientY };
  };
  const onPointerLeave = () => {
    pointerView = null;
  };
  window.addEventListener("pointermove", onPointerMove, { passive: true });
  document.documentElement.addEventListener("pointerleave", onPointerLeave);

  let scrollTimer = 0;
  const onScroll = () => {
    // Stills redraw so the knockouts and labels follow copy moving across the held
    // (sticky) canvas; the loop already does each frame. A phone's canvas scrolls
    // with its copy, so nothing moves against it and no frame is spent.
    if (still && measured?.canvas.sticky) wake();
    window.clearTimeout(scrollTimer);
    scrollTimer = window.setTimeout(() => {
      collectKnockouts();
      collectLines();
      wake();
    }, SCROLL_END_MS);
  };
  window.addEventListener("scroll", onScroll, { passive: true });

  // Reduced motion flipping (STILL), or a mouse arriving (the hover cut).
  const stopMotionWatch = onMotionChange(() => {
    const next = isStill() || !animated;
    if (next !== still) {
      still = next;
      if (still) {
        stopLoop();
        introDone = true;
        emit("acx:bearing", { deg: STILL_BEARING_DEG });
      }
      wake();
    }
    armHover();
  });

  void document.fonts?.ready.then(() => {
    if (!destroyed) scheduleRemeasure();
  });

  const stops = [
    listen("acx:theme", () => {
      palette = readPalette();
      renderer.setPalette(palette);
      needsBake = true;
      wake();
    }),
    listen("acx:calibration", ({ on }) => {
      calibration = on;
      relabel(true);
      wake();
    }),
  ];

  renderer.setPalette(palette);
  renderer.setEchoes(echoes);

  /* ── RadarApi ─────────────────────────────────────────────────────────── */

  const drawn = () => !destroyed && onScreen && !document.hidden && camera() !== null;

  const api: RadarHandle = {
    setFraming(value) {
      if (destroyed || mobile()) return;
      const raw = Math.min(1, Math.max(0, value));
      const target = motion.detent(raw);
      const now = performance.now();
      const jump = lastRaw === null ? 0 : Math.abs(raw - lastRaw);
      lastRaw = raw;
      if (jump > FRAMING_STEP && !still) {
        tween = { from: mix, to: target, start: now };
      } else {
        tween = null;
        setMix(target, now);
      }
      wake();
    },

    setDay(value) {
      if (destroyed) return;
      day = Math.min(todayDoy, Math.max(0, value));
      wake();
    },

    flare(value) {
      if (destroyed || still) return;
      flareDay = Math.round(value);
      flareStart = performance.now();
      wake();
    },

    hail(durationMs = STORM_MS) {
      if (destroyed) return;
      if (still) {
        // Reduced motion: one static ember frame for the storm's length, then back.
        stillStorm = true;
        window.clearTimeout(stillStormTimer);
        stillStormTimer = window.setTimeout(() => {
          stillStorm = false;
          wake();
        }, durationMs);
        wake();
        return;
      }
      stormMs = Math.max(1, durationMs);
      stormStart = performance.now();
      wake();
    },

    latLon(point: ScreenPoint): LatLon | null {
      const cam = camera();
      if (!drawn() || !cam || !measured) return null;
      const { lon, lat } = toLonLat(cam, point.x - left(), point.y - topAt(window.scrollY));
      return inColorado(lat, lon) ? { lat, lon } : null;
    },

    denScreen(): ScreenPoint | null {
      const cam = camera();
      if (!drawn() || !cam || !measured) return null;
      const den = denverOf(cam);
      return { x: den.x + left(), y: den.y + topAt(window.scrollY) };
    },

    knockouts(rects: readonly KnockoutRect[]) {
      if (destroyed) return;
      const scrollY = window.scrollY;
      knocks = rects.slice(0, MAX_KNOCKOUTS).map((rect) => {
        const padded = {
          x: rect.x - KNOCK_PAD.x,
          y: rect.y - KNOCK_PAD.y,
          width: rect.width + 2 * KNOCK_PAD.x,
          height: rect.height + 2 * KNOCK_PAD.y,
        };
        const track = trackFor(padded, scrollY);
        const stage = stageAround(rect, scrollY);
        return { rect: carry(padded, track, scrollY), clip: stage ? carry(stage, track, scrollY) : null, track };
      });
      wake();
    },

    destroy() {
      if (destroyed) return;
      destroyed = true;
      stopLoop();
      window.clearTimeout(remeasureTimer);
      window.clearTimeout(scrollTimer);
      window.clearTimeout(stillStormTimer);
      resizeObserver.disconnect();
      intersection.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      window.removeEventListener("pointermove", onPointerMove);
      document.documentElement.removeEventListener("pointerleave", onPointerLeave);
      window.removeEventListener("scroll", onScroll);
      stopMotionWatch();
      for (const stop of stops) stop();
      stopHoverCut();
      disposeBeamCut();
      renderer.destroy();
      onLabels(null);
    },
  };

  armHover();
  wake();
  return api;
}

src/components/radar/radar-gl.ts

TypeScript · 574 lines · 25.1 KB

/**
 * The WebGL2 renderer: one context on the one radar canvas, four draws a frame.
 * It runs in the radar worker on the canvas's OffscreenCanvas
 * (radar-worker.ts), or on the page's canvas where workers cannot host WebGL2.
 *
 *   1. sweep     fullscreen triangle: baked static layers + phosphor wedge + beam
 *   2. knockout  fullscreen triangle scissored to the copy rects, (ZERO, 1 − src α),
 *                at KNOCKOUT.sweepCap: the instrument still reads faintly behind copy
 *   3. echoes    POINTS, one per hail report location, additive in dark
 *   4. particles POINTS, stateless (position = f(seed, flow uniforms)); dust,
 *                or hailstones with streaks while the "hail" egg's storm runs
 * Echoes and particles fade fully behind copy by the same field (points.frag);
 * particles also vanish on every line of small copy (particles.vert, COPY_LINE).
 *
 * Buffers are uploaded once (echoes) or on resize (particle seeds, bake texture);
 * a frame is uniform writes and four draw calls. No depth, no stencil, no MSAA:
 * the shaders antialias their own edges, and the canvas is premultiplied alpha over
 * the CSS sky. Context loss drops every GL object; on restore everything is rebuilt
 * from the inputs kept here, and the controller redraws.
 *
 * Nothing here holds a thread for long. Creating the context, reading the parallel
 * compile extension, making the bake surface, starting the programs and the first
 * texture upload each run in their own task, so a busy GPU process costs at most one
 * round trip per task. The four programs compile without blocking: they are all
 * started at once and no status is read until KHR_parallel_shader_compile says the
 * driver is done (polled between frames). Until then draw() returns false and the
 * controller keeps waiting; onRestored tells it to bake and draw. Without the
 * extension each program's status is read in its own task, so the wait is split
 * four ways instead of one long task.
 */
import { MAX_KNOCKOUTS } from "@/lib/contracts";
import { COLORADO, LON_SCALE } from "@/lib/geo";
import { FULLSCREEN_VERT } from "@/shaders/fullscreen.vert.glsl";
import { KNOCKOUT_FRAG } from "@/shaders/knockout.frag.glsl";
import { PARTICLES_VERT } from "@/shaders/particles.vert.glsl";
import { POINTS_FRAG } from "@/shaders/points.frag.glsl";
import { POINTS_VERT } from "@/shaders/points.vert.glsl";
import { RADAR_FRAG } from "@/shaders/radar.frag.glsl";
import { bakeStatic, createBakeTarget, fitBakeTarget, type BakeTarget } from "./radar-bake";
import type { Camera, Rect } from "./radar-camera";
import { ECHO_STRIDE, type EchoBuffer } from "./radar-data";
import {
  COPY_LINE,
  ECHO_RADIUS,
  KNOCKOUT,
  MAX_COPY_LINES,
  STORM_DENSITY,
  WEDGE_ALPHA,
  type Knockout,
  type RadarCanvasTarget,
  type RadarPalette,
  type RadarRenderer,
  type RendererEvents,
  type Rgb,
  type Surface,
} from "./radar-render";

type Program = { program: WebGLProgram; uniforms: Map<string, WebGLUniformLocation> };

type Resources = {
  sweep: Program;
  echoes: Program;
  particles: Program;
  knockout: Program;
  /** Attribute-less VAO for the fullscreen passes. */
  empty: WebGLVertexArrayObject;
  echoVao: WebGLVertexArrayObject;
  echoBuffer: WebGLBuffer;
  particleVao: WebGLVertexArrayObject;
  particleBuffer: WebGLBuffer;
  bake: WebGLTexture;
  maxPoint: number;
};

const CONTEXT_ATTRIBUTES: WebGLContextAttributes = {
  alpha: true,
  premultipliedAlpha: true,
  antialias: false,
  depth: false,
  stencil: false,
  preserveDrawingBuffer: false,
  powerPreference: "low-power",
  failIfMajorPerformanceCaveat: true,
};

/** The page's canvas and an OffscreenCanvas type getContext separately; the call is the same. */
function webgl2(canvas: RadarCanvasTarget): WebGL2RenderingContext | null {
  return "transferControlToOffscreen" in canvas
    ? canvas.getContext("webgl2", CONTEXT_ATTRIBUTES)
    : canvas.getContext("webgl2", CONTEXT_ATTRIBUTES);
}

/** Resolve in a fresh task, so the next GPU round trip does not extend this one. */
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));

/**
 * null when the browser has no WebGL2, or only a software one: the controller then
 * uses the canvas-2D fallback (a static frame).
 *
 * failIfMajorPerformanceCaveat refuses a CPU rasterizer (SwiftShader: machines with
 * no usable GPU, VMs, headless test browsers). There, creating the context and
 * compiling the four programs blocked the main thread for over a second on a
 * 4×-throttled CPU (measured: 1.2 s long task, ~3 s in Lighthouse mobile), and every
 * frame after it would be drawn on the CPU too. A real GPU never sees the caveat.
 */
export async function createGlRenderer(canvas: RadarCanvasTarget, events: RendererEvents): Promise<RadarRenderer | null> {
  const gl = webgl2(canvas);
  if (!gl) return null;
  await nextTask();
  const parallel = gl.getExtension("KHR_parallel_shader_compile") as { COMPLETION_STATUS_KHR: number } | null;
  await nextTask();
  const bakeTarget = createBakeTarget();
  if (!bakeTarget) return null;

  let res: Resources | null = null;
  // Plain setTimeout: this runs on the page or in the radar worker, which has no window.
  let buildTimer: ReturnType<typeof setTimeout> | undefined;
  let uploadTimer: ReturnType<typeof setTimeout> | undefined;
  let destroyed = false;
  let surface: Surface | null = null;
  let palette: RadarPalette | null = null;
  let echoData: Float32Array | null = null;
  let particleData: Float32Array = new Float32Array(0);
  /** The camera the bake surface was last drawn for, and the one the texture holds (one upload later). */
  let bakeCamera: Camera | null = null;
  let textureCamera: Camera | null = null;
  const knockData = { rects: new Float32Array(MAX_KNOCKOUTS * 4), clips: new Float32Array(MAX_KNOCKOUTS * 4), count: 0 };
  const lineData = { rects: new Float32Array(MAX_COPY_LINES * 4), count: 0 };

  /** Upload the bake in its own task: a texture upload from a canvas can wait on the GPU process. */
  const scheduleUpload = () => {
    clearTimeout(uploadTimer);
    uploadTimer = setTimeout(() => {
      uploadTimer = undefined;
      if (destroyed || !res || !bakeCamera || gl.isContextLost()) return;
      uploadBake(gl, res.bake, bakeTarget);
      textureCamera = bakeCamera;
      events.onRedraw();
    }, 0);
  };

  const upload = (resources: Resources) => {
    if (echoData) uploadBuffer(gl, resources.echoBuffer, echoData);
    uploadBuffer(gl, resources.particleBuffer, particleData);
    if (bakeCamera) scheduleUpload();
  };

  /**
   * Start every program in a task of its own, then finish once the driver reports
   * them compiled (or a task later without the extension). A context lost on the
   * way drops the build; its restore event starts a new one.
   */
  const build = () => {
    clearTimeout(buildTimer);
    buildTimer = setTimeout(() => {
      if (destroyed || gl.isContextLost()) return;
      let pending: PendingPrograms;
      try {
        pending = startPrograms(gl);
      } catch {
        return; // lost mid-start: stay dark until the next restore
      }
      // One program is checked per task: with the extension only once the driver is
      // done with it; without it, the status read blocks for one program at a time.
      const ready = (name: ProgramName) =>
        !parallel || gl.getProgramParameter(pending[name].program, parallel.COMPLETION_STATUS_KHR) === true;
      const built: Partial<Record<ProgramName, Program>> = {};
      const step = () => {
        buildTimer = undefined;
        if (destroyed || gl.isContextLost()) return;
        try {
          const next = PROGRAMS.find((name) => !built[name]);
          if (next) {
            if (ready(next)) built[next] = finishProgram(gl, pending[next]);
            buildTimer = setTimeout(step, built[next] ? 0 : BUILD_POLL_MS);
            return;
          }
          res = finishInit(gl, built as Record<ProgramName, Program>);
        } catch (error) {
          // A failed build leaves the radar dark (draw returns false); say why in development.
          if (process.env.NODE_ENV !== "production") console.error(error);
          return;
        }
        upload(res);
        events.onRestored();
      };
      buildTimer = setTimeout(step, 0);
    }, 0);
  };

  const onLost = (event: Event) => {
    event.preventDefault(); // tells the browser we will rebuild, so it may restore the context
    clearTimeout(buildTimer);
    clearTimeout(uploadTimer);
    res = null;
    textureCamera = null;
    events.onLost();
  };
  const onRestored = () => build();
  canvas.addEventListener("webglcontextlost", onLost);
  canvas.addEventListener("webglcontextrestored", onRestored);
  build();

  return {
    kind: "webgl2",

    setSurface(next) {
      surface = next;
      canvas.width = Math.max(1, Math.round(next.width * next.dpr));
      canvas.height = Math.max(1, Math.round(next.height * next.dpr));
      fitBakeTarget(bakeTarget, next);
    },

    setPalette(next) {
      palette = next;
    },

    setEchoes(echoes: EchoBuffer | null) {
      echoData = echoes ? Float32Array.from(echoes) : null;
      if (res && echoData) uploadBuffer(gl, res.echoBuffer, echoData);
    },

    setParticles(seeds) {
      particleData = seeds;
      if (res) uploadBuffer(gl, res.particleBuffer, particleData);
    },

    bake(camera) {
      if (!surface || !palette) return;
      bakeStatic(bakeTarget.ctx, { surface, camera, palette });
      bakeCamera = camera;
      if (res) scheduleUpload();
    },

    draw(frame) {
      if (!res || !surface || !palette || !textureCamera || gl.isContextLost()) return false;
      const { width, height, dpr, mobile } = surface;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      // 1 · Sweep over the baked static layers. It writes every pixel of a cleared
      // buffer, so blending would only add a framebuffer read: it is off for this pass.
      gl.disable(gl.BLEND);
      const sweep = res.sweep;
      gl.useProgram(sweep.program);
      gl.bindVertexArray(res.empty);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, res.bake);
      gl.uniform1i(at(sweep, "uBake"), 0);
      gl.uniform2f(at(sweep, "uSize"), width, height);
      gl.uniform1f(at(sweep, "uDpr"), dpr);
      setCamera(gl, at(sweep, "uCam"), frame.camera);
      setCamera(gl, at(sweep, "uBakeCam"), textureCamera);
      gl.uniform2f(at(sweep, "uDen"), frame.den.x, frame.den.y);
      gl.uniform1f(at(sweep, "uRing"), frame.ring);
      gl.uniform1f(at(sweep, "uAz"), frame.bearing);
      gl.uniform1f(at(sweep, "uA0"), frame.startBearing);
      gl.uniform1f(at(sweep, "uReveal"), frame.reveal);
      setColor(gl, at(sweep, "uSignal"), palette.signal);
      const wedge = palette.light ? WEDGE_ALPHA.light : WEDGE_ALPHA.dark;
      gl.uniform2f(at(sweep, "uWedge"), wedge[0], wedge[1]);
      gl.uniform1f(at(sweep, "uCalib"), frame.calibration ? 1 : 0);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      gl.enable(gl.BLEND);

      // 2 · Knockouts over the sweep only, capped, so the field still reads through the copy.
      const knocks = frame.knockouts.slice(0, MAX_KNOCKOUTS);
      packKnockouts(knocks, knockData);
      const scissor = knocks.length > 0 ? scissorFor(knocks, surface) : null;
      if (scissor) {
        const knockout = res.knockout;
        gl.enable(gl.SCISSOR_TEST);
        gl.scissor(scissor.x, scissor.y, scissor.width, scissor.height);
        gl.useProgram(knockout.program);
        gl.uniform2f(at(knockout, "uSize"), width, height);
        gl.uniform1f(at(knockout, "uDpr"), dpr);
        setKnockouts(gl, knockout, knockData);
        gl.uniform1f(at(knockout, "uCap"), KNOCKOUT.sweepCap);
        gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
        gl.disable(gl.SCISSOR_TEST);
      }

      // 3 · Echoes: additive light in the dark console, burnt print (source-over) in clear sky.
      const echoCount = echoData ? echoData.length / ECHO_STRIDE : 0;
      if (echoCount > 0) {
        const echoes = res.echoes;
        gl.useProgram(echoes.program);
        gl.bindVertexArray(res.echoVao);
        gl.uniform2f(at(echoes, "uSize"), width, height);
        gl.uniform1f(at(echoes, "uDpr"), dpr);
        setCamera(gl, at(echoes, "uCam"), frame.camera);
        gl.uniform2f(at(echoes, "uNW"), COLORADO.west, COLORADO.north);
        gl.uniform1f(at(echoes, "uLonK"), LON_SCALE);
        gl.uniform2f(at(echoes, "uDen"), frame.den.x, frame.den.y);
        gl.uniform1f(at(echoes, "uAz"), frame.bearing);
        gl.uniform1f(at(echoes, "uA0"), frame.startBearing);
        gl.uniform1f(at(echoes, "uSwept"), frame.swept);
        gl.uniform1f(at(echoes, "uDay"), frame.day);
        gl.uniform1f(at(echoes, "uFlareDay"), frame.flareDay);
        gl.uniform1f(at(echoes, "uFlare"), frame.flare);
        gl.uniform1f(at(echoes, "uSurge"), frame.surge);
        setPointer(gl, at(echoes, "uPointer"), frame.pointer);
        const radius = mobile ? ECHO_RADIUS.mobile : ECHO_RADIUS.desktop;
        gl.uniform2f(at(echoes, "uRadius"), radius[0], radius[1]);
        gl.uniform1f(at(echoes, "uCalib"), frame.calibration ? 1 : 0);
        gl.uniform1f(at(echoes, "uLight"), palette.light ? 1 : 0);
        setColor(gl, at(echoes, "uEmber"), palette.ember);
        gl.uniform1f(at(echoes, "uMaxPoint"), res.maxPoint);
        setKnockouts(gl, echoes, knockData);
        if (palette.light) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
        else gl.blendFunc(gl.ONE, gl.ONE);
        gl.drawArrays(gl.POINTS, 0, echoCount);
      }

      // 4 · Particles; a storm draws the extra two thirds of the buffer, as hailstones.
      const total = particleData.length / 3;
      const count = Math.min(total, frame.storm > 0 ? frame.particles * STORM_DENSITY : frame.particles);
      if (count > 0) {
        const particles = res.particles;
        gl.useProgram(particles.program);
        gl.bindVertexArray(res.particleVao);
        gl.uniform2f(at(particles, "uSize"), width, height);
        gl.uniform1f(at(particles, "uDpr"), dpr);
        gl.uniform3f(at(particles, "uFlow"), frame.flow.fall, frame.flow.extra, frame.flow.drift);
        setPointer(gl, at(particles, "uPointer"), frame.pointer);
        gl.uniform1f(at(particles, "uStorm"), frame.storm);
        gl.uniform1f(at(particles, "uBase"), frame.particles);
        gl.uniform1f(at(particles, "uCount"), count);
        gl.uniform1f(at(particles, "uMaxPoint"), res.maxPoint);
        gl.uniform1f(at(particles, "uLight"), palette.light ? 1 : 0);
        setColor(gl, at(particles, "uInk"), palette.ink);
        setColor(gl, at(particles, "uEmber"), palette.ember);
        setKnockouts(gl, particles, knockData);
        packLines(frame.lines, lineData);
        gl.uniform4fv(at(particles, "uLines"), lineData.rects);
        gl.uniform1i(at(particles, "uLineCount"), lineData.count);
        gl.uniform3f(at(particles, "uLineShape"), COPY_LINE.padX, COPY_LINE.padY, COPY_LINE.featherPx);
        gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
        gl.drawArrays(gl.POINTS, 0, count);
      }
      gl.bindVertexArray(null);
      return true;
    },

    destroy() {
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      // Free the objects but keep the context: a remount (React StrictMode, fast
      // refresh) gets this same context back from getContext, and a context lost on
      // purpose would come back dead.
      destroyed = true;
      clearTimeout(buildTimer);
      clearTimeout(uploadTimer);
      if (res && !gl.isContextLost()) release(gl, res);
      res = null;
      textureCamera = null;
    },
  };
}

/* ── Setup ───────────────────────────────────────────────────────────────── */

/** Poll interval for the driver's compile status (one frame). */
const BUILD_POLL_MS = 16;

const PROGRAMS = ["sweep", "echoes", "particles", "knockout"] as const;
type ProgramName = (typeof PROGRAMS)[number];
type PendingProgram = { program: WebGLProgram; shaders: readonly WebGLShader[] };
type PendingPrograms = Record<ProgramName, PendingProgram>;

/** Compile and link all four programs without reading any status (non-blocking on a parallel-compile driver). */
function startPrograms(gl: WebGL2RenderingContext): PendingPrograms {
  return {
    sweep: startProgram(gl, FULLSCREEN_VERT, RADAR_FRAG),
    echoes: startProgram(gl, POINTS_VERT, POINTS_FRAG),
    particles: startProgram(gl, PARTICLES_VERT, POINTS_FRAG),
    knockout: startProgram(gl, FULLSCREEN_VERT, KNOCKOUT_FRAG),
  };
}

/** The buffers, vertex arrays and bake texture around four built programs. */
function finishInit(gl: WebGL2RenderingContext, programs: Record<ProgramName, Program>): Resources {
  const { sweep, echoes, particles, knockout } = programs;

  const empty = required(gl.createVertexArray(), "vertex array");

  // Echo attributes read the packed EchoBuffer as is: lon, lat | size | day, 16 bytes a report.
  const echoVao = required(gl.createVertexArray(), "vertex array");
  const echoBuffer = required(gl.createBuffer(), "buffer");
  gl.bindVertexArray(echoVao);
  gl.bindBuffer(gl.ARRAY_BUFFER, echoBuffer);
  const stride = ECHO_STRIDE * Float32Array.BYTES_PER_ELEMENT;
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(1, 1, gl.FLOAT, false, stride, 8);
  gl.enableVertexAttribArray(2);
  gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 12);

  const particleVao = required(gl.createVertexArray(), "vertex array");
  const particleBuffer = required(gl.createBuffer(), "buffer");
  gl.bindVertexArray(particleVao);
  gl.bindBuffer(gl.ARRAY_BUFFER, particleBuffer);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
  gl.bindVertexArray(null);

  const bake = required(gl.createTexture(), "texture");
  gl.bindTexture(gl.TEXTURE_2D, bake);
  // Linear: while the camera moves between framings the bake is sampled at a slight scale.
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array | null;
  const maxPoint = range?.[1] ?? 64;

  return { sweep, echoes, particles, knockout, empty, echoVao, echoBuffer, particleVao, particleBuffer, bake, maxPoint };
}

function release(gl: WebGL2RenderingContext, res: Resources): void {
  for (const program of [res.sweep, res.echoes, res.particles, res.knockout]) gl.deleteProgram(program.program);
  gl.deleteVertexArray(res.empty);
  gl.deleteVertexArray(res.echoVao);
  gl.deleteVertexArray(res.particleVao);
  gl.deleteBuffer(res.echoBuffer);
  gl.deleteBuffer(res.particleBuffer);
  gl.deleteTexture(res.bake);
}

function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
  const shader = required(gl.createShader(type), "shader");
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  return shader;
}

/** Queue a program's compile and link. No status is read here: that read is what would block. */
function startProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string): PendingProgram {
  const shaders = [compile(gl, gl.VERTEX_SHADER, vertexSource), compile(gl, gl.FRAGMENT_SHADER, fragmentSource)];
  const program = required(gl.createProgram(), "program");
  for (const shader of shaders) gl.attachShader(program, shader);
  gl.linkProgram(program);
  return { program, shaders };
}

/** Check a started program and index its active uniforms by name (array uniforms without "[0]"). */
function finishProgram(gl: WebGL2RenderingContext, { program, shaders }: PendingProgram): Program {
  const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true || gl.isContextLost();
  const log = linked ? "" : [gl.getProgramInfoLog(program), ...shaders.map((shader) => gl.getShaderInfoLog(shader))].filter(Boolean).join("\n");
  // The shaders live on inside the linked program; their handles are no longer needed.
  for (const shader of shaders) {
    gl.detachShader(program, shader);
    gl.deleteShader(shader);
  }
  if (!linked) {
    gl.deleteProgram(program);
    throw new Error(`Radar program failed to build: ${log || "no log"}`);
  }
  const uniforms = new Map<string, WebGLUniformLocation>();
  const active = (gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number | null) ?? 0;
  for (let index = 0; index < active; index += 1) {
    const info = gl.getActiveUniform(program, index);
    if (!info) continue;
    const location = gl.getUniformLocation(program, info.name);
    if (location) uniforms.set(info.name.replace(/\[0\]$/, ""), location);
  }
  return { program, uniforms };
}

function required<T>(value: T | null, what: string): T {
  if (value === null) throw new Error(`Radar could not create a WebGL ${what}`);
  return value;
}

/* ── Per-frame helpers ───────────────────────────────────────────────────── */

/** A uniform the compiler kept, or null (gl.uniform* ignores null). */
function at(program: Program, name: string): WebGLUniformLocation | null {
  return program.uniforms.get(name) ?? null;
}

function setCamera(gl: WebGL2RenderingContext, location: WebGLUniformLocation | null, camera: Camera): void {
  gl.uniform3f(location, camera.originX, camera.originY, camera.scale);
}

function setColor(gl: WebGL2RenderingContext, location: WebGLUniformLocation | null, color: Rgb): void {
  gl.uniform3f(location, color[0] / 255, color[1] / 255, color[2] / 255);
}

/** No pointer: park it far off canvas, so no branch is needed in the shaders. */
function setPointer(
  gl: WebGL2RenderingContext,
  location: WebGLUniformLocation | null,
  pointer: { x: number; y: number } | null,
): void {
  gl.uniform2f(location, pointer?.x ?? -1e5, pointer?.y ?? -1e5);
}

function uploadBuffer(gl: WebGL2RenderingContext, buffer: WebGLBuffer, data: Float32Array): void {
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

function uploadBake(gl: WebGL2RenderingContext, texture: WebGLTexture, target: BakeTarget): void {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  // Canvas 2D stores premultiplied pixels; keeping them premultiplied avoids a conversion.
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, target.canvas);
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
}

type KnockData = { rects: Float32Array; clips: Float32Array; count: number };

/** Copy the knockouts into the uKnock / uClip arrays (unused slots are never read: the loop stops at uKnockCount). */
function packKnockouts(knocks: readonly Knockout[], data: KnockData): void {
  knocks.forEach(({ rect, clip }, index) => {
    data.rects.set([rect.x, rect.y, rect.width, rect.height], index * 4);
    data.clips.set([clip.x, clip.y, clip.width, clip.height], index * 4);
  });
  data.count = knocks.length;
}

type LineData = { rects: Float32Array; count: number };

/** Copy the copy lines into uLines (slots past uLineCount are never read). */
function packLines(lines: readonly Rect[], data: LineData): void {
  const count = Math.min(lines.length, MAX_COPY_LINES);
  for (let index = 0; index < count; index += 1) {
    const line = lines[index];
    if (line) data.rects.set([line.x, line.y, line.width, line.height], index * 4);
  }
  data.count = count;
}

function setKnockouts(gl: WebGL2RenderingContext, program: Program, data: KnockData): void {
  gl.uniform4fv(at(program, "uKnock"), data.rects);
  gl.uniform4fv(at(program, "uClip"), data.clips);
  gl.uniform1i(at(program, "uKnockCount"), data.count);
  gl.uniform3f(at(program, "uShape"), KNOCKOUT.featherPx, KNOCKOUT.cornerPx, KNOCKOUT.clipRampPx);
}

/** Where the knockout field can be non-zero (each rect plus the feather, inside its clip), in device px with GL's bottom-left origin. */
function scissorFor(knocks: readonly Knockout[], surface: Surface): Rect | null {
  const pad = KNOCKOUT.featherPx;
  let left = Infinity;
  let top = Infinity;
  let right = -Infinity;
  let bottom = -Infinity;
  for (const { rect, clip } of knocks) {
    const l = Math.max(rect.x - pad, clip.x);
    const t = Math.max(rect.y - pad, clip.y);
    const r = Math.min(rect.x + rect.width + pad, clip.x + clip.width);
    const b = Math.min(rect.y + rect.height + pad, clip.y + clip.height);
    if (r <= l || b <= t) continue;
    left = Math.min(left, l);
    top = Math.min(top, t);
    right = Math.max(right, r);
    bottom = Math.max(bottom, b);
  }
  left = Math.max(0, left);
  top = Math.max(0, top);
  right = Math.min(surface.width, right);
  bottom = Math.min(surface.height, bottom);
  if (right <= left || bottom <= top) return null;
  const { dpr, height } = surface;
  const x = Math.floor(left * dpr);
  const y = Math.floor((height - bottom) * dpr);
  return { x, y, width: Math.ceil(right * dpr) - x, height: Math.ceil((height - top) * dpr) - y };
}

src/components/radar/radar-camera.ts

TypeScript · 261 lines · 10.2 KB

/**
 * Radar camera math, pure and shared by every pass, the labels and the cursor
 * probe.
 *
 * A camera is two numbers the GPU also sees: `uOrigin` (the state's NW corner in
 * canvas CSS px) and `uScale` (px per degree of latitude). Longitude is scaled by
 * cos 39° (geo.ts), so the projection is equirectangular and a ground circle is a
 * screen circle. The SCAN and FIELD framings are both just cameras; moving between
 * them lerps three numbers, never layout.
 */
import { COLORADO, DENVER, KM_PER_DEG_LAT, LON_SCALE, type Point } from "@/lib/geo";
import { smoothstep } from "./radar-render";

export type Camera = { originX: number; originY: number; scale: number };
/** CSS px. Zones and obstacles are measured from the DOM in this shape. */
export type Rect = { x: number; y: number; width: number; height: number };
export type Zone = Rect;
export type Size = { width: number; height: number };
export type LonLat = { lon: number; lat: number };

const SPAN_LON = COLORADO.east - COLORADO.west;
const SPAN_LAT = COLORADO.north - COLORADO.south;

/** State width / height in the equirectangular projection: Δlon·cos 39° / Δlat ≈ 1.36. */
export const STATE_ASPECT = (SPAN_LON * LON_SCALE) / SPAN_LAT;

/** The bearing ring sits 3% outside the state corner farthest from Denver, so it never cuts the outline. */
export const BEARING_RING_PAD = 1.03;

export function toScreen(camera: Camera, lon: number, lat: number): Point {
  return {
    x: camera.originX + (lon - COLORADO.west) * LON_SCALE * camera.scale,
    y: camera.originY + (COLORADO.north - lat) * camera.scale,
  };
}

export function toLonLat(camera: Camera, x: number, y: number): LonLat {
  return {
    lon: COLORADO.west + (x - camera.originX) / (LON_SCALE * camera.scale),
    lat: COLORADO.north - (y - camera.originY) / camera.scale,
  };
}

/** The state's box under this camera. */
export function stateRect(camera: Camera): Rect {
  return {
    x: camera.originX,
    y: camera.originY,
    width: SPAN_LON * LON_SCALE * camera.scale,
    height: SPAN_LAT * camera.scale,
  };
}

export function pxPerKm(camera: Camera): number {
  return camera.scale / KM_PER_DEG_LAT;
}

export function denverOf(camera: Camera): Point {
  return toScreen(camera, DENVER.lon, DENVER.lat);
}

/** Radius of the bearing ring: the state corner farthest from Denver, plus 3%. */
export function ringRadius(camera: Camera): number {
  const den = denverOf(camera);
  const box = stateRect(camera);
  const dx = Math.max(den.x - box.x, box.x + box.width - den.x);
  const dy = Math.max(den.y - box.y, box.y + box.height - den.y);
  return Math.hypot(dx, dy) * BEARING_RING_PAD;
}

function cameraFor(x: number, y: number, height: number): Camera {
  return { originX: x, originY: y, scale: height / SPAN_LAT };
}

/**
 * SCAN (01). Desktop: the state is min(50vh, zone + 30) tall (it may run 30px into
 * the knocked-out copy row), fit to 86% of the zone width if wider, placed 38% into
 * the free width, 14px below the zone top. Mobile: zone width − 56, inset 28px.
 */
export function scanCamera(zone: Zone, viewport: Size, mobile: boolean): Camera {
  if (mobile) {
    const width = Math.max(0, zone.width - 56);
    return cameraFor(zone.x + 28, zone.y + 4, width / STATE_ASPECT);
  }
  let height = Math.min(viewport.height * 0.5, zone.height + 30);
  let width = height * STATE_ASPECT;
  if (width > zone.width * 0.86) {
    width = zone.width * 0.86;
    height = width / STATE_ASPECT;
  }
  return cameraFor(zone.x + (zone.width - width) * 0.38, zone.y + 14, height);
}

/**
 * Where FIELD sits across its zone, as a share of the free width (zone − state).
 * Normally right-aligned at 78%. On a wide, short window the zone's height holds
 * the state while the column keeps widening, so the free width grows into a dead
 * band under the readouts (≈ 26% of the zone at 1440 × 900, 48% at 1920 × 900).
 * From `from` to `to` of the zone the anchor eases to the centre, so the state and
 * its rings sit under the readouts instead.
 */
export const FIELD_ANCHOR = { right: 0.78, centre: 0.5, from: 0.3, to: 0.45 } as const;

/**
 * FIELD (02, desktop only). The state is min(zone − 20, 56vh) tall, fit to 90% of
 * the zone width if wider, and centered at 45% of the free height. Across, it is
 * right-aligned at 78% of the free width, so rings and the bearing ring bleed past
 * the frame, easing to centred where the free width would leave a dead band
 * (FIELD_ANCHOR).
 */
export function fieldCamera(zone: Zone, viewport: Size): Camera {
  let height = Math.max(0, Math.min(zone.height - 20, viewport.height * 0.56));
  let width = height * STATE_ASPECT;
  if (width > zone.width * 0.9) {
    width = zone.width * 0.9;
    height = width / STATE_ASPECT;
  }
  const free = zone.width - width;
  const { right, centre, from, to } = FIELD_ANCHOR;
  const anchor = right + (centre - right) * smoothstep(from, to, zone.width > 0 ? free / zone.width : 0);
  return cameraFor(zone.x + free * anchor, zone.y + (zone.height - height) * 0.45, height);
}

export function mixCamera(from: Camera, to: Camera, t: number): Camera {
  const k = Math.min(1, Math.max(0, t));
  return {
    originX: from.originX + (to.originX - from.originX) * k,
    originY: from.originY + (to.originY - from.originY) * k,
    scale: from.scale + (to.scale - from.scale) * k,
  };
}

export function sameCamera(a: Camera, b: Camera, epsilon = 0.01): boolean {
  return (
    Math.abs(a.originX - b.originX) < epsilon &&
    Math.abs(a.originY - b.originY) < epsilon &&
    Math.abs(a.scale - b.scale) < epsilon
  );
}

/** Parse a computed duration token (`1300ms`, `1.3s`) into ms; null for anything else. */
export function parseDuration(text: string): number | null {
  const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(text);
  if (!match) return null;
  const value = Number(match[1]);
  return match[2] === "s" ? value * 1000 : value;
}

/**
 * How a box's viewport top follows the page scroll, so the radar can place things
 * per frame from scrollY alone, without a layout read. A box is either in flow, or
 * `position: sticky; top: 0` inside a container: the canvas box on desktop, and 02
 * while its replay pin holds it (base.css).
 */
export type ScrollTrack = {
  /** Document y of the box's top in normal flow: the scrollY at which it meets the viewport top. */
  top: number;
  /** Document y of its sticky container's bottom edge (unused when not sticky). */
  end: number;
  height: number;
  sticky: boolean;
};

/** The page itself: an in-flow box at document y 0, so trackTop(PAGE, y) = −scrollY. */
export const PAGE_TRACK: ScrollTrack = { top: 0, end: 0, height: 0, sticky: false };

/** The viewport: a box stuck at 0 for any scroll (fixed copy such as the rail), so trackTop(VIEWPORT, y) = 0. */
export const VIEWPORT_TRACK: ScrollTrack = { top: -1e9, end: 1e9, height: 0, sticky: true };

/**
 * Viewport top of a tracked box at a scroll position. A sticky box holds at 0 from
 * its natural top until its container's bottom pushes it up.
 */
export function trackTop(track: ScrollTrack, scrollY: number): number {
  const natural = track.top - scrollY;
  if (!track.sticky) return natural;
  return Math.min(Math.max(natural, 0), track.end - track.height - scrollY);
}

/**
 * How far a scene's copy has moved against the canvas since its framing's rest
 * position (scrollY = scene.top), in px. Labels laid out at rest are only valid
 * while this stays small: in 01 the copy scrolls over the held canvas; in 02's pin
 * both hold still, so the drift stays 0 across the whole replay.
 */
export function trackDrift(scene: ScrollTrack, canvas: ScrollTrack, scrollY: number): number {
  const now = trackTop(scene, scrollY) - trackTop(canvas, scrollY);
  const rest = trackTop(scene, scene.top) - trackTop(canvas, scene.top);
  return Math.abs(now - rest);
}

export type ColumnSpan = {
  /** First column, 1-based. */
  from: number;
  /** Last column, inclusive. */
  to: number;
  count: number;
  gap: number;
};

/** The x-range of a column span inside a content box (CSS grid arithmetic). */
export function columnRange(content: Rect, span: ColumnSpan): { x: number; width: number } {
  const column = (content.width - (span.count - 1) * span.gap) / span.count;
  const x0 = content.x + (span.from - 1) * (column + span.gap);
  const x1 = content.x + span.to * column + (span.to - 1) * span.gap;
  return { x: x0, width: Math.max(0, x1 - x0) };
}

/**
 * The radar's zone in a scene: the tallest empty vertical band of a column span,
 * between the elements the scenes marked as copy ([data-avoid], [data-knockout]).
 * The radar therefore follows each scene's layout without a dedicated zone node:
 * in 01 it is the gap between the scene head and the role row; in 02 the gap
 * between the readouts and the tape. Null when the span is completely covered.
 */
export function freeBand(content: Rect, span: ColumnSpan, obstacles: readonly Rect[]): Zone | null {
  const { x, width } = columnRange(content, span);
  const top = content.y;
  const bottom = content.y + content.height;
  const blocks = obstacles
    .filter(
      (rect) =>
        rect.width > 0 &&
        rect.height > 0 &&
        rect.x < x + width &&
        rect.x + rect.width > x &&
        rect.y < bottom &&
        rect.y + rect.height > top,
    )
    .map((rect) => ({ from: Math.max(top, rect.y), to: Math.min(bottom, rect.y + rect.height) }))
    .sort((a, b) => a.from - b.from);

  let best: Zone | null = null;
  let cursor = top;
  const consider = (from: number, to: number) => {
    if (to - from > (best?.height ?? 0)) best = { x, y: from, width, height: to - from };
  };
  for (const block of blocks) {
    if (block.from > cursor) consider(cursor, block.from);
    cursor = Math.max(cursor, block.to);
  }
  if (bottom > cursor) consider(cursor, bottom);
  return best;
}

export function intersects(a: Rect, b: Rect): boolean {
  return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}

export function contains(outer: Rect, inner: Rect): boolean {
  return (
    inner.x >= outer.x &&
    inner.y >= outer.y &&
    inner.x + inner.width <= outer.x + outer.width &&
    inner.y + inner.height <= outer.y + outer.height
  );
}

export function inflate(rect: Rect, dx: number, dy: number): Rect {
  return { x: rect.x - dx, y: rect.y - dy, width: rect.width + 2 * dx, height: rect.height + 2 * dy };
}

src/components/radar/radar-ping.ts

TypeScript · 58 lines · 2.4 KB

/**
 * The cluster ping: one 2.4 kHz tick when the beam sweeps into a 2°
 * bearing bin from Denver holding at least 3 reports at or before the playhead.
 * Pure, so the controller only asks "did this frame's turn cross a cluster?".
 */
import { DENVER, LON_SCALE } from "@/lib/geo";
import { ECHO_STRIDE, type EchoBuffer } from "./radar-data";
import { TAU } from "./radar-render";

export const PING_BIN_DEG = 2;
export const PING_MIN_REPORTS = 3;
const BINS = 360 / PING_BIN_DEG;

/**
 * Report locations as seen from Denver: bearing (degrees, 0 = north, clockwise)
 * and day of year, in the same projection the shaders use (longitude × cos 39°),
 * so a bin matches what the beam visibly crosses.
 */
export type EchoBearings = { deg: Float32Array; day: Float32Array };

export function echoBearings(echoes: EchoBuffer | null): EchoBearings {
  const count = echoes ? Math.floor(echoes.length / ECHO_STRIDE) : 0;
  const deg = new Float32Array(count);
  const day = new Float32Array(count);
  for (let i = 0; i < count; i += 1) {
    const at = i * ECHO_STRIDE;
    const east = ((echoes?.[at] ?? 0) - DENVER.lon) * LON_SCALE;
    const north = (echoes?.[at + 1] ?? 0) - DENVER.lat;
    deg[i] = (((Math.atan2(east, north) * 180) / Math.PI) % 360 + 360) % 360;
    day[i] = echoes?.[at + 3] ?? 0;
  }
  return { deg, day };
}

/** Reports per bin, counting only those reported on or before `playhead`. */
export function countBins(echoes: EchoBearings, playhead: number, bins = new Uint16Array(BINS)): Uint16Array {
  bins.fill(0);
  for (let i = 0; i < echoes.deg.length; i += 1) {
    if ((echoes.day[i] ?? 0) > playhead) continue;
    const bin = Math.floor((echoes.deg[i] ?? 0) / PING_BIN_DEG) % BINS;
    bins[bin] = (bins[bin] ?? 0) + 1;
  }
  return bins;
}

/**
 * True when the beam, turning clockwise from `fromRad` to `toRad` this frame,
 * entered a bin holding a cluster. Bearings may be unwrapped (negative, or past a
 * turn): bins are counted on the continuous angle and wrapped only to look up.
 */
export function crossedCluster(bins: Uint16Array, fromRad: number, toRad: number): boolean {
  const binOf = (rad: number) => Math.floor((rad / TAU) * BINS);
  const last = Math.min(binOf(toRad), binOf(fromRad) + BINS);
  for (let bin = binOf(fromRad) + 1; bin <= last; bin += 1) {
    if ((bins[((bin % BINS) + BINS) % BINS] ?? 0) >= PING_MIN_REPORTS) return true;
  }
  return false;
}

src/lib/beam-cut.ts

TypeScript · 240 lines · 9.3 KB

/**
 * The beam cut: as the radar beam passes, it slices a 7° wedge out of the name
 * and shows it again, shifted and in signal, for 340ms.
 *
 * Rules that keep it an accent rather than an effect:
 *   - The h1 (#name) only, and only within ±35° of the baseline's perpendicular
 *     (bearing 180°). At most twice per visit: the intro turn and one hover.
 *   - Never in stills (reduced motion or automation) and never on touch.
 *   - One clip-path write on #name at the start and one removal at the end
 *     (clip-path is the only inline style it may set on the name). No per-frame
 *     masks, no @property, no conic gradients.
 *   - The duplicate and the leading edge are aria-hidden nodes this module owns,
 *     so the accessible name and the one-h1 rule are untouched.
 */
import { emit, type ScreenPoint } from "@/lib/contracts";
import { bearingDeg } from "@/lib/geo";
import { isFinePointer, isStill } from "@/lib/motion";

export const BEAM_CUT_MS = 340;
export const BEAM_CUT_WINDOW_DEG = 35;
export const BEAM_CUT_WEDGE_DEG = 7;
/** Hover cuts take the bearing from Denver to the pointer, clamped to this range. */
export const HOVER_BEARING_DEG = [145, 215] as const;

/** The signal duplicate is offset this far (em) along the beam's normal. */
const SHIFT_EM = 0.045;
/** Room around the h1 in the clip polygon so overhanging glyphs are never clipped. */
const CLIP_PAD = 60;
/** Wedge arms reach this far (px): past any viewport. */
const FAR = 4000;
/** The leading edge is drawn only across the h1, plus this margin (px). */
const EDGE_MARGIN = 8;

export type BeamCutInput = {
  /** The h1 (#name, rendered by MeasuredName). */
  target: HTMLElement;
  /** Denver's viewport position: RadarApi.denScreen(). */
  pivot: ScreenPoint;
  /** Beam bearing in degrees, 0 = north, clockwise. */
  bearingDeg: number;
};

type Box = { x: number; y: number; width: number; height: number };

export type CutGeometry = {
  /** clip-path for #name: its box with the wedge removed (even-odd). */
  hole: string;
  /** clip-path for the duplicate: the wedge alone. */
  wedge: string;
  /** Offset of the duplicate along the beam's normal, px. */
  shift: ScreenPoint;
  /** The leading edge across the h1 in viewport px, or null if the beam misses it. */
  edge: { x: number; y: number; length: number; angleDeg: number } | null;
};

function normalize(deg: number): number {
  return ((deg % 360) + 360) % 360;
}

/** True when a cut may fire at this bearing on this device. */
export function canCut(bearing: number): boolean {
  return Math.abs(normalize(bearing) - 180) <= BEAM_CUT_WINDOW_DEG && isFinePointer() && !isStill();
}

/** Pure geometry of one cut, in the h1's own box (clip-path) and the viewport (edge). */
export function cutGeometry(box: Box, pivot: ScreenPoint, bearing: number, fontSize: number): CutGeometry {
  const local = { x: pivot.x - box.x, y: pivot.y - box.y };
  const half = BEAM_CUT_WEDGE_DEG / 2;
  const toward = (deg: number) => {
    const rad = (deg * Math.PI) / 180;
    return { x: Math.sin(rad), y: -Math.cos(rad) };
  };
  const arm = (deg: number) => {
    const dir = toward(deg);
    return { x: local.x + dir.x * FAR, y: local.y + dir.y * FAR };
  };
  const px = (point: ScreenPoint) => `${point.x.toFixed(1)}px ${point.y.toFixed(1)}px`;
  const a = arm(bearing - half);
  const b = arm(bearing + half);
  const left = -CLIP_PAD;
  const top = -CLIP_PAD;
  const right = box.width + CLIP_PAD;
  const bottom = box.height + CLIP_PAD;
  const frame = [`${left}px ${top}px`, `${right}px ${top}px`, `${right}px ${bottom}px`, `${left}px ${bottom}px`, `${left}px ${top}px`];
  // The path walks the padded box, then the wedge, then returns to the start: the
  // connecting segments enclose no area, so even-odd leaves exactly a wedge-shaped hole.
  const hole = `polygon(evenodd, ${frame.join(", ")}, ${px(local)}, ${px(a)}, ${px(b)}, ${px(local)}, ${left}px ${top}px)`;
  const wedge = `polygon(${px(local)}, ${px(a)}, ${px(b)})`;

  const normal = (bearing * Math.PI) / 180;
  const shift = { x: Math.cos(normal) * SHIFT_EM * fontSize, y: Math.sin(normal) * SHIFT_EM * fontSize };

  // The leading side (the beam turns clockwise) clipped to the h1's box: slab test.
  const lead = toward(bearing + half);
  let enter = 0;
  let exit = Infinity;
  for (const [origin, dir, min, max] of [
    [pivot.x, lead.x, box.x - EDGE_MARGIN, box.x + box.width + EDGE_MARGIN],
    [pivot.y, lead.y, box.y - EDGE_MARGIN, box.y + box.height + EDGE_MARGIN],
  ] as const) {
    if (Math.abs(dir) < 1e-9) {
      if (origin < min || origin > max) exit = -Infinity;
      continue;
    }
    const t0 = (min - origin) / dir;
    const t1 = (max - origin) / dir;
    enter = Math.max(enter, Math.min(t0, t1));
    exit = Math.min(exit, Math.max(t0, t1));
  }
  const edge =
    exit > enter
      ? {
          x: pivot.x + lead.x * enter,
          y: pivot.y + lead.y * enter,
          length: exit - enter,
          angleDeg: bearing + half - 90,
        }
      : null;

  return { hole, wedge, shift, edge };
}

type Overlay = { root: HTMLDivElement; copy: HTMLDivElement; edge: HTMLDivElement };

let overlay: Overlay | null = null;
let busy = false;

/** Properties the duplicate copies so its glyphs land exactly on the h1's. */
const TYPE_PROPS = [
  "font-family",
  "font-size",
  "font-weight",
  "font-style",
  "font-stretch",
  "font-feature-settings",
  "font-variation-settings",
  "font-kerning",
  "letter-spacing",
  "line-height",
  "text-transform",
  "text-indent",
  "text-align",
  "padding-top",
  "padding-right",
  "padding-bottom",
  "padding-left",
] as const;

function ensureOverlay(): Overlay {
  if (overlay?.root.isConnected) return overlay;
  const root = document.createElement("div");
  root.setAttribute("aria-hidden", "true");
  root.hidden = true;
  const set = (element: HTMLElement, styles: Record<string, string>) => {
    for (const [name, value] of Object.entries(styles)) element.style.setProperty(name, value);
  };
  set(root, { position: "fixed", inset: "0", "z-index": "var(--z-name-cut)", "pointer-events": "none", overflow: "hidden" });
  const copy = document.createElement("div");
  set(copy, { position: "absolute", margin: "0", "box-sizing": "border-box", "white-space": "nowrap", color: "var(--accent)" });
  const edge = document.createElement("div");
  set(edge, {
    position: "absolute",
    height: "1px",
    "transform-origin": "0 50%",
    background: "var(--edge-ink)",
    "box-shadow": "0 0 6px var(--accent)",
  });
  root.append(copy, edge);
  document.body.append(root);
  overlay = { root, copy, edge };
  return overlay;
}

/**
 * Fire one cut. Returns false (and does nothing) when the bearing is outside the
 * window, the device or motion setting forbids it, a cut is running, or the h1 is
 * off screen.
 */
export function beamCut({ target, pivot, bearingDeg: bearing }: BeamCutInput): boolean {
  if (busy || !canCut(bearing)) return false;
  const box = target.getBoundingClientRect();
  if (box.width === 0 || box.bottom < 0 || box.top > window.innerHeight) return false;

  const style = getComputedStyle(target);
  const geometry = cutGeometry(box, pivot, bearing, parseFloat(style.fontSize) || 0);
  const { root, copy, edge } = ensureOverlay();

  for (const name of TYPE_PROPS) copy.style.setProperty(name, style.getPropertyValue(name));
  copy.textContent = target.textContent;
  copy.style.setProperty("left", `${box.x}px`);
  copy.style.setProperty("top", `${box.y}px`);
  copy.style.setProperty("width", `${box.width}px`);
  copy.style.setProperty("height", `${box.height}px`);
  copy.style.setProperty("clip-path", geometry.wedge);
  copy.style.setProperty("transform", `translate(${geometry.shift.x.toFixed(2)}px, ${geometry.shift.y.toFixed(2)}px)`);

  edge.hidden = !geometry.edge;
  if (geometry.edge) {
    edge.style.setProperty("left", `${geometry.edge.x}px`);
    edge.style.setProperty("top", `${geometry.edge.y}px`);
    edge.style.setProperty("width", `${geometry.edge.length}px`);
    edge.style.setProperty("transform", `rotate(${geometry.edge.angleDeg}deg)`);
  }

  busy = true;
  root.hidden = false;
  target.style.setProperty("clip-path", geometry.hole);
  emit("acx:sfx", { cue: "ping" });
  window.setTimeout(() => {
    target.style.removeProperty("clip-path");
    root.hidden = true;
    busy = false;
  }, BEAM_CUT_MS);
  return true;
}

/**
 * Arm the hover cut on #name: the bearing from Denver to the pointer, clamped to
 * 145°–215°. It fires once, then disarms (twice per visit at most, with the intro). Returns a
 * cleanup that disarms it early.
 */
export function armHoverCut(target: HTMLElement, pivot: () => ScreenPoint | null): () => void {
  if (!isFinePointer() || isStill()) return () => {};
  const [min, max] = HOVER_BEARING_DEG;
  const onEnter = (event: PointerEvent) => {
    if (event.pointerType !== "mouse" && event.pointerType !== "pen") return;
    const origin = pivot();
    if (!origin) return;
    const bearing = Math.min(max, Math.max(min, bearingDeg(origin, { x: event.clientX, y: event.clientY })));
    if (beamCut({ target, pivot: origin, bearingDeg: bearing })) target.removeEventListener("pointerenter", onEnter);
  };
  target.addEventListener("pointerenter", onEnter);
  return () => target.removeEventListener("pointerenter", onEnter);
}

/** Remove the overlay nodes (radar teardown). A running cut still restores #name. */
export function disposeBeamCut(): void {
  overlay?.root.remove();
  overlay = null;
}

src/lib/hail-days.ts

TypeScript · 340 lines · 12.2 KB

/**
 * Colorado hail reports for the current year, from NWS Local Storm Reports as
 * archived by the Iowa Environmental Mesonet (IEM). SERVER ONLY
 * (the page loads it; client islands get slimmed, already-guarded data).
 *
 * Dates are Denver dates. IEM stamps every report with its UTC `VALID` time, so
 * an evening storm (after 17:00 MST / 18:00 MDT) would land on tomorrow's UTC
 * date. Every report is re-dated to its America/Denver calendar day, and "this
 * year" is the Denver calendar year, so the count, the tape, the radar playhead
 * and LiveData.today all share one calendar.
 *
 * A failed or malformed fetch returns null and the page hides every hail
 * number: nothing here ever estimates a value.
 */

/** Unique hail days and the first/last of them. */
export type HailDayCount = {
  days: number;
  /** Compact YYYYMMDD (Denver) of the earliest hail day, if any. */
  first: string | null;
  /** Compact YYYYMMDD (Denver) of the latest hail day, if any. */
  last: string | null;
};

/** One NWS hail report, reduced to what the radar plots. */
export type HailReport = {
  lat: number;
  lon: number;
  /** Reported hail diameter in inches (NWS `MAG`), or null when the report has none. */
  sizeIn: number | null;
  /** Compact YYYYMMDD (Denver) of the report. */
  day: string;
  /** Place name as written in the NWS report (e.g. "5 SW Colona"). Unfiltered: run it through safePlace before rendering. */
  place: string | null;
};

export type LargestHail = {
  sizeIn: number;
  /** Compact YYYYMMDD (Denver). */
  day: string;
  /** Place name as written in the NWS report. Unfiltered. */
  place: string | null;
};

/** Reports on one hail day, counted before dedup (the tape's line height and PEAK label). */
export type HailDayReports = { day: string; reports: number };

/** One raw report for the echo log, newest first. */
export type HailEcho = {
  /** The report's VALID time as a UTC ISO instant. */
  at: string;
  /** Compact YYYYMMDD (Denver). */
  day: string;
  sizeIn: number | null;
  /** Place name as written in the NWS report. Unfiltered. */
  place: string | null;
};

/** A Colorado hail row from the archive, validated and dated in Denver. */
export type HailRow = {
  /** VALID as a UTC ISO instant. */
  at: string;
  /** Compact YYYYMMDD in America/Denver. */
  day: string;
  /** NaN when the row has no usable position (it still counts as a report). */
  lat: number;
  lon: number;
  sizeIn: number | null;
  place: string | null;
};

export type HailStats = HailDayCount & {
  /** Number of individual Colorado hail reports (not days, not locations). */
  reportCount: number;
  largest: LargestHail | null;
  /**
   * Echoes for the radar: reports merged per Denver day and place to 0.01°
   * (fewer than reportCount). A place hit on two days is two echoes, so never
   * print its length as a count of anything.
   */
  reports: HailReport[];
  /** Distinct places on the radar (0.01°, any day): the number the site labels "locations". */
  locationCount: number;
  /** Every hail day with its raw report count, ascending; sums to reportCount, one entry per counted day. */
  perDay: HailDayReports[];
  /** The ECHO_LOG_SIZE most recent raw reports, newest first. */
  latest: HailEcho[];
};

export type HailSummary = HailStats & {
  /** Denver calendar year the numbers cover. */
  year: number;
  countedAt: string;
  sourceUrl: string;
  sourceLabel: string;
};

/** The hail fetch is cached for 12 h. The 02 scene head prints this window; never hardcode it. */
export const HAIL_REVALIDATE_S = 43_200;

/** The echo log (02) shows this many of the latest reports. */
export const ECHO_LOG_SIZE = 4;

/** NWS local storm reports for Colorado, archived by Iowa Environmental Mesonet. */
export const HAIL_SOURCE_URL = "https://mesonet.agron.iastate.edu/request/gis/lsr.php";

const HAIL_SOURCE_LABEL =
  "NWS Local Storm Reports for Colorado, archived by the Iowa Environmental Mesonet. A day counts once when the report type is hail, the state is Colorado, and the report falls on that Denver calendar day.";

const DENVER_TZ = "America/Denver";

/** Loose box around Colorado: positions outside it are typos, not plotted (they still count as reports). */
const PLOT_BOUNDS = { south: 36.5, north: 41.5, west: -109.6, east: -101.5 };

/** Minimal CSV reader. Remarks in these files contain commas, so splitting lines is not enough. */
function parseCsv(text: string): string[][] {
  const rows: string[][] = [];
  let row: string[] = [];
  let cell = "";
  let quoted = false;

  for (let index = 0; index < text.length; index += 1) {
    const char = text[index] ?? "";
    if (quoted) {
      if (char === '"') {
        if (text[index + 1] === '"') {
          cell += '"';
          index += 1;
        } else {
          quoted = false;
        }
      } else {
        cell += char;
      }
      continue;
    }
    if (char === '"') {
      quoted = true;
    } else if (char === ",") {
      row.push(cell);
      cell = "";
    } else if (char === "\n") {
      row.push(cell);
      rows.push(row);
      row = [];
      cell = "";
    } else if (char !== "\r") {
      cell += char;
    }
  }

  if (cell.length > 0 || row.length > 0) {
    row.push(cell);
    rows.push(row);
  }
  return rows;
}

let denverDateFormat: Intl.DateTimeFormat | null = null;

/**
 * IEM `VALID` ("YYYYMMDDHHMM", UTC) → the instant and its Denver calendar day.
 * Null for a malformed stamp. Intl applies the MST/MDT switch, so a 02:50 UTC
 * report on Sep 21 is correctly an evening report on Sep 20.
 */
export function denverDay(valid: string): { at: string; day: string } | null {
  const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(valid.trim());
  if (!match) return null;
  const [, year, month, day, hour, minute] = match.map(Number) as [number, number, number, number, number, number];
  const instant = new Date(Date.UTC(year, month - 1, day, hour, minute));
  // Date.UTC rolls an impossible stamp (month 13, hour 25) into another date; refuse it instead.
  if (instant.getUTCMonth() !== month - 1 || instant.getUTCDate() !== day || instant.getUTCHours() !== hour) return null;
  denverDateFormat ??= new Intl.DateTimeFormat("en-CA", {
    timeZone: DENVER_TZ,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  });
  const parts = denverDateFormat.formatToParts(instant);
  const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((item) => item.type === type)?.value ?? "";
  return { at: instant.toISOString(), day: `${part("year")}${part("month")}${part("day")}` };
}

/**
 * The archive table → Colorado hail rows dated in Denver. With `year`, rows
 * outside that Denver calendar year are dropped. Returns null when the table is
 * not the report archive, so the page omits the numbers instead of guessing.
 */
export function parseHailCsv(csv: string, year?: number): HailRow[] | null {
  const rows = parseCsv(csv.trim());
  const header = rows[0];
  if (!header) return null;
  const col = (name: string) => header.indexOf(name);
  const typeIndex = col("TYPECODE");
  const stateIndex = col("STATE");
  const validIndex = col("VALID");
  const latIndex = col("LAT");
  const lonIndex = col("LON");
  const magIndex = col("MAG");
  const cityIndex = col("CITY");
  if (typeIndex < 0 || stateIndex < 0 || validIndex < 0) return null;
  const cell = (record: string[], index: number) => (index < 0 ? "" : (record[index] ?? "").trim());
  const yearPrefix = year === undefined ? null : String(year);

  const hail: HailRow[] = [];
  for (const record of rows.slice(1)) {
    if (cell(record, typeIndex) !== "H") continue;
    if (cell(record, stateIndex) !== "CO") continue;
    const dated = denverDay(cell(record, validIndex));
    if (!dated) continue;
    if (yearPrefix !== null && !dated.day.startsWith(yearPrefix)) continue;

    const magText = cell(record, magIndex);
    const mag = magText === "" ? Number.NaN : Number(magText);
    const latText = cell(record, latIndex);
    const lonText = cell(record, lonIndex);
    hail.push({
      ...dated,
      // Number("") is 0, a real coordinate: an empty cell must read as "no position".
      lat: latText === "" ? Number.NaN : Number(latText),
      lon: lonText === "" ? Number.NaN : Number(lonText),
      sizeIn: Number.isFinite(mag) && mag > 0 ? mag : null,
      place: cell(record, cityIndex) || null,
    });
  }
  return hail;
}

const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);

function inPlotBounds(lat: number, lon: number): boolean {
  return (
    Number.isFinite(lat) &&
    Number.isFinite(lon) &&
    lat >= PLOT_BOUNDS.south &&
    lat <= PLOT_BOUNDS.north &&
    lon >= PLOT_BOUNDS.west &&
    lon <= PLOT_BOUNDS.east
  );
}

function round(value: number, places: number): number {
  const factor = 10 ** places;
  return Math.round(value * factor) / factor;
}

/**
 * Everything the site prints about the season, from one pass over the rows.
 *   - days / first / last and perDay come from the same Denver days, so the
 *     numeral, the tape and the bar can never disagree.
 *   - reports merges duplicates (same Denver day and place to 0.01°, keeping the
 *     larger stone): that is what the radar plots, one echo each. locationCount
 *     counts the distinct places among them (any day), which is what the copy
 *     calls "locations".
 *   - largest and latest come from the raw reports.
 */
export function summarizeHail(rows: readonly HailRow[]): HailStats {
  const dayCounts = new Map<string, number>();
  const merged = new Map<string, HailReport>();
  const places = new Set<string>();
  let largest: LargestHail | null = null;

  for (const row of rows) {
    dayCounts.set(row.day, (dayCounts.get(row.day) ?? 0) + 1);
    if (row.sizeIn !== null && (!largest || row.sizeIn > largest.sizeIn)) {
      largest = { sizeIn: row.sizeIn, day: row.day, place: row.place };
    }
    if (!inPlotBounds(row.lat, row.lon)) continue;
    const place = `${row.lat.toFixed(2)}:${row.lon.toFixed(2)}`;
    places.add(place);
    const key = `${row.day}:${place}`;
    const existing = merged.get(key);
    if (!existing || (row.sizeIn ?? 0) > (existing.sizeIn ?? 0)) {
      merged.set(key, { lat: round(row.lat, 3), lon: round(row.lon, 3), sizeIn: row.sizeIn, day: row.day, place: row.place });
    }
  }

  const perDay = [...dayCounts.entries()]
    .sort(([a], [b]) => byString(a, b))
    .map(([day, reports]) => ({ day, reports }));
  // ISO instants sort as strings; Array.prototype.sort is stable, so same-minute reports keep file order.
  const latest = [...rows]
    .sort((a, b) => byString(b.at, a.at))
    .slice(0, ECHO_LOG_SIZE)
    .map(({ at, day, sizeIn, place }) => ({ at, day, sizeIn, place }));

  return {
    days: perDay.length,
    first: perDay[0]?.day ?? null,
    last: perDay[perDay.length - 1]?.day ?? null,
    reportCount: rows.length,
    largest,
    reports: [...merged.values()].sort((a, b) => byString(a.day, b.day)),
    locationCount: places.size,
    perDay,
    latest,
  };
}

/** Denver calendar year of an instant. */
export function denverYear(now: Date): number {
  const year = new Intl.DateTimeFormat("en-US", {
    timeZone: DENVER_TZ,
    year: "numeric",
  }).format(now);
  return Number(year);
}

/**
 * The archive request for one Denver year. The UTC window is padded past both
 * ends of the Denver year (it starts at UTC−6 or −7), and parseHailCsv keeps
 * exactly that year, so no offset math lives in the URL. Fixed per year, the URL
 * (and with it the fetch cache key) is stable all year. `type=HAIL` asks IEM for
 * hail rows only (~60 KB instead of ~2 MB); the parser still checks TYPECODE and STATE.
 */
export function hailArchiveUrl(year: number): string {
  return `https://mesonet.agron.iastate.edu/cgi-bin/request/gis/lsr.py?sts=${year}-01-01T00:00Z&ets=${year + 1}-01-01T12:00Z&fmt=csv&state=CO&type=HAIL`;
}

export async function loadHailDays(now = new Date()): Promise<HailSummary | null> {
  const year = denverYear(now);
  try {
    const response = await fetch(hailArchiveUrl(year), {
      signal: AbortSignal.timeout(25_000),
      next: { revalidate: HAIL_REVALIDATE_S },
    });
    if (!response.ok) return null;
    const rows = parseHailCsv(await response.text(), year);
    if (!rows) return null;
    return {
      ...summarizeHail(rows),
      year,
      countedAt: now.toISOString(),
      sourceUrl: HAIL_SOURCE_URL,
      sourceLabel: HAIL_SOURCE_LABEL,
    };
  } catch {
    return null;
  }
}

src/lib/weather.ts

TypeScript · 119 lines · 5.0 KB

/**
 * Denver's current weather from Open-Meteo. Client-safe: no server
 * imports, so islands may use the types and `denverClock`; only the page calls
 * `loadDenverWeather`. Any failure is null and the page drops every weather
 * reading (R-01, the bar cell, the tint falls back to untinted).
 */
export type Sky = {
  id: "clear" | "partly" | "overcast" | "fog" | "rain" | "snow" | "storm";
  label: string;
};

/**
 * WMO code from Open-Meteo to a tint name. Unknown codes return null
 * so the portrait stays untinted instead of wearing a made-up condition.
 */
export function skyFromCode(code: number): Sky | null {
  if (!Number.isInteger(code) || code < 0 || code > 99) return null;
  if (code === 0) return { id: "clear", label: "Clear" };
  if (code === 1) return { id: "clear", label: "Mainly clear" };
  if (code === 2) return { id: "partly", label: "Partly cloudy" };
  if (code === 3) return { id: "overcast", label: "Overcast" };
  if (code === 45 || code === 48) return { id: "fog", label: "Fog" };
  if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return { id: "rain", label: "Rain" };
  if ((code >= 71 && code <= 77) || code === 85 || code === 86) return { id: "snow", label: "Snow" };
  if (code === 95) return { id: "storm", label: "Thunderstorm" };
  if (code >= 96 && code <= 99) return { id: "storm", label: "Thunderstorm with hail" };
  return null;
}

/** Open-Meteo refreshes about every 15 minutes; the fetch is cached for 30. The page regenerates at its shortest fetch window. */
export const WEATHER_REVALIDATE_S = 1800;

export const DENVER_FORECAST_URL =
  "https://api.open-meteo.com/v1/forecast?latitude=39.7392&longitude=-104.9903&current=temperature_2m,weather_code&timezone=America%2FDenver";

export type DenverSky = {
  sky: Sky;
  /** Raw WMO weather code from Open-Meteo (96–99 = thunderstorm with hail). */
  code: number;
  celsius: number | null;
  /**
   * When Open-Meteo observed it, as a UTC ISO instant ("2026-11-06T20:00:00.000Z"),
   * or null when the payload lacks the time or offset. Format it with
   * Intl (timeZone "America/Denver") to print "13:00 MST"; never print render time instead.
   */
  observedAt: string | null;
};

let denverClockFormat: Intl.DateTimeFormat | null = null;

/**
 * A UTC instant as Denver wall time with its zone, "13:00 MST" (or "MDT"): the
 * one way the site prints an observation time. Null in, or an unparseable
 * instant, gives null, so a missing time is omitted rather than replaced by the
 * render time. Client-safe (no server imports), for the terminal and readouts alike.
 */
export function denverClock(iso: string | null): string | null {
  if (iso === null) return null;
  const instant = new Date(iso);
  if (Number.isNaN(instant.getTime())) return null;
  denverClockFormat ??= new Intl.DateTimeFormat("en-US", {
    timeZone: "America/Denver",
    hour: "2-digit",
    minute: "2-digit",
    hourCycle: "h23",
    timeZoneName: "short",
  });
  return denverClockFormat.format(instant);
}

/** Open-Meteo `current.time` is local wall time ("2026-11-06T13:00"); `utc_offset_seconds` places it. */
export function observedInstant(localTime: unknown, utcOffsetSeconds: unknown): string | null {
  if (typeof localTime !== "string" || typeof utcOffsetSeconds !== "number" || !Number.isFinite(utcOffsetSeconds)) {
    return null;
  }
  const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(localTime);
  if (!match) return null;
  const [, year, month, day, hour, minute] = match.map(Number) as [number, number, number, number, number, number];
  const wall = Date.UTC(year, month - 1, day, hour, minute);
  if (!Number.isFinite(wall)) return null;
  return new Date(wall - utcOffsetSeconds * 1000).toISOString();
}

export function readDenverForecast(payload: unknown): DenverSky | null {
  if (!payload || typeof payload !== "object") return null;
  const current = (payload as { current?: unknown }).current;
  if (!current || typeof current !== "object") return null;
  const code = (current as { weather_code?: unknown }).weather_code;
  const temperature = (current as { temperature_2m?: unknown }).temperature_2m;
  if (typeof code !== "number") return null;
  const sky = skyFromCode(code);
  if (!sky) return null;
  return {
    sky,
    code,
    celsius: typeof temperature === "number" && Number.isFinite(temperature) ? temperature : null,
    observedAt: observedInstant(
      (current as { time?: unknown }).time,
      (payload as { utc_offset_seconds?: unknown }).utc_offset_seconds,
    ),
  };
}

/**
 * Denver now, for the server render, cached for WEATHER_REVALIDATE_S. Any failure (network, status, shape, unknown code)
 * returns null so the page drops its weather readings instead of guessing.
 */
export async function loadDenverWeather(): Promise<DenverSky | null> {
  try {
    const response = await fetch(DENVER_FORECAST_URL, {
      signal: AbortSignal.timeout(8_000),
      next: { revalidate: WEATHER_REVALIDATE_S },
    });
    if (!response.ok) return null;
    return readDenverForecast((await response.json()) as unknown);
  } catch {
    return null;
  }
}

src/lib/live-data.ts

TypeScript · 63 lines · 2.2 KB

/**
 * Server-side shaping of the live data: the page loads the three sources, these
 * pure helpers assemble LiveData and the bar's strip.
 * Each component slims its own client-bound data from LiveData in its own files
 * (the radar: src/components/radar/radar-props.ts; the tape; the terminal).
 * Nothing here fetches, and nothing here invents a value: a failed source stays null.
 */
import type { LiveData, LiveStripData } from "@/lib/contracts";
import type { HailSummary } from "@/lib/hail-days";
import type { LighthouseRecord } from "@/lib/lighthouse-record";
import type { DenverSky } from "@/lib/weather";

const DENVER_TZ = "America/Denver";

/** Denver calendar date as compact YYYYMMDD. */
export function denverToday(now: Date): string {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: DENVER_TZ,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(now);
  const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((item) => item.type === type)?.value ?? "";
  return `${part("year")}${part("month")}${part("day")}`;
}

/** Day of year for a compact YYYYMMDD, Jan 01 = 0. NaN for a malformed stamp. */
export function dayOfYear(compact: string): number {
  if (!/^\d{8}$/.test(compact)) return Number.NaN;
  const year = Number(compact.slice(0, 4));
  const month = Number(compact.slice(4, 6));
  const day = Number(compact.slice(6, 8));
  const date = Date.UTC(year, month - 1, day);
  const check = new Date(date);
  if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return Number.NaN;
  return Math.round((date - Date.UTC(year, 0, 1)) / 86_400_000);
}

export function assembleLiveData(input: {
  hail: HailSummary | null;
  weather: DenverSky | null;
  lighthouse: LighthouseRecord | null;
  now: Date;
}): LiveData {
  const today = denverToday(input.now);
  return {
    hail: input.hail,
    weather: input.weather,
    lighthouse: input.lighthouse,
    year: Number(today.slice(0, 4)),
    today,
    todayDoy: dayOfYear(today),
    renderedAt: input.now.toISOString(),
  };
}

export function toLiveStrip(live: LiveData): LiveStripData {
  return {
    weather: live.weather,
    hailDays: live.hail ? live.hail.days : null,
    year: live.year,
  };
}

src/components/system/terminal-commands.ts

TypeScript · 394 lines · 16.6 KB

/**
 * TTY/01: the ten commands, completion and history.
 *
 * Pure: no DOM, no React, no clock. Every output line is assembled from
 * src/content/site.ts and TerminalFacts, which the server builds from the same
 * LiveData the HUD prints, so the terminal can't drift from the page. A failed
 * source prints that it is unavailable; nothing here fills in a number.
 *
 * Side effects (copy, open a tab, route, clear) are returned as actions and run
 * by terminal.tsx, which reports "copied" only after the clipboard accepted it.
 * The server renders the seeded scrollback with this module; the client loads
 * it lazily once 04 is within a viewport, so it stays off the first-load path.
 */
import { denverTime } from "@/components/console/denver-time";
import { site } from "@/content/site";

export const COMMAND_NAMES = [
  "help",
  "whoami",
  "contact",
  "linkedin",
  "hailscore",
  "resume",
  "stack",
  "weather",
  "hail",
  "clear",
] as const;

export type CommandName = (typeof COMMAND_NAMES)[number];

/** One row of Built with, also printed by `stack`. `id` is the npm name, or "webgl2" for the radar. */
export type StackItem = { id: string; label: string; version: string };

/** Serializable facts the server hands the terminal island. null = the source failed. */
export type TerminalFacts = {
  /** Denver calendar year of the render (LiveData.year). */
  year: number;
  hail: {
    days: number;
    /** Compact YYYYMMDD. */
    first: string | null;
    last: string | null;
    /** Every hail report (HailSummary.reportCount). */
    reports: number;
    /** Distinct places the radar plots (HailSummary.locationCount). Never printed as reports. */
    locations: number;
  } | null;
  /** observedAt: UTC ISO instant (DenverSky.observedAt), printed in America/Denver. */
  weather: { celsius: number | null; label: string; observedAt: string | null } | null;
  stack: readonly StackItem[];
};

export type Tone = "out" | "key" | "value" | "hail" | "signal";
/**
 * One run of output. Consecutive `keep` spans render as one unit the line
 * never breaks inside ("first April 1, 2026", "lenis 1.3.27"), so a narrow
 * terminal wraps between units instead of stranding "2026" or "raw".
 */
export type Span = { text: string; tone?: Tone; href?: string; keep?: boolean };

/**
 * A seeded line a narrow phone's short scrollback leaves out, so the entry it
 * belongs to reads whole at rest (terminal.module.css: the `tty` screen under
 * 328px wide and 240px tall). Seeds only: a command the visitor runs prints all of it.
 */
export type LineDrop = "narrow";

export type TerminalLine =
  /** `source`: the line names where the numbers above it come from. */
  | { kind: "text"; spans: readonly Span[]; source?: boolean; drop?: LineDrop }
  /** help: name/description pairs, two pairs per row on desktop. */
  | { kind: "table"; rows: readonly (readonly [string, string])[] };

/**
 * Where seeded scrollback gives way so the live prompt stays in view: "short" when the scrollback is under 600px
 * tall (a 1366×768 laptop), "short-narrow" when it is also under 560px wide (1024×768), where `help` wraps.
 * The same marks rank the desktop-only seeds a tall tablet terminal brings back: "short-narrow" first, then "short".
 */
export type SeedDrop = "short" | "short-narrow";

/**
 * One prompt and what it printed. Seeded scrollback only: `desktopOnly` entries are dropped by the 390 layout,
 * and `drop` entries by a short desktop terminal, or kept by a tall tablet one (terminal.module.css container queries).
 */
export type TerminalEntry = { id: number; input: string; lines: readonly TerminalLine[]; desktopOnly?: boolean; drop?: SeedDrop };

export type CommandAction =
  | { type: "copy"; text: string }
  | { type: "open"; href: string }
  | { type: "route"; href: string }
  | { type: "clear" };

export type CommandResult = { lines: readonly TerminalLine[]; action?: CommandAction };

/** Data sources, named once for the terminal and the 05 footer. */
export const SOURCES = {
  hail: "NWS Local Storm Reports · Iowa Environmental Mesonet",
  hailShort: "NWS · Iowa Environmental Mesonet",
  /** As 01's head names it: "NWS LSR / IEM". */
  hailCredit: "NWS LSR / IEM",
  weather: "Open-Meteo",
} as const;

const LINKEDIN = site.profiles.find((profile) => profile.label === "LinkedIn")?.href ?? null;

export function isCommandName(value: string): value is CommandName {
  return (COMMAND_NAMES as readonly string[]).includes(value);
}

/* ── Formatting (Intl with fixed zones, so server and client agree) ─────── */

const DAY_FORMAT = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });

/** "20260401" → "April 1, 2026". A compact stamp is a calendar date, so it is formatted in UTC (no zone shift). */
export function formatDay(compact: string): string | null {
  const match = /^(\d{4})(\d{2})(\d{2})$/.exec(compact);
  if (!match) return null;
  const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
  return Number.isNaN(date.getTime()) ? null : DAY_FORMAT.format(date);
}

/** UTC ISO instant → "13:00 MST" (or MDT), Denver wall time, formatted like the bar's clock. */
export function formatDenverTime(iso: string): string | null {
  const date = new Date(iso);
  if (Number.isNaN(date.getTime())) return null;
  const { time, zone } = denverTime(date, false);
  return `${time} ${zone}`;
}

export function formatCelsius(celsius: number): string {
  return `${celsius.toFixed(1)} °C`;
}

/* ── Commands ─────────────────────────────────────────────────────────── */

type TextLine = Extract<TerminalLine, { kind: "text" }>;

const text = (...spans: (Span | string)[]): TextLine => ({
  kind: "text",
  spans: spans.map((span) => (typeof span === "string" ? { text: span } : span)),
});

function host(url: string): string {
  return new URL(url).hostname.replace(/^www\./, "");
}

/** One-line descriptions for `help`, in COMMAND_NAMES order. */
export function commandHelp(facts: TerminalFacts): readonly (readonly [CommandName, string])[] {
  const about: Record<CommandName, string> = {
    help: "this list",
    whoami: "who this is",
    contact: "copy the email",
    linkedin: "open LinkedIn",
    hailscore: `open ${host(site.company.url)}`,
    resume: "the one-page résumé",
    stack: "what this site runs on",
    weather: `${site.locality}, right now`,
    hail: `Colorado hail days, ${facts.year}`,
    clear: "clear the screen",
  };
  return COMMAND_NAMES.map((name) => [name, about[name]] as const);
}

function hailLines(facts: TerminalFacts): TerminalLine[] {
  const hail = facts.hail;
  if (!hail) return [text("Storm reports unavailable. The count stays dark rather than guessed.")];
  const first = hail.first ? formatDay(hail.first) : null;
  const last = hail.last ? formatDay(hail.last) : null;
  const lines: TerminalLine[] = [
    text({ text: String(hail.days), tone: "hail" }, ` hail days in Colorado so far in ${facts.year}.`),
  ];
  if (first && last) {
    // The two spaces between the pairs are the only break, so 390 reads "first …" over "latest …".
    lines.push(
      text(
        { text: "first ", tone: "key", keep: true },
        { text: first, tone: "value", keep: true },
        "  ",
        { text: "latest ", tone: "key", keep: true },
        { text: last, tone: "value", keep: true },
      ),
    );
  }
  lines.push(
    text(
      { text: String(hail.reports), tone: "value" },
      " reports · ",
      { text: String(hail.locations), tone: "value" },
      " locations on the radar",
    ),
  );
  lines.push({ ...text({ text: "source  ", tone: "key" }, { text: SOURCES.hail, tone: "key" }), source: true });
  return lines;
}

function weatherLines(facts: TerminalFacts): TerminalLine[] {
  const weather = facts.weather;
  if (!weather) return [text(`${site.locality} weather unavailable. The reading stays dark rather than guessed.`)];
  const time = weather.observedAt ? formatDenverTime(weather.observedAt) : null;
  const spans: Span[] = [{ text: site.locality }];
  if (weather.celsius !== null) spans.push({ text: " " }, { text: formatCelsius(weather.celsius), tone: "value" });
  spans.push({ text: ` · ${weather.label.toLowerCase()}` }, { text: ` · ${SOURCES.weather}${time ? ` · ${time}` : ""}`, tone: "key" });
  return [text(...spans)];
}

/**
 * `hailscore`: the Selected work case file, all from site.work: the pitch, the free tier
 * for homeowners, the two dataset figures as they are published, and the link (the tab it opens).
 * Each figure and its label is one unit, so a narrow terminal wraps between the two.
 */
function hailscoreLines(): TerminalLine[] {
  const { work } = site;
  const figures: Span[] = [];
  work.stats.forEach((stat, index) => {
    if (index > 0) figures.push({ text: " · " });
    figures.push({ text: stat.value, tone: "value", keep: true }, { text: ` ${stat.label}`, keep: true });
  });
  return [
    text({ text: work.title, tone: "key" }),
    text(work.pitch),
    text({ text: `${work.freeLabel}:`, tone: "key" }, { text: ` ${work.free}` }),
    text(...figures),
    text({ text: work.url, tone: "value", href: work.url }),
  ];
}

function stackLines(facts: TerminalFacts): TerminalLine[] {
  const spans: Span[] = [];
  facts.stack.forEach((item, index) => {
    if (index > 0) spans.push({ text: " · " });
    const isPackage = item.id !== "webgl2";
    spans.push({ text: `${isPackage ? item.id : item.label.toLowerCase()} `, keep: true }, { text: item.version, tone: "value", keep: true });
  });
  return [text(...spans)];
}

/**
 * Run one line of input. Only the first word counts and case is ignored, so
 * `HAIL` and `hail now` both run `hail`. Empty input echoes an empty prompt.
 */
export function runCommand(input: string, facts: TerminalFacts): CommandResult {
  const word = (input.trim().split(/\s+/)[0] ?? "").toLowerCase();
  if (word === "") return { lines: [] };
  if (!isCommandName(word)) {
    return { lines: [text("command not found: ", { text: word, tone: "value" }, " · try ", { text: "help", tone: "signal" })] };
  }
  switch (word) {
    case "help":
      return { lines: [{ kind: "table", rows: commandHelp(facts) }] };
    case "whoami":
      return {
        lines: [text(`${site.name} — ${site.role}.`), text(`Weather data analyst based in ${site.locationLabel}.`)],
      };
    case "contact":
      // The line is printed by contactLines() once the copy has actually run.
      return { lines: [], action: { type: "copy", text: site.email } };
    case "linkedin":
      return LINKEDIN
        ? { lines: [text({ text: LINKEDIN, tone: "value", href: LINKEDIN })], action: { type: "open", href: LINKEDIN } }
        : { lines: [text("LinkedIn profile unavailable.")] };
    case "hailscore":
      return { lines: hailscoreLines(), action: { type: "open", href: site.work.url } };
    case "resume":
      return { lines: [text({ text: "/resume", tone: "value" }, " · opening the one-page résumé")], action: { type: "route", href: "/resume" } };
    case "stack":
      return { lines: stackLines(facts) };
    case "weather":
      return { lines: weatherLines(facts) };
    case "hail":
      return { lines: hailLines(facts) };
    case "clear":
      return { lines: [], action: { type: "clear" } };
  }
}

/** What `contact` prints after the copy ran (or failed), so it never claims a copy that didn't happen. */
export function contactLines(copied: boolean): readonly TerminalLine[] {
  return copied
    ? [text({ text: site.email, tone: "value" }, " · copied to clipboard")]
    : [text({ text: site.email, tone: "value" }, " · the clipboard is blocked here, so copy it from this line")];
}

/**
 * Seeded output may give up its source line on a narrow phone ("narrow", the
 * 320–375 screens), where the seeded `hail` wraps to nine lines in a seven-line
 * view: 02's echo log links the same NWS reports and 05's footer names them.
 */
function seedLines(lines: readonly TerminalLine[]): TerminalLine[] {
  return lines.map((line) => (line.kind === "text" && line.source ? { ...line, drop: "narrow" } : line));
}

/**
 * The read-only session the scrollback is seeded with (no `contact`, nothing
 * that claims a side effect). The 390 layout keeps whoami and hail; a tablet's
 * taller terminal adds `weather`, then `stack`, as its height allows. A short
 * desktop terminal drops `stack` first (Built with prints the same list beside
 * it), then `weather` (the bar prints it) where it is also narrow.
 */
export function seedSession(facts: TerminalFacts): TerminalEntry[] {
  const seed: { input: CommandName; desktopOnly?: boolean; drop?: SeedDrop }[] = [
    { input: "help", desktopOnly: true },
    { input: "whoami" },
    { input: "hail" },
    { input: "weather", desktopOnly: true, drop: "short-narrow" },
    { input: "stack", desktopOnly: true, drop: "short" },
  ];
  return seed.map((entry, index) => ({
    id: index,
    input: entry.input,
    lines: seedLines(runCommand(entry.input, facts).lines),
    ...(entry.desktopOnly ? { desktopOnly: true } : {}),
    ...(entry.drop ? { drop: entry.drop } : {}),
  }));
}

/* ── Completion ───────────────────────────────────────────────────────── */

/** Commands that start with the typed word (case-insensitive). Nothing once a space is typed. */
export function completions(input: string): readonly CommandName[] {
  const word = input.trimStart().toLowerCase();
  if (word === "" || /\s/.test(word)) return [];
  return COMMAND_NAMES.filter((name) => name.startsWith(word));
}

function commonPrefix(words: readonly string[]): string {
  if (words.length === 0) return "";
  let prefix = words[0] ?? "";
  for (const word of words) {
    while (!word.startsWith(prefix)) prefix = prefix.slice(0, -1);
  }
  return prefix;
}

/**
 * Tab: extend the input to the longest prefix every match shares (bash
 * behaviour). `ha` → `hail`, `hails` → `hailscore`. Returns the input unchanged
 * when there is nothing to add.
 */
export function complete(input: string): string {
  const matches = completions(input);
  const prefix = commonPrefix(matches);
  const typed = input.trimStart();
  return prefix.length > typed.length ? prefix : input;
}

/**
 * The ghost text after the caret: exactly what Tab would add, so the hint never
 * promises a completion Tab won't make. `help` when the line is empty.
 */
export function ghost(input: string): string {
  if (input === "") return "help";
  const completed = complete(input);
  return completed === input ? "" : completed.slice(input.trimStart().length);
}

/* ── History (↑/↓) ────────────────────────────────────────────────────── */

/**
 * `index === items.length` is the live line. `edits` keeps what the visitor
 * typed on any line while browsing, by index: the half-typed new line and any
 * recalled line they changed. Walking away and back finds the edit, not the
 * original, until Enter clears them all (readline with revert-all-at-newline).
 */
export type HistoryState = { items: readonly string[]; index: number; edits: Readonly<Record<number, string>> };

export const EMPTY_HISTORY: HistoryState = { items: [], index: 0, edits: {} };

export function historyFrom(items: readonly string[]): HistoryState {
  return { items, index: items.length, edits: {} };
}

/** Record a submitted line. Blank lines and an immediate repeat are skipped, like HISTCONTROL=ignoredups. */
export function remember(state: HistoryState, line: string): HistoryState {
  const value = line.trim();
  const items = value === "" || state.items.at(-1) === value ? state.items : [...state.items, value];
  return { items, index: items.length, edits: {} };
}

/** A line as history holds it: the original entry, or "" for the live line. */
function original(state: HistoryState, index: number): string {
  return index === state.items.length ? "" : (state.items[index] ?? "");
}

/** ↑ (-1) or ↓ (+1). `current` is the input's value right now. At either end nothing moves and the line is kept. */
export function recall(state: HistoryState, direction: -1 | 1, current: string): { state: HistoryState; value: string } {
  const index = Math.min(state.items.length, Math.max(0, state.index + direction));
  if (state.items.length === 0 || index === state.index) return { state, value: current };
  // Keep the line being left only if it differs from what history holds there.
  const edits = { ...state.edits };
  if (current === original(state, state.index)) delete edits[state.index];
  else edits[state.index] = current;
  return { state: { items: state.items, index, edits }, value: edits[index] ?? original(state, index) };
}

src/components/system/terminal.tsx

TSX · 302 lines · 12.2 KB

"use client";

/**
 * TTY/01: the live terminal in 04.
 *
 * Not on the first-load path: the server renders the finished session as static
 * HTML and TerminalIsland (terminal-island.tsx) swaps this component in for it
 * once 04 is within a viewport. Both render the same markup (terminal-view.tsx).
 * This file is the input handling and the scrollback's resting place; the
 * command logic (terminal-commands.ts) is a separate chunk fetched on the same
 * cue, and the first-entry replay types the last seeded command
 * (terminal-hooks.ts).
 *
 * Input model: a real, visible <input> in a monospace face with its native caret
 * hidden. The 2px signal caret is drawn at `selectionStart × 1ch`, which is exact
 * because every glyph of a monospace font advances one `ch`. The ghost
 * completion sits at `value.length × 1ch`. If the field ever scrolls (a very long
 * line) or text is selected, the native caret comes back instead of guessing.
 *
 * `linkedin` and `hailscore` open their page in a new tab with a fine pointer;
 * on touch they only print it, link included (the tab would hide the output).
 *
 * Keyboard: Enter runs, ↑/↓ walk history, Tab completes, → accepts the ghost, Esc
 * clears the line, Ctrl-L clears the screen, Ctrl-C cancels the line. Tab is only
 * taken when it does something: it extends the word, or (zsh-style) lists an
 * ambiguous word's candidates once. The next Tab on that same line, and any Tab
 * on an empty or complete line, moves focus as usual, so the terminal is never a
 * keyboard trap and repeated presses never stack copies of the list.
 *
 * Where the scrollback rests: at the newest output, from its own command line
 * when that output is taller than the view (terminal-rest.ts), through resizes
 * and font swaps until the visitor scrolls away to read. CSS first drops the
 * seeds another panel already prints when the terminal is short
 * (terminal.module.css), so on most screens nothing needs to scroll at all.
 */
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type FocusEvent, type FormEvent, type KeyboardEvent, type MouseEvent, type SyntheticEvent } from "react";
import { copyText } from "@/components/overlays/copy-text";
import { emit } from "@/lib/contracts";
import { isFinePointer } from "@/lib/motion";
import type { HistoryState, TerminalEntry, TerminalFacts, TerminalLine } from "./terminal-commands";
import { useCommandLogic, useFirstEntryReplay } from "./terminal-hooks";
import { holdRest } from "./terminal-rest";
import { RESTING_PROMPT, TerminalPanel } from "./terminal-view";

/**
 * What the visitor did to the static copy before this one arrived
 * (terminal-island.tsx): the line typed so far, what had focus (the field, the
 * scrollback, a chip by its index, or null: none of them), a chip or line to
 * run, and where they scrolled the scrollback (null: at rest).
 */
export type Handoff = {
  input: string;
  focus: "input" | "scrollback" | { chip: number } | null;
  run: string | null;
  scrollTop: number | null;
};

/**
 * The coarse layout's chips run in one row that scrolls sideways, and the
 * browser doesn't always scroll a focused chip that peeks in at the row's edge
 * into view. Bring it in, clear of the edge fades (the row's scroll-padding).
 */
function revealChip(row: HTMLElement, chip: Element | null) {
  if (row.scrollWidth <= row.clientWidth || !(chip instanceof HTMLElement) || chip.parentElement !== row) return;
  chip.scrollIntoView({ block: "nearest", inline: "nearest" });
}

export type TerminalProps = {
  facts: TerminalFacts;
  /** The read-only session, rendered on the server with the same command logic. */
  seed: readonly TerminalEntry[];
  /** Command names for the strip (passed in so the shell needn't import the logic). */
  commands: readonly string[];
  /** Host printed in the banner, e.g. "alexchicilo.com". */
  host: string;
  className?: string;
  initial?: Handoff;
};

export function Terminal({ facts, seed, commands, host, className, initial }: TerminalProps) {
  const router = useRouter();
  const rootRef = useRef<HTMLDivElement>(null);
  const bodyRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const history = useRef<HistoryState>({ items: seed.map((entry) => entry.input), index: seed.length, edits: {} });
  const nextId = useRef(seed.length);
  const acted = useRef(false);
  /** Re-rest the scrollback; `force` also takes it back from a visitor who scrolled away (they just ran something). */
  const rest = useRef<(force: boolean) => void>(() => {});
  const handoff = useRef(initial);
  /** The line whose candidates Tab already listed: another Tab on it moves focus instead of listing again. */
  const listed = useRef<string | null>(null);

  const [entries, setEntries] = useState<readonly TerminalEntry[]>(seed);
  const [banner, setBanner] = useState(true);
  const [value, setValue] = useState(initial?.input ?? "");
  const [column, setColumn] = useState(initial?.input.length ?? 0);
  const [nativeCaret, setNativeCaret] = useState(false);
  const { logic, ensure } = useCommandLogic(rootRef);
  const { typing, cancel: cancelReplay } = useFirstEntryReplay(rootRef, seed);

  // The layout shows the seeds it has room for (terminal.module.css), so ↑ recalls what is on screen.
  useEffect(() => {
    const shown = new Set(
      [...(rootRef.current?.querySelectorAll<HTMLElement>("[data-entry]") ?? [])]
        .filter((element) => element.getClientRects().length > 0)
        .map((element) => Number(element.dataset.entry)),
    );
    const visible = seed.filter((entry) => shown.has(entry.id)).map((entry) => entry.input);
    history.current = { items: visible, index: visible.length, edits: {} };
  }, [seed]);

  // The scrollback's resting place, kept through resizes and font swaps (terminal-rest.ts).
  useLayoutEffect(() => {
    const body = bodyRef.current;
    if (!body) return;
    const hold = holdRest(body, handoff.current?.scrollTop ?? null);
    rest.current = hold.rest;
    return () => {
      rest.current = () => {};
      hold.stop();
    };
  }, []);

  // After the visitor acts, the newest output comes into view from its command line (never on first paint).
  useLayoutEffect(() => {
    if (acted.current) rest.current(true);
  }, [entries]);

  const append = useCallback((input: string, lines: readonly TerminalLine[]) => {
    acted.current = true;
    const id = nextId.current;
    nextId.current += 1;
    setEntries((current) => [...current, { id, input, lines }]);
  }, []);

  const clearScreen = useCallback(() => {
    acted.current = true;
    setEntries([]);
    setBanner(false);
  }, []);

  const run = useCallback(
    async (raw: string) => {
      cancelReplay();
      // Loaded already, the action below still runs inside the click or keypress (popups allowed).
      const mod = logic ?? (await ensure().catch(() => null));
      if (!mod) {
        // The command chunk failed to load (offline?). Say so; the next command retries it.
        append(raw, [{ kind: "text", spans: [{ text: "The terminal could not load its commands. Check the connection and try again." }] }]);
        return;
      }
      history.current = mod.remember(history.current, raw);
      const result = mod.runCommand(raw, facts);
      const action = result.action;
      if (action?.type === "clear") {
        clearScreen();
        return;
      }
      // On touch the tab would take the screen before the output is read (iOS switches to it at once):
      // the lines print and their link opens it on a second, deliberate tap. A keyboard and mouse get the tab.
      if (action?.type === "open" && !window.matchMedia("(pointer: coarse)").matches) {
        window.open(action.href, "_blank", "noopener,noreferrer");
      }
      if (action?.type === "copy") {
        const copied = await copyText(action.text);
        if (copied) emit("acx:sfx", { cue: "copy" });
        append(raw, mod.contactLines(copied));
        return;
      }
      append(raw, result.lines);
      if (action?.type === "route") router.push(action.href);
    },
    [append, cancelReplay, clearScreen, ensure, facts, logic, router],
  );

  // Take over from the static copy: its focus, then the chip or line it was asked to run.
  useEffect(() => {
    const from = handoff.current;
    if (!from) return;
    handoff.current = undefined;
    if (from.focus === "input") inputRef.current?.focus({ preventScroll: true });
    else if (from.focus === "scrollback") bodyRef.current?.focus({ preventScroll: true });
    else if (from.focus) {
      const row = rootRef.current?.querySelector<HTMLElement>("[data-chips]");
      const chip = row?.children[from.focus.chip];
      if (row && chip instanceof HTMLElement) {
        chip.focus({ preventScroll: true });
        revealChip(row, chip);
      }
    }
    if (from.run !== null) void run(from.run);
  }, [run]);

  const syncCaret = (event: SyntheticEvent<HTMLInputElement>) => {
    const input = event.currentTarget;
    const start = input.selectionStart ?? input.value.length;
    setColumn(start);
    setNativeCaret(input.scrollLeft > 0 || start !== (input.selectionEnd ?? start));
  };

  const setLine = (next: string) => {
    listed.current = null;
    setValue(next);
    setColumn(next.length);
  };

  const ghost = logic ? logic.ghost(value) : value === "" ? RESTING_PROMPT.ghost : "";
  const showGhost = ghost !== "" && column === value.length && !nativeCaret;

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const raw = value;
    setLine("");
    void run(raw);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    const input = event.currentTarget;
    const key = event.key;
    if ((key === "ArrowUp" || key === "ArrowDown") && logic) {
      event.preventDefault();
      const step = logic.recall(history.current, key === "ArrowUp" ? -1 : 1, value);
      history.current = step.state;
      setLine(step.value);
      return;
    }
    if (key === "Tab" && !event.shiftKey && logic && value.trim() !== "") {
      const matches = logic.completions(value);
      const completed = logic.complete(value);
      if (completed !== value) {
        event.preventDefault();
        setLine(completed);
      } else if (matches.length > 1 && listed.current !== value) {
        event.preventDefault();
        listed.current = value;
        append(value, [{ kind: "text", spans: [{ text: matches.join("  "), tone: "value" }] }]);
      }
      return;
    }
    if (key === "ArrowRight" && showGhost) {
      event.preventDefault();
      setLine(value + ghost);
      return;
    }
    if (key === "Escape" && value !== "") {
      // Handled here, so eggs.ts leaves Calibration mode alone (it skips defaultPrevented).
      event.preventDefault();
      setLine("");
      return;
    }
    if (event.ctrlKey && !event.metaKey && !event.altKey) {
      const letter = key.toLowerCase();
      if (letter === "l") {
        event.preventDefault();
        clearScreen();
      } else if (letter === "c" && input.selectionStart === input.selectionEnd) {
        event.preventDefault();
        append(`${value}^C`, []);
        setLine("");
      }
    }
  };

  const onScreenClick = (event: MouseEvent<HTMLDivElement>) => {
    // Mouse convenience only: on touch, tapping the scrollback must not raise the keyboard.
    if (!isFinePointer() || window.getSelection()?.isCollapsed === false) return;
    if (event.target instanceof Element && event.target.closest("a, button, input, [data-more-cue]")) return;
    inputRef.current?.focus({ preventScroll: true });
  };

  return (
    <TerminalPanel
      className={className}
      host={host}
      commands={commands}
      entries={entries}
      banner={banner}
      typing={typing}
      ready={logic !== null}
      prompt={{ value, column, ghost, nativeCaret }}
      live={{
        rootRef,
        bodyRef,
        inputRef,
        onFocusCapture: cancelReplay,
        onScreenClick,
        onSubmit,
        onChange: (event) => {
          listed.current = null;
          setValue(event.currentTarget.value);
          syncCaret(event);
        },
        onSelect: syncCaret,
        onKeyDown,
        onCommand: (name) => void run(name),
        onChipFocus: (event: FocusEvent<HTMLDivElement>) => revealChip(event.currentTarget, event.target),
      }}
    />
  );
}

src/components/system/terminal-hooks.ts

TypeScript · 150 lines · 5.8 KB

/**
 * The terminal shell's two timed behaviours, kept out of terminal.tsx so the
 * component reads as input handling only. Client-only hooks
 * (imported by the "use client" terminal).
 *
 * - useCommandLogic: the command chunk (terminal-commands.ts) is not on the
 *   first-load path. It is fetched once 04 is within one viewport,
 *   or on first use if the visitor gets there sooner.
 * - useFirstEntryReplay: on first entry the last seeded command types itself
 *   at 18ms a character. Armed only when 04 starts off screen and the
 *   page is not STILL; a page opened at #system shows the finished session.
 *
 * Every observer reads the LAST entry of a callback. One delivery can hold
 * several entries for the same target: a page opened at /#system is measured
 * at the top, then jumps to the hash before the first callback, which arrives
 * as [not intersecting, intersecting]. Reading the first entry would miss the
 * jump and leave the logic unloaded and the replay armed under a visible prompt.
 */
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { isStill } from "@/lib/motion";
import type { TerminalEntry } from "./terminal-commands";

export type Logic = typeof import("./terminal-commands");

let logicChunk: Promise<Logic> | null = null;
/** One fetch of the command chunk per page, shared by every caller. A failed fetch is forgotten, so the next use retries it. */
const loadLogic = () =>
  (logicChunk ??= import("./terminal-commands").catch((error: unknown) => {
    logicChunk = null;
    throw error;
  }));

/** The logic once loaded (null before), and `ensure` to get it now for a command run early. */
export function useCommandLogic(rootRef: RefObject<HTMLElement | null>): { logic: Logic | null; ensure: () => Promise<Logic> } {
  const [logic, setLogic] = useState<Logic | null>(null);

  useEffect(() => {
    const root = rootRef.current;
    if (!root || typeof IntersectionObserver !== "function") return;
    let live = true;
    const near = new IntersectionObserver(
      (entries) => {
        if (!entries.at(-1)?.isIntersecting) return;
        near.disconnect();
        // A failed fetch here is not fatal: the first command retries it through `ensure`.
        loadLogic().then(
          (mod) => {
            if (live) setLogic(mod);
          },
          () => {},
        );
      },
      { rootMargin: "100% 0px" },
    );
    // The section: before 04's mode wipe its stage's clip-path hides the terminal from the observer.
    near.observe(root.closest("section") ?? root);
    return () => {
      live = false;
      near.disconnect();
    };
  }, [rootRef]);

  const ensure = useCallback(async () => {
    const mod = await loadLogic();
    setLogic(mod);
    return mod;
  }, []);

  return { logic, ensure };
}

/** 18ms a character: quick enough to finish while 04 settles, slow enough to read as typing. */
const TYPE_MS = 18;
/** Share of the terminal that must be visible to count as "entered". */
const ENTER_THRESHOLD = 0.35;

/** The seeded entry being typed and how many characters of its input show. */
export type Typing = { id: number; shown: number };

/**
 * The last seeded entry actually on screen: 390 drops the desktop-only seeds, a tall
 * tablet terminal keeps some, and a short desktop one drops `stack` (CSS), so the replay types what is visible,
 * never an entry that `display: none` keeps out of sight while the prompt waits.
 */
function lastShown(root: HTMLElement, seed: readonly TerminalEntry[]): TerminalEntry | undefined {
  const shown = [...root.querySelectorAll<HTMLElement>("[data-entry]")].filter((element) => element.getClientRects().length > 0);
  const id = Number(shown.at(-1)?.dataset.entry ?? Number.NaN);
  return seed.find((entry) => entry.id === id);
}

export function useFirstEntryReplay(
  rootRef: RefObject<HTMLElement | null>,
  seed: readonly TerminalEntry[],
): { typing: Typing | null; cancel: () => void } {
  const [typing, setTyping] = useState<Typing | null>(null);
  const cancelRef = useRef<() => void>(() => {});

  useEffect(() => {
    const root = rootRef.current;
    if (!root || seed.length === 0 || typeof IntersectionObserver !== "function") return;
    let target: TerminalEntry | undefined;
    let timer = 0;
    let first = true;
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries.at(-1);
        if (!entry) return;
        if (first) {
          first = false;
          target = lastShown(root, seed);
          if (target && !entry.isIntersecting && !isStill()) setTyping({ id: target.id, shown: 0 });
          else observer.disconnect();
          return;
        }
        if (!entry.isIntersecting || !target) return;
        observer.disconnect();
        // Start once the mode wipe (--t-wipe) has revealed 04, so the typing is seen, not hidden under it.
        const wipe = parseFloat(getComputedStyle(root).getPropertyValue("--t-wipe")) || 480;
        const typed = target;
        let shown = 0;
        const step = () => {
          shown += 1;
          if (shown > typed.input.length) {
            setTyping(null);
            return;
          }
          setTyping({ id: typed.id, shown });
          timer = window.setTimeout(step, TYPE_MS);
        };
        timer = window.setTimeout(step, wipe + 120);
      },
      { threshold: ENTER_THRESHOLD },
    );
    observer.observe(root);
    cancelRef.current = () => {
      observer.disconnect();
      window.clearTimeout(timer);
      setTyping(null);
    };
    return () => {
      observer.disconnect();
      window.clearTimeout(timer);
      cancelRef.current = () => {};
    };
  }, [rootRef, seed]);

  // Stable, so handlers can call it freely: the visitor got here first, show the finished session.
  const cancel = useCallback(() => cancelRef.current(), []);
  return { typing, cancel };
}

src/lib/eggs.ts

TypeScript · 130 lines · 5.5 KB

/**
 * Easter eggs. Installed once, on idle, by OverlayHost.
 *
 * - "hail" typed anywhere outside a text field: a 4 s storm on the radar's
 *   particle field (RadarApi.hail), <html data-storm> for its duration and
 *   "acx:hailstorm" for the bar label and the sound patter. Under STILL the
 *   radar draws one static ember frame itself.
 * - The Konami code toggles Calibration mode; Esc exits. This module owns the
 *   state (<html data-calibration> + "acx:calibration"); calibration-mode.tsx
 *   is only the view. The code's arrows scroll the page as they are typed (↓ ↓
 *   moves it 80px), so the scroll position from before its first key is put
 *   back when it completes: Calibration opens on the frame the visitor was
 *   looking at, never with a scene head pushed under the bar.
 * - A styled greeting in the DevTools console, for engineers.
 *
 * Keys typed into inputs never count, so the terminal and the palette can't
 * fire an egg, and modified keys (⌘K, Ctrl-L) are ignored.
 */
import { site } from "@/content/site";
import { DATA_ATTR, emit, getRadar, STORM_MS } from "@/lib/contracts";
import { advanceSequence } from "@/lib/sequence";

/** ↑ ↑ ↓ ↓ ← → ← → B A, as lowercased KeyboardEvent.key values. */
export const KONAMI = ["arrowup", "arrowup", "arrowdown", "arrowdown", "arrowleft", "arrowright", "arrowleft", "arrowright", "b", "a"] as const;

/**
 * Progress through KONAMI after one more key: the longest tail of what was typed
 * that is still a prefix of the code. A plain reset-on-mismatch would lose
 * "↑ ↑ ↑ ↓ ↓ …" (the third ↑ must keep two in hand), so it backs off instead.
 */
export function konamiStep(position: number, key: string): number {
  const typed = [...KONAMI.slice(0, position), key.toLowerCase()];
  for (let length = Math.min(typed.length, KONAMI.length); length > 0; length -= 1) {
    const tail = typed.slice(typed.length - length);
    if (tail.every((value, index) => value === KONAMI[index])) return length;
  }
  return 0;
}

/** Text fields where typing is content, not a command. */
const EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';

export function isTypingTarget(target: EventTarget | null): boolean {
  return target instanceof Element && target.closest(EDITABLE) !== null;
}

/** The console greeting. Pure so it can be tested; styles are applied by `printGreeting`. */
export function greetingLines(origin: string, year: number): { mark: string; body: string } {
  return {
    mark: ["  ▄▀█ █▀▀", `  █▀█ █▄▄   ${new URL(origin).host}`].join("\n"),
    body: [
      `  ${site.name} · ${site.role} · ${site.locationLabel}`,
      `  Every ember on the radar marks a real ${year} NWS hail report location (IEM).`,
      `  Type "hail" anywhere. Then try ↑ ↑ ↓ ↓ ← → ← → B A.`,
      `  Source, read from disk at build: ${origin}/source`,
    ].join("\n"),
  };
}

function denverYear(): number {
  return Number(new Intl.DateTimeFormat("en-US", { timeZone: "America/Denver", year: "numeric" }).format(new Date()));
}

function printGreeting(): void {
  const { mark, body } = greetingLines(window.location.origin, denverYear());
  // Console CSS can't read custom properties, so the signal ink is resolved from the tokens now.
  // The body keeps the console's own text colour: DevTools may be light or dark whatever the site's theme.
  const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent-rgb").trim();
  console.log(
    `%c${mark}\n%c${body}`,
    `color: rgb(${accent}); font: 600 14px/1.25 ui-monospace, monospace;`,
    "font: 12px/1.6 ui-monospace, monospace;",
  );
}

export function installEggs(): () => void {
  const root = document.documentElement;
  let hailBuffer = "";
  let konami = 0;
  /** window.scrollY before the code's first key (keydown runs before the arrow's own scroll). */
  let konamiFrom = 0;
  let stormTimer = 0;

  const storm = () => {
    getRadar()?.hail(STORM_MS);
    root.setAttribute(DATA_ATTR.storm, "");
    emit("acx:hailstorm", { durationMs: STORM_MS });
    window.clearTimeout(stormTimer);
    stormTimer = window.setTimeout(() => root.removeAttribute(DATA_ATTR.storm), STORM_MS);
  };

  const setCalibration = (on: boolean) => {
    root.toggleAttribute(DATA_ATTR.calibration, on);
    emit("acx:calibration", { on });
    emit("acx:sfx", { cue: "detent" });
  };

  const onKey = (event: KeyboardEvent) => {
    if (event.metaKey || event.ctrlKey || event.altKey || event.isComposing) return;
    if (event.key === "Escape") {
      // A dialog or field that handled Esc first (the palette, the terminal) keeps it.
      if (!event.defaultPrevented && root.hasAttribute(DATA_ATTR.calibration)) setCalibration(false);
      return;
    }
    if (isTypingTarget(event.target) || event.repeat) return;

    const step = advanceSequence(hailBuffer, event.key);
    hailBuffer = step.buffer;
    if (step.matched) storm();

    const before = konami;
    konami = konamiStep(konami, event.key);
    if (before === 0 && konami > 0) konamiFrom = window.scrollY;
    if (konami === KONAMI.length) {
      konami = 0;
      // Instant, and native: the arrows scrolled natively too, so Lenis (when it runs) follows as it did for them.
      window.scrollTo({ top: konamiFrom, behavior: "instant" });
      setCalibration(!root.hasAttribute(DATA_ATTR.calibration));
    }
  };

  window.addEventListener("keydown", onKey);
  printGreeting();

  return () => {
    window.removeEventListener("keydown", onKey);
    window.clearTimeout(stormTimer);
    root.removeAttribute(DATA_ATTR.storm);
  };
}

src/lib/sound.ts

TypeScript · 202 lines · 7.4 KB

/**
 * Sound: WebAudio synthesis, no audio files.
 * Installed on idle by OverlayHost, or on the first "acx:sound-toggle".
 *
 * Muted by default; the choice persists in SOUND_STORAGE_KEY. The AudioContext
 * is created on the first toggle to "on" (a click, so the browser lets it
 * start). A visitor who left sound on gets their context on their first
 * gesture of the next visit instead: creating one before any gesture only
 * produces a suspended context and a console warning.
 *
 * Every cue is a few ms of an oscillator through an envelope that starts and
 * ends at zero gain, so nothing clicks. Hover and press cues come from one
 * delegated listener each, so no component has to remember to emit them.
 */
import { DATA_ATTR, emit, listen, SOUND_STORAGE_KEY, type SoundCue } from "@/lib/contracts";

type Blip = { freq: number; ms: number; gain: number; at?: number };

/** One short synthesized blip list per cue. `at` is the start offset in ms. */
export const CUES: Record<Exclude<SoundCue, "hail">, readonly Blip[]> = {
  hover: [{ freq: 1200, ms: 18, gain: 0.025 }],
  press: [{ freq: 600, ms: 30, gain: 0.04 }],
  detent: [
    { freq: 3200, ms: 14, gain: 0.03 },
    { freq: 3200, ms: 14, gain: 0.03, at: 40 },
  ],
  copy: [
    { freq: 880, ms: 60, gain: 0.05 },
    { freq: 1320, ms: 60, gain: 0.05, at: 60 },
  ],
  ping: [{ freq: 2400, ms: 30, gain: 0.03 }],
};

/** Minimum gap per cue, so a sweep over a row of links or a burst of pings can't buzz. */
const MIN_GAP_MS: Partial<Record<SoundCue, number>> = { hover: 45, ping: 400, detent: 120 };

const INTERACTIVE = 'a[href], button, summary, [role="button"], [role="option"], [role="slider"]';

export function readStoredSound(value: string | null): boolean {
  return value === "on";
}

/** Seeded PRNG (mulberry32): the patter is irregular but the same every time, which keeps it testable. */
export function seededRandom(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let value = state;
    value = Math.imul(value ^ (value >>> 15), value | 1);
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

export type Patter = { at: number; freq: number; gain: number };

/**
 * The hail egg's patter: sparse noise ticks over the storm, at gain .02.
 * Density and loudness decay with the storm (uStorm 1 → 0), so it thins out
 * like the particle field does.
 */
export function patterSchedule(durationMs: number, seed = 7): Patter[] {
  const random = seededRandom(seed);
  const ticks: Patter[] = [];
  for (let at = 0; at < durationMs; at += 24) {
    const storm = 1 - at / durationMs;
    if (random() > 0.55 * storm) continue;
    ticks.push({ at: at + random() * 20, freq: 2200 + random() * 3800, gain: 0.02 * (0.35 + 0.65 * storm) });
  }
  return ticks;
}

export function installSound(): () => void {
  const root = document.documentElement;
  let on = false;
  try {
    on = readStoredSound(localStorage.getItem(SOUND_STORAGE_KEY));
  } catch {
    // Storage blocked: stay muted.
  }
  let context: AudioContext | null = null;
  let noise: AudioBuffer | null = null;
  const lastPlayed = new Map<SoundCue, number>();
  let lastHover: Element | null = null;

  const announce = () => {
    root.setAttribute(DATA_ATTR.sound, on ? "on" : "off");
    emit("acx:sound", { on });
  };

  /** The context, created only from a gesture (or after one happened this visit). */
  const audio = (fromGesture: boolean): AudioContext | null => {
    if (context) return context;
    if (typeof AudioContext === "undefined") return null;
    const activated = (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }).userActivation?.hasBeenActive;
    if (!fromGesture && !activated) return null;
    context = new AudioContext();
    return context;
  };

  const blip = (ctx: AudioContext, { freq, ms, gain, at = 0 }: Blip) => {
    const start = ctx.currentTime + at / 1000;
    const end = start + ms / 1000;
    const osc = ctx.createOscillator();
    const env = ctx.createGain();
    osc.type = "sine";
    osc.frequency.value = freq;
    env.gain.setValueAtTime(0, start);
    env.gain.linearRampToValueAtTime(gain, start + 0.002);
    env.gain.exponentialRampToValueAtTime(0.0001, end);
    osc.connect(env).connect(ctx.destination);
    osc.start(start);
    osc.stop(end + 0.01);
  };

  const tick = (ctx: AudioContext, { at, freq, gain }: Patter) => {
    noise ??= (() => {
      const buffer = ctx.createBuffer(1, Math.round(ctx.sampleRate * 0.02), ctx.sampleRate);
      const data = buffer.getChannelData(0);
      const random = seededRandom(11);
      for (let index = 0; index < data.length; index += 1) data[index] = random() * 2 - 1;
      return buffer;
    })();
    const start = ctx.currentTime + at / 1000;
    const source = ctx.createBufferSource();
    const band = ctx.createBiquadFilter();
    const env = ctx.createGain();
    source.buffer = noise;
    band.type = "bandpass";
    band.frequency.value = freq;
    band.Q.value = 6;
    env.gain.setValueAtTime(gain, start);
    env.gain.exponentialRampToValueAtTime(0.0001, start + 0.012);
    source.connect(band).connect(env).connect(ctx.destination);
    source.start(start);
    source.stop(start + 0.02);
  };

  const play = (cue: SoundCue, durationMs = 0) => {
    if (!on || document.hidden) return;
    const ctx = audio(false);
    if (!ctx) return;
    const now = performance.now();
    const gap = MIN_GAP_MS[cue] ?? 0;
    if (now - (lastPlayed.get(cue) ?? -Infinity) < gap) return;
    lastPlayed.set(cue, now);
    if (ctx.state === "suspended") void ctx.resume();
    if (cue === "hail") for (const item of patterSchedule(durationMs)) tick(ctx, item);
    else for (const item of CUES[cue]) blip(ctx, item);
  };

  const setOn = (next: boolean) => {
    on = next;
    try {
      localStorage.setItem(SOUND_STORAGE_KEY, on ? "on" : "off");
    } catch {
      // Private mode: the choice lasts for this visit.
    }
    if (on) {
      const ctx = audio(true);
      if (ctx?.state === "suspended") void ctx.resume();
    }
    announce();
    play("press");
  };

  const onPointerOver = (event: PointerEvent) => {
    if (!on || event.pointerType !== "mouse") return;
    const target = event.target instanceof Element ? event.target.closest(INTERACTIVE) : null;
    if (target && target !== lastHover) play("hover");
    lastHover = target;
  };

  const onPointerDown = (event: PointerEvent) => {
    if (on && event.target instanceof Element && event.target.closest(INTERACTIVE)) play("press");
  };

  const onVisibility = () => {
    if (!context) return;
    if (document.hidden) void context.suspend();
    else if (on) void context.resume();
  };

  announce();
  const stops = [
    listen("acx:sound-toggle", ({ on: next }) => setOn(next ?? !on)),
    listen("acx:sfx", ({ cue }) => play(cue)),
    listen("acx:hailstorm", ({ durationMs }) => play("hail", durationMs)),
  ];
  document.addEventListener("pointerover", onPointerOver, { passive: true });
  document.addEventListener("pointerdown", onPointerDown, { passive: true });
  document.addEventListener("visibilitychange", onVisibility);

  return () => {
    for (const stop of stops) stop();
    document.removeEventListener("pointerover", onPointerOver);
    document.removeEventListener("pointerdown", onPointerDown);
    document.removeEventListener("visibilitychange", onVisibility);
    void context?.close();
    context = null;
  };
}

src/lib/magnetic.ts

TypeScript · 258 lines · 11.1 KB

/**
 * Magnetic pull for every [data-magnetic] element.
 * Installed on idle by OverlayHost, for fine pointers when not STILL.
 *
 * Active within the element's bounds + 32px; offset (dx·0.28, dy·0.38) from its
 * centre, capped at ±12px; released over 600ms on --ease-out. One element at a
 * time pulls (pickMagnet).
 *
 * Why it is written this way:
 * - The offset goes on the individual `translate` property, never `transform`,
 *   so it composes with whatever transform the element's owner sets, and the
 *   only inline style written on another component's element is our own property.
 * - Rects are measured without our offset (rect − applied), so the pull never
 *   feeds back on itself as the element moves toward the pointer.
 * - One rAF loop reads every rect first and writes every offset after, so a
 *   frame never interleaves layout reads and style writes. The loop sleeps when
 *   nothing is moving and wakes on the next pointermove.
 * - The release is a Web Animation from the current offset to zero, so it
 *   doesn't clobber the element's own CSS `transition` list. If the pointer
 *   comes back mid-release, the pull resumes from where the animation has the
 *   element right then (its computed `translate`), not from zero, so it never
 *   jumps.
 * - One element pulls at a time: the one under the pointer, else the nearest in
 *   reach. Buttons sit 8px apart, well inside each other's reach, and two pulls
 *   toward the gap between them would overlap them by 16px. The pull toward a
 *   neighbour is also held to under half the gap between their resting boxes,
 *   so even the element releasing while the next one pulls can't meet it.
 */
import { DATA_ATTR } from "@/lib/contracts";

export const MAGNET = { reach: 32, pullX: 0.28, pullY: 0.38, cap: 12, releaseMs: 600 } as const;

export type Offset = { x: number; y: number };
type Box = { left: number; top: number; width: number; height: number };

const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value));

/** Pure: the pull for a pointer at (px, py) on an element at rest at `box`, or null out of reach. */
export function magneticOffset(px: number, py: number, box: Box): Offset | null {
  const { reach, pullX, pullY, cap } = MAGNET;
  const inside =
    px >= box.left - reach && px <= box.left + box.width + reach && py >= box.top - reach && py <= box.top + box.height + reach;
  if (!inside) return null;
  return {
    x: clamp((px - (box.left + box.width / 2)) * pullX, cap),
    y: clamp((py - (box.top + box.height / 2)) * pullY, cap),
  };
}

/** Pure: how far (px) a pointer at (px, py) is from `box`; 0 inside it. */
export function distanceTo(px: number, py: number, box: Box): number {
  const dx = Math.max(box.left - px, 0, px - (box.left + box.width));
  const dy = Math.max(box.top - py, 0, py - (box.top + box.height));
  return Math.hypot(dx, dy);
}

/**
 * Pure: limit `offset` so the moved box keeps clear of every neighbour's resting
 * box: toward a neighbour beside (or above, below) it, the pull stops 1px short
 * of half the gap. A neighbour counts as beside when their spans across the
 * other axis come within the cap, since the pull can move that far.
 */
export function clampToNeighbours(offset: Offset, box: Box, neighbours: readonly Box[]): Offset {
  const { cap } = MAGNET;
  let { x, y } = offset;
  const right = box.left + box.width;
  const bottom = box.top + box.height;
  const room = (gap: number) => Math.max(0, gap / 2 - 1);
  for (const other of neighbours) {
    const otherRight = other.left + other.width;
    const otherBottom = other.top + other.height;
    const besideRows = other.top < bottom + cap && box.top - cap < otherBottom;
    const besideCols = other.left < right + cap && box.left - cap < otherRight;
    if (besideRows && other.left >= right) x = Math.min(x, room(other.left - right));
    if (besideRows && otherRight <= box.left) x = Math.max(x, -room(box.left - otherRight));
    if (besideCols && other.top >= bottom) y = Math.min(y, room(other.top - bottom));
    if (besideCols && otherBottom <= box.top) y = Math.max(y, -room(box.top - otherBottom));
  }
  return { x, y };
}

/** A challenger must be this much nearer (px) to take the pull from the element that has it. */
const HANDOVER_PX = 4;

/**
 * Pure: the one element that pulls, as an index into `boxes` (resting boxes,
 * null for off-screen), or -1. The element under the pointer wins, else the
 * nearest within reach. `held`, the element pulling last frame, keeps the pull
 * unless the pointer is inside another or another is HANDOVER_PX nearer, so a
 * pointer resting in a gap doesn't flicker between two.
 */
export function pickMagnet(px: number, py: number, boxes: readonly (Box | null)[], held = -1): number {
  let best = -1;
  let bestDistance = Infinity;
  boxes.forEach((box, index) => {
    if (!box || !magneticOffset(px, py, box)) return;
    const distance = distanceTo(px, py, box);
    if (distance < bestDistance) {
      best = index;
      bestDistance = distance;
    }
  });
  const heldBox = boxes[held];
  if (best === -1 || best === held || !heldBox || !magneticOffset(px, py, heldBox)) return best;
  const heldDistance = distanceTo(px, py, heldBox);
  return bestDistance === 0 || heldDistance - bestDistance > HANDOVER_PX ? best : held;
}

/** Pure: every element's pull this frame: one winner (pickMagnet), clamped clear of its neighbours; null for the rest. */
export function magneticOffsets(px: number, py: number, boxes: readonly (Box | null)[], held = -1): (Offset | null)[] {
  const winner = pickMagnet(px, py, boxes, held);
  return boxes.map((box, index) => {
    if (index !== winner || !box) return null;
    const offset = magneticOffset(px, py, box);
    if (!offset) return null;
    const neighbours = boxes.filter((other, at): other is Box => other !== null && at !== index);
    return clampToNeighbours(offset, box, neighbours);
  });
}

/** Pure: a computed `translate` ("none", "4px", "4px -2.5px") as an offset. */
export function parseTranslate(value: string): Offset {
  const [x = 0, y = 0] = value
    .trim()
    .split(/\s+/)
    .map((part) => parseFloat(part))
    .map((number) => (Number.isFinite(number) ? number : 0));
  return { x, y };
}

/** Frame-rate independent smoothing: the share of the remaining distance covered in `dt` ms, at `perFrame` per 60 Hz frame. */
export function follow(perFrame: number, dt: number): number {
  return 1 - Math.pow(1 - perFrame, dt / (1000 / 60));
}

type State = { x: number; y: number; release: Animation | null };

export function installMagnetic(): () => void {
  const selector = `[${DATA_ATTR.magnetic}]`;
  const states = new Map<HTMLElement, State>();
  const ease = getComputedStyle(document.documentElement).getPropertyValue("--ease-out").trim() || "ease-out";
  const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
  let pointer: { x: number; y: number } | null = null;
  /** The element pulling last frame (pickMagnet's `held`). */
  let held: HTMLElement | null = null;
  let frame = 0;
  let last = 0;

  const write = (element: HTMLElement, state: State) => {
    if (Math.abs(state.x) < 0.01 && Math.abs(state.y) < 0.01) element.style.removeProperty("translate");
    else element.style.setProperty("translate", `${state.x.toFixed(2)}px ${state.y.toFixed(2)}px`);
  };

  const release = (element: HTMLElement, state: State) => {
    if (state.release || (state.x === 0 && state.y === 0)) return;
    const from = `${state.x}px ${state.y}px`;
    state.x = 0;
    state.y = 0;
    write(element, state);
    state.release = element.animate([{ translate: from }, { translate: "0px 0px" }], { duration: MAGNET.releaseMs, easing: ease });
    state.release.onfinish = state.release.oncancel = () => {
      state.release = null;
    };
  };

  const releaseAll = () => {
    pointer = null;
    held = null;
    for (const [element, state] of states) release(element, state);
  };

  const tick = (now: number) => {
    frame = 0;
    const dt = last ? Math.min(64, now - last) : 1000 / 60;
    last = now;
    if (!pointer) return;

    // Read phase: resting rects of every magnetic element on screen.
    const targets: { element: HTMLElement; state: State; resume: Offset | null }[] = [];
    const boxes: (Box | null)[] = [];
    for (const element of document.querySelectorAll<HTMLElement>(selector)) {
      const state = states.get(element) ?? { x: 0, y: 0, release: null };
      states.set(element, state);
      // Mid-release the rect includes the animation's offset, not state's (which is already 0).
      const animated = state.release ? parseTranslate(getComputedStyle(element).translate) : null;
      const shift = animated ?? state;
      const rect = element.getBoundingClientRect();
      const offscreen = rect.bottom < 0 || rect.top > window.innerHeight || rect.width === 0;
      targets.push({ element, state, resume: offscreen ? null : animated });
      boxes.push(offscreen ? null : { left: rect.left - shift.x, top: rect.top - shift.y, width: rect.width, height: rect.height });
    }
    const offsets = magneticOffsets(pointer.x, pointer.y, boxes, held ? targets.findIndex((target) => target.element === held) : -1);
    const winner = offsets.findIndex((offset) => offset !== null);
    held = winner === -1 ? null : (targets[winner]?.element ?? null);

    // Write phase.
    let moving = false;
    const k = follow(0.35, dt);
    for (const [index, { element, state, resume }] of targets.entries()) {
      const offset = offsets[index];
      if (!offset) {
        release(element, state);
        continue;
      }
      if (resume) {
        state.x = resume.x;
        state.y = resume.y;
      }
      state.release?.cancel();
      const dx = offset.x - state.x;
      const dy = offset.y - state.y;
      state.x = Math.abs(dx) < 0.05 ? offset.x : state.x + dx * k;
      state.y = Math.abs(dy) < 0.05 ? offset.y : state.y + dy * k;
      moving ||= state.x !== offset.x || state.y !== offset.y;
      write(element, state);
    }
    for (const element of states.keys()) if (!element.isConnected) states.delete(element);
    if (moving) frame = requestAnimationFrame(tick);
    else last = 0;
  };

  const wake = () => {
    if (!frame) frame = requestAnimationFrame(tick);
  };

  const onMove = (event: PointerEvent) => {
    if (event.pointerType !== "mouse" || reduce.matches) return;
    pointer = { x: event.clientX, y: event.clientY };
    wake();
  };

  const onLeave = (event: MouseEvent) => {
    if (!event.relatedTarget) releaseAll();
  };

  const onReduce = () => {
    if (reduce.matches) releaseAll();
  };

  window.addEventListener("pointermove", onMove, { passive: true });
  // Scrolling moves elements under a resting pointer without a pointermove.
  window.addEventListener("scroll", wake, { passive: true });
  document.addEventListener("mouseout", onLeave, { passive: true });
  reduce.addEventListener("change", onReduce);

  return () => {
    cancelAnimationFrame(frame);
    window.removeEventListener("pointermove", onMove);
    window.removeEventListener("scroll", wake);
    document.removeEventListener("mouseout", onLeave);
    reduce.removeEventListener("change", onReduce);
    for (const [element, state] of states) {
      state.release?.cancel();
      element.style.removeProperty("translate");
    }
    states.clear();
  };
}

src/lib/fit-text.ts

TypeScript · 153 lines · 6.6 KB

/**
 * fitText: set display type to the exact measure from its real advance width.
 * Used by the name, the 03 Now title and the 05 email.
 *
 * 1. After `document.fonts.ready`, set the element to 100px and measure its widest
 *    line (children marked [data-fit-line], else the element itself).
 * 2. font-size = measure / width × 100 (× `slack`), capped at `max`.
 * 3. Re-run when the measure's width changes (ResizeObserver, debounced 140ms), when
 *    a late font face finishes loading, and when the text's own width per px of
 *    font-size changes with nothing resized: a reader's letter-, word-spacing or
 *    font override (WCAG 1.4.12 text spacing) would otherwise leave the line wider
 *    than its measure, clipped by the scene.
 *
 * The CSS fallback size (tokens.css) lands within 1px of the fitted value, so the
 * refit is not a layout shift. Lines must not wrap: [data-fit-line] is
 * `display:block; width:max-content; white-space:nowrap` (base.css).
 *
 * Cost: one forced layout per fit (write the probe size, read the width, write the
 * result). It runs once after fonts load and once per settled resize, never per frame.
 */

export type FitTextOptions = {
  /** Available width in px. Default: the parent's content-box width. */
  measure?: (element: HTMLElement) => number;
  /**
   * Largest font-size in px (e.g. 280 for the name). A function is re-evaluated on
   * every fit, for caps that depend on the viewport (the Now title: 190px at
   * ≥ 900px, 17vw below).
   */
  max?: number | ((element: HTMLElement) => number);
  /** Multiplier under 1 leaves breathing room (the mockup uses 0.995 / 0.985). Default 1. */
  slack?: number;
  /** Called after every fit with the font-size in px. */
  onFit?: (fontSize: number) => void;
};

export const FIT_DEBOUNCE_MS = 140;
const PROBE_SIZE = 100;
/** How far the text's width per px of font-size may drift (rounding, hinting) before it counts as a new setting. */
const RATIO_TOLERANCE = 0.005;

/** Content-box width of an element (client width less horizontal padding). */
export function contentWidth(element: Element): number {
  const style = getComputedStyle(element);
  return element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
}

/** Content-box width of the element's parent. */
export function parentMeasure(element: HTMLElement): number {
  const parent = element.parentElement;
  return parent ? contentWidth(parent) : element.getBoundingClientRect().width;
}

/** Pure size rule, exported for tests: measure / widest × probe, slack, capped. */
export function fittedSize(measure: number, widestAtProbe: number, options: { max?: number; slack?: number } = {}): number {
  if (!(measure > 0) || !(widestAtProbe > 0)) return 0;
  const size = (measure / widestAtProbe) * PROBE_SIZE * (options.slack ?? 1);
  return options.max ? Math.min(options.max, size) : size;
}

/** The boxes whose widths are fitted: the [data-fit-line] lines, else the element itself. */
function fitLines(element: HTMLElement): HTMLElement[] {
  const lines = Array.from(element.querySelectorAll<HTMLElement>("[data-fit-line]"));
  return lines.length > 0 ? lines : [element];
}

function widestLine(element: HTMLElement): number {
  const lines = element.querySelectorAll<HTMLElement>("[data-fit-line]");
  if (lines.length === 0) return element.getBoundingClientRect().width;
  let widest = 0;
  for (const line of lines) {
    // A line hidden at this width (display:none) has no boxes and does not count.
    if (line.getClientRects().length === 0) continue;
    widest = Math.max(widest, line.getBoundingClientRect().width);
  }
  return widest;
}

/** Fit once, synchronously. Returns the applied size in px (0 when nothing could be measured). */
export function fitOnce(element: HTMLElement, options: FitTextOptions = {}): number {
  const measure = (options.measure ?? parentMeasure)(element);
  element.style.setProperty("font-size", `${PROBE_SIZE}px`);
  const max = typeof options.max === "function" ? options.max(element) : options.max;
  const size = fittedSize(measure, widestLine(element), { max, slack: options.slack });
  if (size > 0) {
    element.style.setProperty("font-size", `${size.toFixed(2)}px`);
    options.onFit?.(size);
  } else {
    element.style.removeProperty("font-size");
  }
  return size;
}

/** Fit now (after fonts are ready) and whenever the measure changes. Returns a cleanup function. */
export function fitText(element: HTMLElement, options: FitTextOptions = {}): () => void {
  let disposed = false;
  let timer = 0;
  let lastWidth = -1;
  /** The widest line's width per px of font-size after the last fit (0 before the first). */
  let lastRatio = 0;
  /** The text's width per px of font-size as laid out now. */
  const ratio = () => {
    const size = parseFloat(getComputedStyle(element).fontSize);
    return size > 0 ? widestLine(element) / size : 0;
  };
  const run = () => {
    if (disposed) return;
    fitOnce(element, options);
    lastRatio = ratio();
  };
  const schedule = () => {
    window.clearTimeout(timer);
    timer = window.setTimeout(run, FIT_DEBOUNCE_MS);
  };

  const fonts = typeof document !== "undefined" ? document.fonts : undefined;
  void (fonts ? fonts.ready : Promise.resolve()).then(run);
  // A face that loads after `ready` settled (e.g. a weight first used later) changes the advance width.
  fonts?.addEventListener("loadingdone", schedule);

  // The parent's width is the measure: only a width change refits (the fit itself
  // changes the parent's height, and reacting to that would refit for nothing). The
  // lines resize on every fit too, so a line only refits when its width per px of
  // font-size has moved: new spacing or a new face, never the fit's own write.
  const target = element.parentElement ?? element;
  const observer =
    typeof ResizeObserver === "function"
      ? new ResizeObserver((entries) => {
          let stale = false;
          for (const entry of entries) {
            if (entry.target !== target) continue;
            const width = entry.contentRect.width;
            if (width === lastWidth) continue;
            const first = lastWidth < 0;
            lastWidth = width;
            if (!first) stale = true;
          }
          if (!stale && lastRatio > 0 && entries.some((entry) => entry.target !== target)) {
            stale = Math.abs(ratio() / lastRatio - 1) > RATIO_TOLERANCE;
          }
          if (stale) schedule();
        })
      : null;
  observer?.observe(target);
  for (const line of fitLines(element)) if (line !== target) observer?.observe(line);

  return () => {
    disposed = true;
    window.clearTimeout(timer);
    fonts?.removeEventListener("loadingdone", schedule);
    observer?.disconnect();
  };
}

src/lib/type-metrics.ts

TypeScript · 165 lines · 6.9 KB

/**
 * Calibration geometry for display type.
 *
 * One source for where the cap, x-height, ascender and baseline lines fall, so
 * the calibration lines, the plate crop's "collar 16px above the name's
 * ascender line" and Calibration mode all agree to the pixel.
 *
 * CSS centers the font's content area (hhea ascent + descent) inside the line box
 * (half-leading), so for a single-line box of unitless line-height L at size S:
 *   baseline = top + S × ((L − (ascent + descent)) / 2 + ascent)
 * The "Clash Fallback" faces in base.css reproduce Clash's ascent/descent, so the
 * same lines hold before and after the font swap. CLASH_METRICS mirrors the
 * --clash-* tokens in tokens.css (pinned by type-metrics.test.ts); readFontMetrics
 * re-reads cap and x-height from the face the browser actually loaded, which is
 * what the calibration labels print ("CAP 0.670").
 */

export type FontMetrics = {
  /** Cap height, em. */
  cap: number;
  /** x-height, em. */
  x: number;
  /** Tallest ascender (the i dot), em. */
  asc: number;
  /** hhea ascent / unitsPerEm. */
  ascent: number;
  /** |hhea descent| / unitsPerEm. */
  descent: number;
};

/** Clash Display 600, measured in Chrome 2026-11-06. */
export const CLASH_METRICS: FontMetrics = { cap: 0.67, x: 0.501, asc: 0.691, ascent: 0.89, descent: 0.25 };

/** Viewport y of each line, px. */
export type TypeLines = { ascender: number; cap: number; x: number; baseline: number };

/** Baseline offset from the top of a single-line box, px. */
export function baselineFromTop(fontSize: number, lineHeight: number, metrics: FontMetrics = CLASH_METRICS): number {
  return fontSize * ((lineHeight - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent);
}

/** Pure: every line for a box whose top is at `top` (viewport px). */
export function typeLines(top: number, fontSize: number, lineHeight: number, metrics: FontMetrics = CLASH_METRICS): TypeLines {
  const baseline = top + baselineFromTop(fontSize, lineHeight, metrics);
  return {
    ascender: baseline - metrics.asc * fontSize,
    cap: baseline - metrics.cap * fontSize,
    x: baseline - metrics.x * fontSize,
    baseline,
  };
}

/**
 * Measure a rendered single-line element (the h1, one [data-fit-line] span) in
 * viewport px. Its line-height must be unitless or px (never `normal`). Call it
 * after `document.fonts.ready`; it reads layout, so batch it with other reads.
 */
export function measureTypeLines(element: Element, metrics: FontMetrics = CLASH_METRICS): TypeLines | null {
  const rect = element.getBoundingClientRect();
  const style = getComputedStyle(element);
  const fontSize = parseFloat(style.fontSize);
  const lineHeightPx = parseFloat(style.lineHeight);
  if (!(fontSize > 0) || !(lineHeightPx > 0)) return null;
  return typeLines(rect.top, fontSize, lineHeightPx / fontSize, metrics);
}

/* ── Live metrics from the loaded face ──────────────────────────────────── */

/** First family of a computed `font-family` list, quotes kept (it goes back into a CSS font shorthand). */
export function primaryFamily(fontFamily: string): string {
  return (fontFamily.split(",")[0] ?? "").trim();
}

/** A measured em value is usable when it is finite and in a sane band for a Latin display face. */
function plausible(value: number, low: number, high: number): boolean {
  return Number.isFinite(value) && value >= low && value <= high;
}

/** Glyph boxes a canvas reports, in px at `size`. Split out so the arithmetic is testable without a canvas. */
export type GlyphProbe = {
  size: number;
  capAscent: number;
  xAscent: number;
  fontAscent: number;
  fontDescent: number;
};

/**
 * Pure: turn canvas glyph boxes into em metrics, keeping a fallback value for
 * anything the canvas could not report (older engines lack fontBoundingBox*).
 * The ascender is deliberately not re-measured: the plate crop is tuned
 * to the 0.691 token, and moving that line would move the portrait.
 */
export function metricsFromProbe(probe: GlyphProbe, fallback: FontMetrics = CLASH_METRICS): FontMetrics {
  const em = (px: number) => px / probe.size;
  const cap = em(probe.capAscent);
  const x = em(probe.xAscent);
  const ascent = em(probe.fontAscent);
  const descent = em(probe.fontDescent);
  return {
    cap: plausible(cap, 0.4, 1) ? cap : fallback.cap,
    x: plausible(x, 0.25, 0.9) ? x : fallback.x,
    asc: fallback.asc,
    ascent: plausible(ascent, 0.5, 1.5) ? ascent : fallback.ascent,
    descent: plausible(descent, 0, 0.8) ? descent : fallback.descent,
  };
}

const PROBE_PX = 200;
const metricsCache = new Map<string, FontMetrics>();

/**
 * Cap height, x-height, ascent and descent of the face `element` is set in,
 * read with canvas measureText ("H", "x", and the font's own bounding box).
 * Falls back to `fallback` until that face has loaded, so a fallback face is
 * never measured as if it were Clash. Cached per family and weight.
 */
export function readFontMetrics(element: Element, fallback: FontMetrics = CLASH_METRICS): FontMetrics {
  if (typeof document === "undefined") return fallback;
  const style = getComputedStyle(element);
  const family = primaryFamily(style.fontFamily);
  const font = `${style.fontWeight} ${PROBE_PX}px ${family}`;
  const cached = metricsCache.get(font);
  if (cached) return cached;
  if (!family || !document.fonts?.check(font)) return fallback;
  const context = document.createElement("canvas").getContext("2d");
  if (!context) return fallback;
  context.font = font;
  const cap = context.measureText("H");
  const metrics = metricsFromProbe(
    {
      size: PROBE_PX,
      capAscent: cap.actualBoundingBoxAscent,
      xAscent: context.measureText("x").actualBoundingBoxAscent,
      fontAscent: cap.fontBoundingBoxAscent,
      fontDescent: cap.fontBoundingBoxDescent,
    },
    fallback,
  );
  metricsCache.set(font, metrics);
  return metrics;
}

/* ── Which line of a fitted title carries the lines ─────────────────────── */

/**
 * The single-line element calibration should measure inside `target`: the
 * `line`-th (1-based) VISIBLE [data-fit-line] child, the last visible one by
 * default, or the target itself when it has no fit lines (the h1). A line hidden
 * with display:none has no client rects, which is how mobile-only and
 * desktop-only line breaks drop out.
 */
export function calibratedLine(target: HTMLElement, line?: number): HTMLElement {
  const visible = Array.from(target.querySelectorAll<HTMLElement>("[data-fit-line]")).filter(
    (candidate) => candidate.getClientRects().length > 0,
  );
  if (visible.length === 0) return target;
  const index = line === undefined ? visible.length - 1 : Math.min(Math.max(line - 1, 0), visible.length - 1);
  return visible[index] ?? target;
}

/** "CAP 0.670", "X 0.501": the label a calibration line prints for a metric. */
export function metricLabel(name: "cap" | "x", metrics: FontMetrics): string {
  return `${name === "cap" ? "Cap" : "X"} ${metrics[name].toFixed(3)}`;
}

src/styles/tokens.css

CSS · 349 lines · 12.9 KB

/*
 * Design tokens: the only place raw color values live.
 * Add tokens freely; renaming or removing one touches every component that reads it.
 *
 * Themes: dark ("night console") is the default on :root. [data-theme="light"] is
 * "clear sky", a daylight chart rather than an inversion. The <head> boot script
 * (src/lib/theme.ts) sets data-theme before first paint.
 *
 * Sanctioned copies outside this file (each pinned to it by a test):
 *   - src/lib/theme-colors.ts  (meta theme-color, which cannot read CSS)
 *   - src/lib/sky-tint.ts      (SVG duotone tables + OG cards, which cannot read CSS)
 */

/* ── 5.1 Color: dark (default) ─────────────────────────────────────────── */
:root {
  color-scheme: dark;

  /* raw */
  --void: #05070c;
  --void-2: #070a11;
  --well: #0a0f19;
  --panel: #03050a;
  --g50: #edf2fa;
  --g100: #d5dde9;
  --g200: #b9c4d6;
  --g300: #a9b4c7;
  --g400: #8f9bb2;
  --g500: #7d8ba6;
  --g600: #5b6884;
  --g700: #414c63;
  --g800: #283044;
  --g900: #151b28;
  --blue: #4d8dff;
  --ember: #e0913a;

  /* semantic */
  --bg: var(--void);
  --bg-2: var(--void-2);
  --bg-raised: var(--well);
  --bg-panel: var(--panel);
  --sky: var(--void); /* scene background (a gradient in clear sky) */
  --sky-scene: var(--sky); /* what each console scene paints (base.css); clear sky varies it by how the page scrolls */
  --fg: var(--g50);
  --fg-muted: var(--g300);
  --fg-subtle: var(--g500);
  --line: rgba(140, 170, 220, 0.11);
  --line-2: rgba(140, 170, 220, 0.22);
  --line-3: rgba(140, 170, 220, 0.44);
  --accent: var(--blue);
  --alert: var(--ember);
  --focus: var(--blue);
  --on-accent: #05070c;
  --paper: #c9d1df;
  --paper-ink: #05070c;
  --paper-ink-2: #2a3346;
  --paper-accent: #17459e;
  --name-ink: var(--fg);
  --name-ink-shirt: var(--fg);

  /* GL + canvas triplets: rgba(var(--accent-rgb), a) */
  --accent-rgb: 77, 141, 255;
  --alert-rgb: 224, 145, 58;
  --fg-rgb: 220, 232, 250;

  /* chips: every label drawn over the radar sits on one, so it keeps AA contrast over echoes */
  --chip: var(--bg);

  /* plate paper per sky; the light theme lifts them 30% toward --paper-lift */
  --paper-clear: #dbe7fb;
  --paper-partly: #d4dced;
  --paper-overcast: #c9d1df;
  --paper-fog: #d3d6dc;
  --paper-rain: #bfcbe0;
  --paper-snow: #e6ecf5;
  --paper-storm: #c6c4d6;
  --paper-hail: #dcc6aa;
  --paper-untinted: #cfd2d8;
  --paper-lift: #f4f8fe;
  --paper-lift-amount: 0%;
  --shirt: #05070c; /* below the image the plate continues in shirt color */

  /* night panels (terminal, code peek): syntax + ink */
  --night: var(--panel);
  --code-keyword: var(--accent);
  --code-string: #9fd3a8;
  --code-number: #b9a4ff;

  /* overlays */
  --storm-slate: #2a3344; /* the theme-switch storm's cloud bank */
  --flash: rgba(255, 255, 255, 0.1); /* the storm's one 60ms lightning frame */
  --edge-ink: #ffffff; /* beam-cut leading edge */
  --shadow-panel: 0 30px 80px rgba(0, 0, 0, 0.5); /* the command palette */
  /* console bar + sub-strip: an instrument surface, not glass. At 97% the page scrolling
     under it stays a faint movement and never reads as text behind the bar's own labels;
     no blur (it costs every scroll frame), the 1px bottom --line keeps the layering. */
  --veil: color-mix(in oklab, var(--bg) 97%, transparent);
}

/* ── 5.1 Color: clear sky ──────────────────────────────────────────────── */
[data-theme="light"] {
  color-scheme: light;

  --void: #e8f0fb;
  --void-2: #dfe9f8;
  --well: #f4f8fe;
  --panel: #06101f;
  --blue: #1a55c4;
  --ember: #934708;

  --bg: #e8f0fb;
  --bg-2: #dfe9f8;
  --bg-raised: #f4f8fe;
  --sky: linear-gradient(180deg, #cfe0f8 0%, #e8f0fb 46%, #f3f7fd 100%);
  /* Read in one native scroll (phones, narrow windows, reduced motion), every scene's sky
     starts and ends on --bg, so one scene runs into the next without a step from near
     white to blue. Desktop detents keep the full --sky (below). */
  --sky-scene: linear-gradient(180deg, #e8f0fb 0%, #dfe9f8 35%, #eef4fc 75%, #e8f0fb 100%);
  --fg: #06101f;
  --fg-muted: #2c3a52;
  --fg-subtle: #4a5873;
  --line: rgba(16, 52, 120, 0.12);
  --line-2: rgba(16, 52, 120, 0.24);
  --line-3: rgba(16, 52, 120, 0.46);
  --on-accent: #ffffff;
  --paper: #d9e2ef;
  --name-ink: #06101f;
  --name-ink-shirt: #edf2fa;
  --accent-rgb: 26, 85, 196;
  --alert-rgb: 160, 76, 8;
  --fg-rgb: 20, 52, 110;

  --paper-lift-amount: 30%;
  --edge-ink: var(--fg);
}

/* Desktop detents (canPin() in motion.ts; the pin's query in base.css): one scene fills
   the window at a time, so each shows the whole clear-sky gradient. */
@media (min-width: 900px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  :root[data-theme="light"]:not([data-still]) {
    --sky-scene: var(--sky);
  }
}

/* Night panels (terminal, code peek) stay dark in clear sky. Mark the panel root
   with [data-night]. Every token a panel's contents may use is re-declared here:
   custom properties that reference another var() (e.g. --focus: var(--blue)) are
   resolved on :root, so re-scoping --accent alone would not reach them.
   `color` is reset too, because body's computed color is inherited, not var(). */
[data-theme="light"] [data-night] {
  --fg: #edf2fa;
  --fg-muted: #b1bdd2;
  --fg-subtle: #8593ac;
  --accent: #6aa0ff;
  --alert: #f0a95a;
  --focus: #6aa0ff;
  --on-accent: #05070c;
  --bg: #06101f;
  --bg-2: #06101f;
  --bg-raised: #06101f;
  --chip: #06101f;
  --night: #06101f;
  --line: rgba(140, 170, 220, 0.12);
  --line-2: rgba(140, 170, 220, 0.24);
  --line-3: rgba(140, 170, 220, 0.44);
  --accent-rgb: 106, 160, 255;
  --alert-rgb: 240, 169, 90;
  --fg-rgb: 220, 232, 250;
  --code-keyword: #6aa0ff;
  color: var(--fg);
  background: var(--night);
}

/* ── 6 Type ─────────────────────────────────────────────────────────────── */
:root {
  /* families: next/font sets --font-clash / --font-satoshi / --font-jetbrains on <html>.
     Satoshi falls back to "Satoshi Fallback" (base.css), Arial tuned to Satoshi's width
     and vertical metrics, so a paragraph wraps the same before and after the swap.
     On the home page Satoshi joins once the page has loaded (<html data-sans>, set by
     OverlayHost on idle, or by the boot script before paint once the file is cached):
     no Satoshi text sits in the phone's first viewport, so its 42 KB never competes
     with the LCP. Every other route names it from the first paint (boot script). */
  --f-display: var(--font-clash, "Clash Display"), "Clash Fallback", Arial, sans-serif;
  --f-text-fallback: "Satoshi Fallback", Arial, system-ui, sans-serif;
  --f-text: var(--f-text-fallback);
  /*
   * Mono starts on the system's own 0.6em monospace, and JetBrains Mono joins once the
   * page has loaded (<html data-mono>, set by OverlayHost after load, on idle; the boot
   * script sets it before paint once the file is in this visitor's cache). Its file is
   * then never fetched ahead of the LCP image. Every face here advances 0.6em, like
   * JetBrains Mono, and every mono rule sets its own line-height, so the late swap
   * moves nothing (e2e/font-fallback.spec.ts holds CLS at 0 across it).
   */
  --f-mono-fallback: ui-monospace, "SF Mono", Menlo, "Liberation Mono", "Courier New", monospace;
  --f-mono: var(--f-mono-fallback);

  /* Clash Display 600, measured in Chrome 2026-11-06 + hhea from the shipped woff2 */
  --clash-cap: 0.67;
  --clash-x: 0.501;
  --clash-asc: 0.691; /* tallest ascender (the i dot) */
  --clash-ascent: 0.89; /* hhea ascent / unitsPerEm */
  --clash-descent: 0.25; /* hhea |descent| / unitsPerEm */
  --name-em: 5.154; /* "Alex Chicilo" at -0.045em, in em */

  /* scale (fitText refines --fs-name / --fs-title / --fs-mail at runtime, same value ±1px).
     --fs-name: the stage's content width (stage padding 12+12 mobile, 24+20 desktop) less
     --name-slack, over the name's advance width in em. 64.8px @390, 248.4px @1440.
     The h1 itself refines this from its container (type/measured-name.module.css). */
  --fs-name: calc((100vw - var(--m-l) - var(--m-r) - 24px - var(--name-slack)) / var(--name-em));
  --name-max: 17.5rem; /* 280px: the cap fitText honors too (NAME_MAX_PX) */
  /* The trailing "o" inks ~0.024em past its advance box (−0.045em tracking pulls the box in);
     the name's measure keeps this much room for it (NAME_SLACK_PX in type/name-fit.tsx). */
  --name-slack: 4px;
  --fs-title-max: 17vw; /* 03 Now title: fitText per line, capped here (190px desktop) */
  --fs-numeral: 39vw;
  --fs-h2: clamp(2.25rem, 4.6vw, 4.5rem);
  --fs-role: clamp(1.5rem, 2.2vw, 2.125rem);
  --fs-h3: clamp(1.375rem, 2vw, 1.875rem);
  --fs-readout: clamp(1.75rem, 3vw, 3rem);
  --fs-lede: clamp(1.0625rem, 1.2vw, 1.25rem);
  /* Phones (< 900px): body 16px and HUD mono 11px, the floors for reading at arm's
     length. The desktop steps are in the 900px block below. */
  --fs-body: max(16px, clamp(0.975rem, 1.05vw, 1.0625rem));
  --fs-hud: 11px;
  --fs-hud-min: 11px; /* the smallest HUD step (bar chips, strip cells, labels): 11px on phones, 10px from 900px */
  --fs-tty: 12.5px;
  --fs-code: 11.5px;

  /* tracking (one per type step; steps that share a value still get their own name) */
  --tr-name: -0.045em;
  --tr-title: -0.045em;
  --tr-mail: -0.045em;
  --tr-numeral: -0.075em;
  --tr-h2: -0.035em;
  --tr-readout: -0.035em;
  --tr-role: -0.02em;
  --tr-h3: -0.02em;
  --tr-hud: 0.08em; /* 0.12em from 900px; at 11px the line grows < 4% over 10px / 0.12em */
  --tr-hud-readout: 0.08em;

  /* leading */
  --lh-name: 0.86;
  --lh-title: 0.86;
  --lh-numeral: 0.78;
  --lh-mail: 0.9;
  --lh-h2: 0.9;
  --lh-readout: 0.9;
  --lh-role: 1.05;
  --lh-h3: 1.05;
  --lh-lede: 1.45;
  --lh-body: 1.5;
  --lh-hud: 1.4;
  --lh-tty: 1.6;
}

/* ── 5.3 Space, radii, z, motion ───────────────────────────────────────── */
:root {
  /* spacing: 4px base */
  --s-1: 4px;
  --s-2: 8px;
  --s-3: 12px;
  --s-4: 16px;
  --s-5: 24px;
  --s-6: 32px;
  --s-7: 48px;
  --s-8: 64px;
  --s-9: 96px;
  --m: clamp(14px, 1.667vw, 28px); /* frame margin: 24px at 1440, 14px at 390 */
  /* The side margins clear a notch or rounded corner (viewport-fit: cover, landscape phones). */
  --m-l: max(var(--m), env(safe-area-inset-left, 0px));
  --m-r: max(var(--m), env(safe-area-inset-right, 0px));
  --g: clamp(12px, 1.667vw, 24px); /* gutter */
  --bar: 52px;
  --sub: 26px; /* 0 at ≥900 */
  --rail: 0px; /* 64px at ≥900 */
  --cols: 6; /* 12 at ≥900 */
  --corner: 16px; /* registration L-marks */

  /* radii: none. The instrument is square; the only curves are radar arcs and dials. */
  --r: 0;

  /* z layers */
  --z-radar: 0;
  --z-plate: 1;
  --z-stage: 2;
  --z-cal: 3;
  --z-name-cut: 4;
  --z-frame: 5;
  --z-bar: 60;
  --z-palette: 80;
  --z-storm: 90;
  --z-cursor: 100;

  /* motion */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* reveals, magnetic release, underlines */
  --ease-detent: cubic-bezier(0.75, 0, 0.2, 1); /* mode changes, theme storm */
  --ease-sweep: cubic-bezier(0.65, 0, 0.35, 1); /* scan lines, wipes */
  --t-micro: 120ms;
  --t-ui: 240ms;
  --t-wipe: 480ms;
  --t-reveal: 900ms;
  --t-intro-turn: 1300ms;
  --t-storm: 1100ms;
  --t-scan-loop: 7500ms;
  --t-radar-rev: 6000ms;
  --t-dim: 200ms; /* leaving 01 dims its stage */
  --t-snap: 600ms; /* detent snap; scroll.ts reads --ease-detent for its curve */
  --pin-room: 120vh; /* the 02 replay pin's scroll length */
}

/* Desktop grid: breakpoint 900px (BREAKPOINT_DESKTOP in src/lib/motion.ts). */
@media (min-width: 900px) {
  :root {
    --sub: 0px;
    --rail: 64px;
    --cols: 12;
    --fs-name: min(var(--name-max), calc((100vw - var(--m-l) - var(--m-r) - var(--rail) - 44px - var(--name-slack)) / var(--name-em)));
    --fs-title-max: 190px;
    --fs-numeral: clamp(10rem, 23vw, 22rem);
    --fs-tty: 13.5px;
    --fs-body: max(15px, clamp(0.975rem, 1.05vw, 1.0625rem));
    --fs-hud: clamp(0.625rem, 0.72vw, 0.6875rem); /* 10–11px, never < 10px */
    --fs-hud-min: 10px;
    --tr-hud: 0.12em;
  }
}

/*
 * Touch tablets on the desktop grid (an iPad in landscape, 1024–1180px wide): the
 * desktop layout, read at arm's length like a phone. HUD mono keeps the phones' 11px
 * floor, at the phones' .08em tracking, so every HUD line stays as long as it is at
 * 10px / .12em and the bar, strips and labels keep their fit.
 */
@media (min-width: 900px) and (pointer: coarse) {
  :root {
    --fs-hud: 11px;
    --fs-hud-min: 11px;
    --tr-hud: 0.08em;
  }
}

/* JetBrains Mono, once the page has loaded (see --f-mono above). */
:root[data-mono] {
  --f-mono: var(--font-jetbrains, "JetBrains Mono"), var(--f-mono-fallback);
}

/* Satoshi, once the home page has loaded and from the first paint elsewhere (see --f-text above). */
:root[data-sans] {
  --f-text: var(--font-satoshi, "Satoshi"), var(--f-text-fallback);
}

src/lib/contracts.ts

TypeScript · 365 lines · 16.8 KB

/**
 * The shared contract for alexchicilo.com.
 *
 * Everything that crosses an ownership boundary is typed here: scene ids, the
 * data the page hands to scenes, the bar and the radar, the RadarApi, the
 * custom-event bus and the DOM data attributes. It changes deliberately and
 * all at once: every module that crosses a boundary reads its types from here.
 *
 * This module is safe on the server and the client: types, constants and tiny
 * helpers only, no React and no side effects at import.
 */
import type { HailSummary } from "@/lib/hail-days";
import type { LighthouseRecord } from "@/lib/lighthouse-record";
import type { DenverSky, Sky } from "@/lib/weather";

/* ── Scenes ──────────────────────────────────────────────────────────────── */

export type SceneId = "scan" | "observe" | "log" | "system" | "transmit";
export type SceneIndex = 1 | 2 | 3 | 4 | 5;

export type SceneMeta = {
  id: SceneId;
  index: SceneIndex;
  /** Mode name as the bar, rail and scene head print it. */
  name: string;
  /** id of the scene's heading; the <section> is aria-labelledby it. */
  headingId: string;
};

/** The five detents, in scroll order. `#<id>` is the section id and the jump target. */
export const SCENES = [
  { id: "scan", index: 1, name: "Scan", headingId: "name" },
  { id: "observe", index: 2, name: "Observe", headingId: "obs-h" },
  { id: "log", index: 3, name: "Log", headingId: "now-h" },
  { id: "system", index: 4, name: "System", headingId: "built-h" },
  { id: "transmit", index: 5, name: "Transmit", headingId: "transmit-h" },
] as const satisfies readonly SceneMeta[];

export const SCENE_IDS: readonly SceneId[] = SCENES.map((scene) => scene.id);

export function isSceneId(value: unknown): value is SceneId {
  return typeof value === "string" && (SCENE_IDS as readonly string[]).includes(value);
}

export function sceneMeta(id: SceneId): SceneMeta {
  const scene = SCENES.find((item) => item.id === id);
  if (!scene) throw new Error(`Unknown scene ${id}`);
  return scene;
}

/** "01", "02", … as printed in the rail, bar and scene heads. */
export function sceneNumber(index: SceneIndex): string {
  return String(index).padStart(2, "0");
}

/* ── Live data (server → scenes) ─────────────────────────────────────────── */

/**
 * Everything the home page loaded for this ISR render. A null source failed:
 * its readings go dark and are removed, never replaced by a guess.
 */
export type LiveData = {
  /** IEM / NWS local storm reports for Colorado this year. Place strings are UNGUARDED: run them through `safePlace` (src/lib/safe-place.ts) before rendering. */
  hail: HailSummary | null;
  /** Denver now, from Open-Meteo. */
  weather: DenverSky | null;
  /** public/lighthouse.json, written by a real run. */
  lighthouse: LighthouseRecord | null;
  /** Denver calendar year of this render. */
  year: number;
  /** Denver calendar date of this render, compact YYYYMMDD (the playhead's "today"). */
  today: string;
  /** Day of year of `today`, Jan 01 = 0 (same scale as radar `a_day`). */
  todayDoy: number;
  /** ISO timestamp of this render. */
  renderedAt: string;
};

/** Props every scene receives from src/app/page.tsx. Scenes are Server Components. */
export type SceneProps = { live: LiveData };
export type ScanSceneProps = SceneProps;
export type ObserveSceneProps = SceneProps;
export type LogSceneProps = SceneProps;
export type SystemSceneProps = SceneProps;
export type TransmitSceneProps = SceneProps;

/** The bar's live strip and the mobile sub-strip. Serializable (it crosses into client islands). */
export type LiveStripData = {
  weather: DenverSky | null;
  /** Unique Colorado hail days this year, or null when IEM failed. */
  hailDays: number | null;
  year: number;
};

export type ConsoleBarProps = { strip: LiveStripData };
export type SubStripProps = { strip: LiveStripData };

export type SkyId = Sky["id"];

/* ── Radar ───────────────────────────────────────────────────────────────── */

/*
 * The radar's data shape (EchoBuffer, ECHO_STRIDE, RadarLargest, RadarCanvasProps)
 * is the radar's own: src/components/radar/radar-data.ts (types, client-safe) and
 * radar-props.ts (server shaping). page.tsx passes toRadarProps(live) through.
 */

/** Viewport CSS pixels (clientX / clientY space). */
export type ScreenPoint = { x: number; y: number };
export type LatLon = { lat: number; lon: number };
/** Viewport CSS pixels, as from getBoundingClientRect(). */
export type KnockoutRect = { x: number; y: number; width: number; height: number };

/** The two named camera framings: SCAN (01, the whole state) and FIELD (02). FIELD is desktop only. */
export type Framing = "scan" | "field";

export const MAX_KNOCKOUTS = 4;
export const KNOCKOUT_FEATHER_PX = 14;
export const FLARE_MS = 300;
export const STORM_MS = 4000;
/** Bearing of every still frame (reduced motion, automation, OG renders). */
export const STILL_BEARING_DEG = 115;

/**
 * Imperative radar handle. Implemented by the radar (radar-controller.ts via
 * radar-canvas.tsx), registered with `provideRadar`, consumed through
 * `whenRadar` / `getRadar`. Every member is cheap (a uniform write or a lookup,
 * never layout) and safe to call at any rate. After teardown the old handle's
 * methods are no-ops, so a stale reference never throws.
 *
 * One writer per input, so no two modules fight over a uniform:
 *   setFraming → src/lib/scroll.ts             setDay, flare → the season tape (incl. the replay)
 *   hail       → src/lib/eggs.ts               latLon        → the cursor probe; anyone may read
 *   denScreen  → read-only, anyone             knockouts     → the radar only (collected from [data-knockout])
 * Everything else the radar reacts to arrives on the bus (acx:theme re-bake,
 * acx:calibration test pattern) or is read by the radar itself (pointer, scroll velocity
 * from window.scrollY per frame — works under Lenis too, document.hidden).
 */
export interface RadarApi {
  /**
   * Camera mix between the SCAN (0) and FIELD (1) framings. Callers pass raw
   * scroll progress (01's last 30% → 02's top); the radar applies
   * --ease-detent and lerps uOrigin/uScale. Clamped to [0, 1]. Desktop only:
   * a no-op on mobile, where the canvas lives in 01.
   */
  setFraming(mix: number): void;
  /**
   * Playhead (uDay): day of year, Jan 01 = 0, the same scale as LiveData.todayDoy.
   * May be fractional while scrubbing; echoes with a_day > day are hidden.
   * Clamped to [0, todayDoy]. The radar starts at todayDoy until someone calls it.
   */
  setDay(day: number): void;
  /** Flare the echoes reported on integer `day`: uFlareDay = day, uFlare 1 → 0 over FLARE_MS. */
  flare(day: number): void;
  /** Hail storm on the particle field: uStorm 1 → 0 over durationMs (default STORM_MS). Fall ×6, density ×3, ember. STILL: one static ember frame. */
  hail(durationMs?: number): void;
  /** Geographic position under a viewport point, or null when the point is off the drawn state or the radar is not on screen. */
  latLon(point: ScreenPoint): LatLon | null;
  /** Denver's viewport position under the current camera, or null while the radar is not drawn. */
  denScreen(): ScreenPoint | null;
  /**
   * Copy rects the radar fades behind (uKnock, 14px feather); [] clears, at most
   * MAX_KNOCKOUTS are used. The radar calls this itself with the visible [data-knockout]
   * rects (on resize, scroll end and font load). Everything else marks elements with
   * [data-knockout] instead of calling it.
   */
  knockouts(rects: readonly KnockoutRect[]): void;
}

let radarApi: RadarApi | null = null;
const radarWaiters = new Set<(api: RadarApi) => void>();

/** The radar only: publish (or withdraw, with null) the live radar. */
export function provideRadar(api: RadarApi | null): void {
  radarApi = api;
  if (!api) return;
  const waiters = [...radarWaiters];
  radarWaiters.clear();
  for (const waiter of waiters) {
    try {
      waiter(api);
    } catch (error) {
      // One broken consumer must not starve the others.
      console.error(error);
    }
  }
}

/** The radar if it is mounted. It is lazy: most callers want `whenRadar`. */
export function getRadar(): RadarApi | null {
  return radarApi;
}

/** Run `callback` once the radar exists (immediately if it already does). Returns a cancel function. */
export function whenRadar(callback: (api: RadarApi) => void): () => void {
  if (radarApi) {
    callback(radarApi);
    return () => {};
  }
  radarWaiters.add(callback);
  return () => {
    radarWaiters.delete(callback);
  };
}

/* ── Theme, sound ────────────────────────────────────────────────────────── */

export type Theme = "dark" | "light";

/** Every UI sound. Played through the bus so nobody imports the audio module. */
export type SoundCue = "hover" | "press" | "detent" | "copy" | "ping" | "hail";

export const SOUND_STORAGE_KEY = "ac-sound";

/* ── Event bus ───────────────────────────────────────────────────────────── */

/**
 * Window CustomEvents, one name per message. "Request" events ask the owner to
 * act; state events announce what already happened. Detail objects are plain data.
 */
export type BusEventMap = {
  /** State: the active detent changed. Emitted by the mode tracker (src/lib/scroll.ts) and mirrored to <html data-mode>. */
  "acx:mode": { scene: SceneId; index: SceneIndex };
  /** Request: bring a scene to the top (palette, rail, terminal). Handled by OverlayHost (native scroll), or by src/lib/scroll.ts while Lenis runs (<html data-smooth>). */
  "acx:jump": { scene: SceneId };
  /**
   * State: the painted theme changed (after the swap). The radar re-bakes. A system
   * change is dispatched by the inline boot script, which cannot import `emit`, so
   * `listen(…, { current: true })` replays toggles only; read `currentTheme()` for the
   * painted theme instead.
   */
  "acx:theme": { theme: Theme; source: "toggle" | "system" };
  /** Request: run the storm transition and flip the theme. `origin` is where the cloud grows from (viewport px). */
  "acx:theme-toggle": { origin: ScreenPoint | null };
  /** State: sound turned on or off (persisted in SOUND_STORAGE_KEY). Mirrored to <html data-sound>. */
  "acx:sound": { on: boolean };
  /** Request: flip sound, or set it when `on` is given. */
  "acx:sound-toggle": { on?: boolean };
  /** Request: play a cue. Silent unless sound is on. */
  "acx:sfx": { cue: SoundCue };
  /** State: the "hail" egg fired; storm for durationMs. <html data-storm> is set for its duration. */
  "acx:hailstorm": { durationMs: number };
  /** State: Calibration mode on/off (Konami toggles, Esc exits). Mirrored to <html data-calibration>. */
  "acx:calibration": { on: boolean };
  /** Request: open or close the command palette. */
  "acx:palette": { open: boolean };
  /** State: radar beam bearing in degrees (0 = north, clockwise), at most once per second. */
  "acx:bearing": { deg: number };
  /**
   * State: 02 replay progress from the desktop pin, 0 = Jan 01 … 1 = today
   * (src/lib/scroll.ts). Above 01's last 30% it rests at 1 (01 shows the season so
   * far); from there it follows the pin: 0 until 02 settles, rising to 1 across
   * the pin, 1 after it. Only sent while the pin exists (desktop, fine pointer,
   * motion allowed, window ≥ 768px tall): no event means nothing drives the replay.
   */
  "acx:replay": { progress: number };
};

export type BusEvent = keyof BusEventMap;

/** The last detail emitted per event, for `listen(…, { current: true })`. Client only. */
const lastDetail = new Map<BusEvent, unknown>();

export function emit<K extends BusEvent>(type: K, detail: BusEventMap[K]): void {
  if (typeof window === "undefined") return;
  lastDetail.set(type, detail);
  window.dispatchEvent(new CustomEvent(type, { detail }));
}

export type ListenOptions = {
  /**
   * Also deliver the most recent detail of this event, if one was emitted before
   * you subscribed (in a microtask, never inside the listen call). For state
   * events read by lazy code, e.g. a chunk that loads after the 02 pin already
   * reported its "acx:replay" progress.
   */
  current?: boolean;
};

/** Subscribe to one bus event. Returns the unsubscribe function (use it as an effect cleanup). */
export function listen<K extends BusEvent>(
  type: K,
  handler: (detail: BusEventMap[K]) => void,
  options: ListenOptions = {},
): () => void {
  if (typeof window === "undefined") return () => {};
  let active = true;
  const wrapped = (event: Event) => {
    handler((event as CustomEvent<BusEventMap[K]>).detail);
  };
  window.addEventListener(type, wrapped);
  if (options.current && lastDetail.has(type)) {
    const detail = lastDetail.get(type) as BusEventMap[K];
    queueMicrotask(() => {
      if (active) handler(detail);
    });
  }
  return () => {
    active = false;
    window.removeEventListener(type, wrapped);
  };
}

/* ── DOM data attributes ─────────────────────────────────────────────────── */

/**
 * Cursor state for an element and its subtree. Links and buttons default
 * to "link", inputs and textareas to "text"; set data-cursor to override.
 */
export type CursorState = "default" | "probe" | "link" | "text" | "plate" | "scrub" | "hidden";

/**
 * Attributes that wire components together without shared files.
 * Elements: read by the named owner. Root (<html>): written by the named owner only.
 */
export const DATA_ATTR = {
  /** Element. CursorState. Read by the cursor (cursor.tsx). */
  cursor: "data-cursor",
  /** Element. Text the cursor prints next to itself in that state, e.g. "Scan" on the plate, "Nov 06" on the tape (the tape updates it while scrubbing). Read by the cursor (cursor.tsx). */
  cursorLabel: "data-cursor-label",
  /** Element, boolean. Magnetic pull ±12px (magnetic.ts). CTAs, Copy, LinkedIn, ⌘K, View source. */
  magnetic: "data-magnetic",
  /** Element, boolean. The radar fades behind its rect (the radar reads it; max 4 visible). */
  knockout: "data-knockout",
  /** Element, boolean. Radar labels hide when they would intersect it (the radar reads it). */
  avoid: "data-avoid",
  /**
   * Element. "scan" | "field": an empty aria-hidden layout cell marking where a
   * framing's state sits (the scan scene in 01, the observe scene in 02). A layout marker only: the radar
   * derives its zone from the [data-avoid] / [data-knockout] rects around it.
   */
  radarZone: "data-radar-zone",
  /** Element, boolean. A night panel that stays dark in clear sky (tokens.css). */
  night: "data-night",
  /** Element, boolean. A fitText line (src/lib/fit-text.ts). */
  fitLine: "data-fit-line",
  /** Section: SceneId (rendered by SceneFrame). Root: the active SceneId (mode tracker, scroll.ts). */
  mode: "data-mode",
  /** Section, boolean. Scene is taller than the viewport: read freely, snapped only at its top and end (mode tracker, scroll.ts). */
  tall: "data-tall",
  /** Root. "dark" | "light" (theme boot script + applyTheme, theme.ts). */
  theme: "data-theme",
  /** Root, boolean. STILL = reduced motion || navigator.webdriver (theme boot script). */
  still: "data-still",
  /** Root, boolean. Lenis is driving scroll (scroll.ts). */
  smooth: "data-smooth",
  /** Root. "mac" | "pc": which shortcut glyph the ⌘K controls print (theme boot script). */
  kbd: "data-kbd",
  /** Root, boolean. --f-mono names JetBrains Mono: set after load, on idle (overlay-host.tsx), or before paint once the file is cached (theme boot script). */
  mono: "data-mono",
  /** Root, boolean. --f-text names Satoshi: from the first paint off the home page; on it, after load, on idle (overlay-host.tsx), or before paint once the file is cached (theme boot script). */
  sans: "data-sans",
  /** Root, boolean. The weather reading is older than SKY_STALE_AFTER_MS on the visitor's clock: "now" wording hides, "as of" shows (data-age-root.tsx). */
  skyStale: "data-sky-stale",
  /** Root, boolean. The page's Denver date is not the visitor's: "Today" hides (data-age-root.tsx). */
  dayStale: "data-day-stale",
  /** Root, boolean. Calibration mode is on (eggs.ts / calibration-mode.tsx). */
  calibration: "data-calibration",
  /** Root, boolean. The hail storm is running (eggs.ts). */
  storm: "data-storm",
  /** Root. "on" | "off" (sound.ts). */
  sound: "data-sound",
  /** Root, boolean. The custom cursor is showing: CSS hides the native cursor under it (cursor.tsx). Removed on keyboard use. */
  customCursor: "data-custom-cursor",
} as const;

src/components/system/highlight.ts

TypeScript · 438 lines · 15.7 KB

/**
 * Build-time syntax highlighting for the 04 peek and /source.
 *
 * A small hand-written lexer instead of a dependency (no new deps, and no
 * highlighter in client JS: only Server Components import this). It knows
 * three languages: TypeScript/TSX, GLSL (inside `/* glsl *\/` template
 * literals, the way every shader module here is written) and CSS.
 *
 * It is a lexer, not a parser: it colours tokens, it never needs to understand
 * the program. The one context-sensitive call is `/`, which starts a regular
 * expression only where an expression can begin (after an operator, an opening
 * bracket or a keyword like `return`), the same heuristic editors use.
 */

export type TokenKind = "plain" | "keyword" | "string" | "number" | "comment" | "function" | "type" | "property" | "punct";
export type Token = { kind: TokenKind; text: string };
export type Language = "ts" | "glsl" | "css";

const TS_KEYWORDS = new Set(
  (
    "abstract as async await break case catch class const continue declare default delete do else enum export extends " +
    "false finally for from function get if implements import in infer instanceof interface keyof let namespace never new " +
    "null of private protected public readonly return satisfies set static super switch this throw true try type typeof " +
    "undefined unique unknown var void while yield"
  ).split(" "),
);

const GLSL_KEYWORDS = new Set(
  (
    "attribute bool break bvec2 bvec3 bvec4 centroid const continue discard do else false flat float for highp if in " +
    "inout int invariant isampler2D ivec2 ivec3 ivec4 layout lowp mat2 mat3 mat4 mediump out precision return sampler2D " +
    "sampler3D smooth struct true uint uniform usampler2D uvec2 uvec3 uvec4 varying vec2 vec3 vec4 void while"
  ).split(" "),
);

/** After these tokens an expression may begin, so `/` opens a regex rather than dividing. */
const REGEX_AFTER_PUNCT = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^"]);
const REGEX_AFTER_WORD = new Set(["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "yield", "await"]);

const IDENT = /[A-Za-z_$][\w$]*/y;
const NUMBER = /(?:0[xX][\da-fA-F_]+|0[bB][01_]+|(?:\d[\d_]*)?\.?\d[\d_]*(?:[eE][+-]?\d+)?n?)(?![\w$])/y;
const SPACE = /\s+/y;

function sticky(pattern: RegExp, source: string, at: number): string | null {
  pattern.lastIndex = at;
  const match = pattern.exec(source);
  return match ? match[0] : null;
}

/** Collects tokens, merging neighbours of the same kind so the HTML stays small. */
class TokenList {
  readonly tokens: Token[] = [];
  push(kind: TokenKind, text: string): void {
    if (text === "") return;
    const last = this.tokens.at(-1);
    if (last && last.kind === kind) last.text += text;
    else this.tokens.push({ kind, text });
  }
  /** The last token that is not whitespace or a comment. */
  significant(): Token | undefined {
    for (let index = this.tokens.length - 1; index >= 0; index -= 1) {
      const token = this.tokens[index];
      if (token && token.kind !== "comment" && token.text.trim() !== "") return token;
    }
    return undefined;
  }
}

function nextNonSpace(source: string, from: number): string {
  for (let index = from; index < source.length; index += 1) {
    const char = source[index] ?? "";
    if (!/\s/.test(char)) return char;
  }
  return "";
}

function scanQuoted(source: string, start: number, quote: string): number {
  let index = start + 1;
  while (index < source.length) {
    const char = source[index];
    if (char === "\\") index += 2;
    else if (char === quote || char === "\n") return index + 1;
    else index += 1;
  }
  return source.length;
}

function scanRegex(source: string, start: number): number {
  let index = start + 1;
  let inClass = false;
  while (index < source.length) {
    const char = source[index];
    if (char === "\n") return index;
    if (char === "\\") index += 2;
    else {
      if (char === "[") inClass = true;
      else if (char === "]") inClass = false;
      else if (char === "/" && !inClass) {
        index += 1;
        while (/[a-z]/.test(source[index] ?? "")) index += 1;
        return index;
      }
      index += 1;
    }
  }
  return source.length;
}

function regexAllowed(previous: Token | undefined): boolean {
  if (!previous) return true;
  const last = previous.text.trimEnd();
  if (previous.kind === "keyword") return REGEX_AFTER_WORD.has(last);
  if (previous.kind === "punct") return REGEX_AFTER_PUNCT.has(last.at(-1) ?? "");
  return false;
}

/** Identifier → keyword, function (followed by `(`), type (PascalCase) or plain. */
function classifyWord(word: string, keywords: Set<string>, afterDot: boolean, next: string): TokenKind {
  // Member names are never keywords: `map.get(…)`, `style.set…`.
  if (!afterDot && keywords.has(word)) return "keyword";
  if (next === "(") return "function";
  if (/^[A-Z]/.test(word) && /[a-z]/.test(word)) return "type";
  return "plain";
}

/**
 * Lex code (TS or GLSL) from `start` to the end, or, with `untilBrace`, to the
 * `}` that closes a `${…}` hole in a template literal (not consumed). Returns
 * the index reached.
 */
function lexCode(source: string, start: number, out: TokenList, language: "ts" | "glsl", untilBrace = false): number {
  const keywords = language === "glsl" ? GLSL_KEYWORDS : TS_KEYWORDS;
  let depth = 0;
  let index = start;
  let glslNext = false; // set by a `/* glsl */` marker: the next template literal holds a shader
  while (index < source.length) {
    const char = source[index] ?? "";
    const pair = source.slice(index, index + 2);

    const space = sticky(SPACE, source, index);
    if (space) {
      out.push("plain", space);
      index += space.length;
      continue;
    }
    if (pair === "//") {
      const end = source.indexOf("\n", index);
      const stop = end < 0 ? source.length : end;
      out.push("comment", source.slice(index, stop));
      index = stop;
      continue;
    }
    if (pair === "/*") {
      const end = source.indexOf("*/", index + 2);
      const stop = end < 0 ? source.length : end + 2;
      const comment = source.slice(index, stop);
      out.push("comment", comment);
      glslNext = language === "ts" && /^\/\*\s*glsl\s*\*\/$/.test(comment);
      index = stop;
      continue;
    }
    if (language === "glsl" && char === "#") {
      // Preprocessor line (#version 300 es, #define …).
      const end = source.indexOf("\n", index);
      const stop = end < 0 ? source.length : end;
      out.push("keyword", source.slice(index, stop));
      index = stop;
      continue;
    }
    if (language === "ts" && (char === '"' || char === "'")) {
      const stop = scanQuoted(source, index, char);
      out.push("string", source.slice(index, stop));
      index = stop;
      continue;
    }
    if (language === "ts" && char === "`") {
      index = lexTemplate(source, index, out, glslNext);
      glslNext = false;
      continue;
    }
    if (language === "ts" && char === "/" && regexAllowed(out.significant())) {
      const stop = scanRegex(source, index);
      out.push("string", source.slice(index, stop));
      index = stop;
      continue;
    }
    const number = /\d/.test(char) || (char === "." && /\d/.test(source[index + 1] ?? "")) ? sticky(NUMBER, source, index) : null;
    if (number) {
      out.push("number", number);
      index += number.length;
      continue;
    }
    const word = sticky(IDENT, source, index);
    if (word) {
      const previous = out.significant();
      const afterDot = previous?.kind === "punct" && previous.text.trimEnd().endsWith(".");
      out.push(classifyWord(word, keywords, afterDot, nextNonSpace(source, index + word.length)), word);
      index += word.length;
      continue;
    }
    if (untilBrace) {
      if (char === "{") depth += 1;
      if (char === "}") {
        if (depth === 0) return index;
        depth -= 1;
      }
    }
    out.push("punct", char);
    index += 1;
  }
  return index;
}

/** A template literal from its opening backtick; `${…}` holes are lexed as TypeScript. */
function lexTemplate(source: string, start: number, out: TokenList, glsl: boolean): number {
  out.push("string", "`");
  let index = start + 1;
  let chunkStart = index;
  const flush = (end: number) => {
    const chunk = source.slice(chunkStart, end);
    if (glsl) lexCode(chunk, 0, out, "glsl");
    else out.push("string", chunk);
  };
  while (index < source.length) {
    const char = source[index];
    if (char === "\\") {
      index += 2;
      continue;
    }
    if (char === "`") {
      flush(index);
      out.push("string", "`");
      return index + 1;
    }
    if (char === "$" && source[index + 1] === "{") {
      flush(index);
      out.push("punct", "${");
      index = lexCode(source, index + 2, out, "ts", true);
      out.push("punct", "}");
      index += 1;
      chunkStart = index;
      continue;
    }
    index += 1;
  }
  flush(source.length);
  return source.length;
}

function lexCss(source: string, out: TokenList): void {
  let index = 0;
  let depth = 0;
  while (index < source.length) {
    const char = source[index] ?? "";
    const space = sticky(SPACE, source, index);
    if (space) {
      out.push("plain", space);
      index += space.length;
      continue;
    }
    if (source.startsWith("/*", index)) {
      const end = source.indexOf("*/", index + 2);
      const stop = end < 0 ? source.length : end + 2;
      out.push("comment", source.slice(index, stop));
      index = stop;
      continue;
    }
    if (char === '"' || char === "'") {
      const stop = scanQuoted(source, index, char);
      out.push("string", source.slice(index, stop));
      index = stop;
      continue;
    }
    const at = /@[\w-]+/y;
    const atRule = char === "@" ? sticky(at, source, index) : null;
    if (atRule) {
      out.push("keyword", atRule);
      index += atRule.length;
      continue;
    }
    const custom = char === "-" ? sticky(/--[\w-]+/y, source, index) : null;
    if (custom) {
      out.push("property", custom);
      index += custom.length;
      continue;
    }
    // A hex colour inside a declaration (in a selector, `#id` stays plain).
    const hex = char === "#" && depth > 0 ? sticky(/#[\da-fA-F]{3,8}(?![\w-])/y, source, index) : null;
    if (hex) {
      out.push("number", hex);
      index += hex.length;
      continue;
    }
    const number = /[\d.]/.test(char) ? sticky(/(?:\d+\.?\d*|\.\d+)(?:%|[a-z]+)?/y, source, index) : null;
    if (number) {
      out.push("number", number);
      index += number.length;
      continue;
    }
    const word = sticky(/-?[A-Za-z_][\w-]*/y, source, index);
    if (word) {
      const next = nextNonSpace(source, index + word.length);
      const kind: TokenKind = next === "(" ? "function" : depth > 0 && next === ":" ? "property" : "plain";
      out.push(kind, word);
      index += word.length;
      continue;
    }
    if (char === "{") depth += 1;
    if (char === "}") depth = Math.max(0, depth - 1);
    out.push("punct", char);
    index += 1;
  }
}

export function languageOf(path: string): Language {
  return path.endsWith(".css") ? "css" : path.endsWith(".glsl") ? "glsl" : "ts";
}

export function tokenize(source: string, language: Language): Token[] {
  const out = new TokenList();
  if (language === "css") lexCss(source, out);
  else lexCode(source, 0, out, language);
  return out.tokens;
}

/**
 * Escapes text for element content (never an attribute value): `&`, `<` and
 * `>`. Quotes need no escape there, and code is full of them; each `&quot;`
 * cost five bytes more in the HTML and four more in the RSC payload.
 */
export function escapeHtml(value: string): string {
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

/** Class per token kind; a kind with no class (and `plain` text) is left unwrapped. */
export type TokenClasses = Partial<Record<Exclude<TokenKind, "plain">, string>>;

/**
 * Highlighted HTML for a <code> inside a <pre>. Spans run across line breaks:
 * a JSDoc block or a run of `//` lines is one comment span however many lines
 * it covers, since the <pre> keeps the newlines. Line numbers live in a gutter
 * beside the code (src/app/source/page.tsx), never in it, so copying the block
 * gives back the exact source. The input is our own source read at build time,
 * and it is escaped anyway.
 */
export function highlightToHtml(source: string, language: Language, classes: TokenClasses): string {
  const tokens = tokenize(source.replace(/\r\n?/g, "\n").replace(/\n$/, ""), language);
  return classRuns(tokens, classes)
    .map(({ className, text }) => {
      const escaped = escapeHtml(text);
      if (!className) return escaped;
      // A one-word class needs no quotes (two bytes a span in the HTML, four in the RSC payload).
      const value = /^[\w-]+$/.test(className) ? className : `"${escapeHtml(className).replace(/"/g, "&quot;")}"`;
      return `<span class=${value}>${escaped}</span>`;
    })
    .join("");
}

type Run = { className: string | undefined; text: string };

/**
 * Tokens as runs of one class each: neighbours that share a class share a span,
 * and whitespace between two of them, newlines included, joins it (spaces have
 * no colour). `});` is one span, not three, and so is a ten-line comment block:
 * same text, same colours, fewer spans (every span ships twice on /source, in
 * the HTML and again in the RSC payload).
 */
function classRuns(tokens: readonly Token[], classes: TokenClasses): Run[] {
  const runs: Run[] = [];
  let gap = "";
  for (const token of tokens) {
    if (token.text.trim() === "") {
      gap += token.text;
      continue;
    }
    const className = token.kind === "plain" ? undefined : classes[token.kind];
    const last = runs.at(-1);
    if (last && last.className === className) {
      last.text += gap + token.text;
    } else {
      if (gap) {
        if (last && last.className === undefined) last.text += gap;
        else runs.push({ className: undefined, text: gap });
      }
      runs.push({ className, text: token.text });
    }
    gap = "";
  }
  if (gap) {
    const last = runs.at(-1);
    if (last && last.className === undefined) last.text += gap;
    else runs.push({ className: undefined, text: gap });
  }
  return runs;
}

/**
 * The source of the first `for` loop inside `function <name>`, dedented, from
 * its line start to its closing brace. Braces are counted on tokens, so a `{8}`
 * inside a regex or a brace in a string can't end the block early. Null when
 * the function or loop is gone, so the caller can drop the peek, not fake it.
 */
export function extractFirstLoop(source: string, functionName: string): string | null {
  const start = source.search(new RegExp(`function\\s+${functionName}\\s*\\(`));
  if (start < 0) return null;
  const tokens = tokenize(source.slice(start), "ts");
  let offset = start;
  let loopStart = -1;
  let depth = 0;
  let opened = false;
  for (const token of tokens) {
    if (loopStart < 0 && token.kind === "keyword" && token.text === "for") loopStart = offset;
    if (loopStart >= 0 && token.kind === "punct") {
      for (let index = 0; index < token.text.length; index += 1) {
        const char = token.text[index];
        if (char === "{") {
          depth += 1;
          opened = true;
        } else if (char === "}") {
          depth -= 1;
          if (opened && depth === 0) {
            const lineStart = source.lastIndexOf("\n", loopStart) + 1;
            return dedent(source.slice(lineStart, offset + index + 1));
          }
        }
      }
    }
    offset += token.text.length;
  }
  return null;
}

export function dedent(block: string): string {
  const lines = block.split("\n");
  const indents = lines.filter((line) => line.trim() !== "").map((line) => /^[ \t]*/.exec(line)?.[0].length ?? 0);
  const cut = indents.length > 0 ? Math.min(...indents) : 0;
  return lines.map((line) => line.slice(cut)).join("\n");
}

src/lib/source-allowlist.ts

TypeScript · 79 lines · 3.8 KB

/**
 * What /source may show. Server only.
 *
 * An allowlist, checked against a denylist that wins even if a path is
 * allowlisted by mistake: the content guard, design notes, tests, env files,
 * the deploy link folder and font files are never rendered. The page also runs
 * the content guard on every file it reads and skips any hit, and
 * source-allowlist.test.ts proves every entry exists, passes the denylist and
 * is clean, so a bad entry fails CI before it can ship.
 */

export const SOURCE_GROUPS = ["Shaders", "Radar", "Data", "Terminal", "Interaction", "Type", "Tokens", "Contracts", "This page"] as const;
export type SourceGroup = (typeof SOURCE_GROUPS)[number];

export type SourceEntry = { path: string; group: SourceGroup };

/** Repo-relative paths shown on /source, in reading order. */
export const SOURCE_ALLOWLIST: readonly SourceEntry[] = [
  { path: "src/shaders/radar.frag.glsl.ts", group: "Shaders" },
  { path: "src/shaders/fullscreen.vert.glsl.ts", group: "Shaders" },
  { path: "src/shaders/points.vert.glsl.ts", group: "Shaders" },
  { path: "src/shaders/points.frag.glsl.ts", group: "Shaders" },
  { path: "src/shaders/particles.vert.glsl.ts", group: "Shaders" },
  { path: "src/shaders/knockout.frag.glsl.ts", group: "Shaders" },
  { path: "src/components/radar/radar-controller.ts", group: "Radar" },
  { path: "src/components/radar/radar-gl.ts", group: "Radar" },
  { path: "src/components/radar/radar-camera.ts", group: "Radar" },
  { path: "src/components/radar/radar-ping.ts", group: "Radar" },
  { path: "src/lib/beam-cut.ts", group: "Radar" },
  { path: "src/lib/hail-days.ts", group: "Data" },
  { path: "src/lib/weather.ts", group: "Data" },
  { path: "src/lib/live-data.ts", group: "Data" },
  { path: "src/components/system/terminal-commands.ts", group: "Terminal" },
  { path: "src/components/system/terminal.tsx", group: "Terminal" },
  { path: "src/components/system/terminal-hooks.ts", group: "Terminal" },
  { path: "src/lib/eggs.ts", group: "Interaction" },
  { path: "src/lib/sound.ts", group: "Interaction" },
  { path: "src/lib/magnetic.ts", group: "Interaction" },
  { path: "src/lib/fit-text.ts", group: "Type" },
  { path: "src/lib/type-metrics.ts", group: "Type" },
  { path: "src/styles/tokens.css", group: "Tokens" },
  { path: "src/lib/contracts.ts", group: "Contracts" },
  { path: "src/components/system/highlight.ts", group: "This page" },
  { path: "src/lib/source-allowlist.ts", group: "This page" },
];

/** Never shown, whatever the allowlist says. Matched against the normalized repo-relative path. */
export const SOURCE_DENYLIST: readonly RegExp[] = [
  /^src\/lib\/guard\.ts$/i,
  /^design\//i,
  /(^|\/)\.env/i,
  /(^|\/)\.vercel(\/|$)/i,
  /(^|\/)node_modules\//i,
  /\.(woff2?|ttf|otf|eot)$/i,
  /\.(test|spec)\.[cm]?[jt]sx?$/i,
  /(^|\/)e2e\//i,
];

/** Repo-relative form: forward slashes, no "./" or empty segments. Null for absolute paths and anything that climbs out with "..". */
export function normalizeSourcePath(path: string): string | null {
  const parts = path.replace(/\\/g, "/").split("/").filter((part) => part !== "" && part !== ".");
  if (path.startsWith("/") || /^[a-z]:/i.test(path) || parts.includes("..")) return null;
  return parts.join("/");
}

export function isSourceDenied(path: string): boolean {
  const normalized = normalizeSourcePath(path);
  return normalized === null || SOURCE_DENYLIST.some((pattern) => pattern.test(normalized));
}

export function isSourceAllowed(path: string): boolean {
  const normalized = normalizeSourcePath(path);
  return normalized !== null && SOURCE_ALLOWLIST.some((entry) => entry.path === normalized) && !isSourceDenied(normalized);
}

/** Stable anchor id for a file on /source: "src/lib/hail-days.ts" → "src-lib-hail-days-ts". */
export function sourceAnchor(path: string): string {
  return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}