# Street light

> A line-drawn street with an LED street light: change its height, tilt and optic and watch where the light lands.

A technical figure of a terraced street at night. Pick a narrow, wide or asymmetric optic, drag the pole’s height and the head’s tilt, and the light on the road, the gardens, the houses and the sky is computed as you go, with the BUG rating, road lux and the lux on a bedroom window. A lux map turns the light into false colour with isolux lines.

A React component by Bruno Delorence: https://brunodelorence.com/components/street-light. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Figure**: The street drawn on a canvas at the device’s pixel ratio, lit by the lantern’s photometry. Dragging on it sets the height (up and down) and the tilt (left and right); it is one image to a screen reader, labelled with the settings.
2. **Pole height**: A native range from 5 to 12 m in steps of 0.1, its value in an `output` above it: a 2 px rule and a hollow 14 px ring.
3. **Tilt**: The same slider, 0 to 25°: it turns the head towards the houses, so the light reaches further across the street.
4. **Optics**: A radio group of three cards, keys 1 to 3; each names its optic and the BUG rating it earns on an 8 m pole. The picked one takes the strong edge.
5. **Distribution**: One stacked bar of where the flux goes (road, pavements, behind the pole, gardens, houses, sky), its legend under it with each share.
6. **Stats**: The BUG rating, the road’s average and peak lux and the lux on the bedroom window, read from the same model as the picture.
7. **Lux map**: A toggle button (`aria-pressed`, key L) that cross-fades the warm light into false colour with isolux lines.
8. **Readout**: The summary line under the panel; the same words are read out politely once the settings rest.

## When to use

- Explaining a physical trade-off by letting the reader move it: how pole height, tilt and optic change where the light lands.
- A long-form article or report on lighting, light pollution or street design, where one interactive figure carries the argument.
- Teaching the vocabulary of a field (BUG rating, lux, isolux lines) next to the picture it describes, so every number has a place.
- A figure that should read as a drawing first: hairlines, captions and small tracked capitals, in a dark or a light scheme.

## When not to use

- Real lighting design or compliance work. The model is a teaching one; use photometric software fed with the luminaire’s IES file.
- Reporting measured data. Use a chart or a table of the readings, not a simulation that looks like one.
- A dense dashboard tile. The figure needs about 600 px to read; a small stat card or a sparkline fits better.
- Pages without room for a canvas to repaint (long lists, many instances): one figure per page, or a static image for the rest.

## Installation

Install it with the shadcn CLI, into components/street-light of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/street-light.json
```

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/street-light
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { StreetLight } from './street-light';

export function LightingFigure() {
  return <StreetLight defaultOptic="wide" defaultTilt={10} defaultLuxMap autoplay={false} />;
}
```

## States

- **Asymmetric**: The default: the light is thrown forward across the road, with little behind the pole.
- **Narrow**: A tight spot on the road by the pole: bright in its beam, dark at the far kerb.
- **Wide**: An even spread that spills onto the pavements, the gardens and the houses.
- **Tilted 20°**: The head turned towards the houses: the road gets more, and so do the windows and the sky.
- **Short pole, 5 m**: Brighter under the lantern, a smaller pool around it.
- **Tall pole, 12 m**: A wider, dimmer pool: the same flux spread over more street.
- **Lux map on**: False colour on a log scale from 0.5 to 100 lx, with isolux lines; set with `defaultLuxMap`.
- **Dark scheme**: With `colorScheme="dark"`: hairlines over near-black faces, the light a warm glow.
- **Light scheme**: With `colorScheme="light"` the panel’s tokens and the canvas palette turn to paper and ink.
- **Narrow container**: Under 600 px wide the sliders and the optic cards stack, and the stats sit two by two.

## How it works

- The street is a small 3D model in metres, drawn in a parallel projection: every solid shows its top, its front and its right end, painted far to near on a canvas at the device’s pixel ratio, with 1 px hairlines over near-black faces.
- The light is a point source with a candela distribution per optic, all three at the same flux. The wide and asymmetric lenses turn with the head’s tilt; the narrow one is aimed back at the kerb and follows the tilt by a third, so its spot stays on the road by the pole. Every lit face gets a texture of illuminance, E = I · cos θ / d², 0.25 m a texel; the ground shares one texture, laid on each horizontal face through the plane’s affine map.
- Where the light goes is found by tracing a fixed grid of directions from the lens: each lands on the road, a pavement, the ground behind the pole, a garden, the houses or the sky, and the shares fill the stacked bar. The BUG rating reads the same directions: light behind the head, above the horizon, and at high angles.
- With the lux map on, the road, the near pavement and the facade take a colour on a logarithmic scale from 0.5 to 100 lx, with isolux lines (1, 2, 5, 10, 20, 50, 100, 200 lx) traced by marching squares over the same grids; the other faces keep a softer warm light, and the haze around the head stays. The two views cross-fade.
- A change of optic cross-fades the candela tables over a second, so the light, the bar and every number slide to their new values together; the picked LED module lifts out of its tray at the front of the plinth.
- Dragging on the figure sets the height (up and down) and the tilt (left and right). While on screen and untouched, a demo pointer switches optics, tilts the head, turns the lux map on and drags the height; any real input hands control to the reader.
- Painting runs only while something moves. Its colours follow the page’s color-scheme, or the colorScheme prop: the panel uses light-dark() tokens, and the canvas repaints when a probe in the stylesheet changes colour.

## API reference

- **defaultOptic** (`'narrow' | 'wide' | 'asymmetric'`, default `'asymmetric'`): The optic in the head when it mounts.
- **defaultHeight** (`number`, default `8`): The pole’s height in metres, from 5 to 12.
- **defaultTilt** (`number`, default `0`): The head’s tilt towards the houses, in degrees, from 0 to 25.
- **defaultLuxMap** (`boolean`, default `false`): Starts with the lux map on.
- **onChange** (`(settings: { optic, height, tilt, luxMap }) => void`): Called with the settings whenever the reader or the demo changes them.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer plays with the controls. It never runs under reduced motion.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s. Without it, the component follows the page’s color-scheme.
- **className** (`string`): A class for the root, to size or place it.

## Accessibility

- Height and tilt are native range inputs with their values spoken in metres and degrees; the optics are a radio group, and the lux map is a toggle button with aria-pressed.
- Keys 1, 2 and 3 pick an optic and L turns the lux map on or off, anywhere in the component.
- The figure is an image with a label that states the pole’s height, the tilt and the optic; the drag on it only repeats what the sliders do.
- The summary line (rating, road share, sky share, window lux) is read out politely once the settings rest, not at every step of a drag.
- Under reduced motion the lamp is on from the start, changes are instant and the demo never runs. The demo pointer is hidden from screen readers.

## Source

### street-light/street-light.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import {
  OPTICS,
  WINDOW,
  ZONES,
  lanternAt,
  ratingText,
  report,
  type Lantern,
  type Mix,
  type Optic,
  type Rating,
  type Zone,
} from './photometry';
import { PALETTES, Renderer } from './renderer';
import { build, headPoints } from './scene';
import { useAutoplay } from './use-autoplay';
import styles from './street-light.module.css';

/*
 * Street light: a figure of an LED street light over a terraced street, drawn as an axonometric
 * line diorama, with the light it throws computed as you change it.
 *
 * - The pole's height, the head's tilt and the optic (narrow, wide or asymmetric) set a lantern;
 *   photometry.ts turns it into lux on every surface (inverse square and cosine, from a candela
 *   distribution per optic, all at the same flux) and into the share of the flux that lands on
 *   the road, the pavements, behind the pole, in the gardens, on the houses and in the sky, with
 *   a BUG rating (backlight, uplight, glare).
 * - renderer.ts paints the scene on a canvas: hairlines on near-black faces, the light as a warm
 *   texture on each face, or, with the lux map on, as a false-colour map with isolux lines.
 * - Dragging on the figure sets the height (up and down) and the tilt (left and right); the
 *   sliders, the optic cards (keys 1, 2, 3) and the lux map switch (L) do the same by keyboard.
 * - Changes of optic cross-fade the candela tables over a second, so the light, the numbers and
 *   the bar all slide to their new values together; the lux map fades in and out.
 * - While on screen and untouched, a demo pointer switches optics, tilts the head, turns the lux
 *   map on and drags the height. Any real input hands control to the reader.
 * - Light or dark: the panel's colours are light-dark() tokens; the canvas reads the scheme from a
 *   probe in the stylesheet and repaints when it changes.
 */

const LABELS: Record<Optic, string> = { narrow: 'Narrow', wide: 'Wide', asymmetric: 'Asymmetric' };
const ZONE_LABELS: Record<Zone, string> = {
  road: 'Road',
  pavements: 'Pavements',
  behind: 'Behind pole',
  gardens: 'Gardens',
  houses: 'Houses',
  sky: 'Sky',
};
export const HEIGHT = { min: 5, max: 12, step: 0.1 };
export const TILT = { min: 0, max: 25, step: 0.1 };

const rad = (deg: number) => (deg * Math.PI) / 180;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const mixOf = (optic: Optic): Mix => ({ narrow: 0, wide: 0, asymmetric: 0, [optic]: 1 });

/** The rating each optic earns on an 8 m pole, untilted: the line under its name. */
const CARD_RATINGS: Record<Optic, string> = (() => {
  const out = {} as Record<Optic, string>;
  for (const optic of OPTICS) {
    out[optic] = ratingText(
      report({ at: lanternAt(8), tilt: 0, mix: mixOf(optic), power: 1 }).rating,
    );
  }
  return out;
})();

type Stats = {
  zones: Record<Zone, number>;
  rating: Rating;
  roadAvg: number;
  roadPeak: number;
  window: number;
};

/** A value that eases from where it is to a target. */
class Tween {
  from: number;
  to: number;
  start = 0;
  constructor(
    public value: number,
    public duration: number,
  ) {
    this.from = value;
    this.to = value;
  }
  set(to: number, now: number, instant = false) {
    if (to === this.to && !instant) return;
    this.from = instant ? to : this.value;
    this.to = to;
    this.start = now;
    if (instant) this.value = to;
  }
  step(now: number) {
    if (this.value === this.to) return false;
    const t = clamp((now - this.start) / this.duration, 0, 1);
    this.value = t >= 1 ? this.to : this.from + (this.to - this.from) * ease(t);
    return true;
  }
}

const lux = (value: number) =>
  value < 0.05 ? '0' : value < 10 ? value.toFixed(1) : Math.round(value).toString();
const pct = (value: number) => `${Math.round(value * 100)}%`;

export function StreetLight({
  defaultOptic = 'asymmetric',
  defaultHeight = 8,
  defaultTilt = 0,
  defaultLuxMap = false,
  onChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The optic in the head when it mounts. */
  defaultOptic?: Optic;
  /** The pole's height, metres (5 to 12). */
  defaultHeight?: number;
  /** The head's tilt towards the houses, degrees (0 to 25). */
  defaultTilt?: number;
  /** Starts with the lux map on. */
  defaultLuxMap?: boolean;
  /** Called with the settings whenever the reader (or the demo) changes them. */
  onChange?: (settings: { optic: Optic; height: number; tilt: number; luxMap: boolean }) => void;
  /** While on screen and untouched, a demo pointer plays with the controls. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [optic, setOptic] = useState<Optic>(defaultOptic);
  const [height, setHeight] = useState(clamp(defaultHeight, HEIGHT.min, HEIGHT.max));
  const [tilt, setTilt] = useState(clamp(defaultTilt, TILT.min, TILT.max));
  const [luxMap, setLuxMap] = useState(defaultLuxMap);
  const [stats, setStats] = useState<Stats | null>(null);
  const [spoken, setSpoken] = useState('');
  const canvas = useRef<HTMLCanvasElement>(null);
  const figure = useRef<HTMLDivElement>(null);
  const probe = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const heightInput = useRef<HTMLInputElement>(null);
  const tiltInput = useRef<HTMLInputElement>(null);
  const cards = useRef<Record<Optic, HTMLLabelElement | null>>({
    narrow: null,
    wide: null,
    asymmetric: null,
  });
  const luxButton = useRef<HTMLButtonElement>(null);
  const id = useId();

  /** What the paint loop reads: the settings, and the eased values. */
  const live = useRef({ optic, height, tilt, luxMap });
  live.current = { optic, height, tilt, luxMap };
  const kick = useRef<() => void>(() => {});

  const demo = autoplay && playing;

  useEffect(() => {
    onChange?.({ optic, height, tilt, luxMap });
    kick.current();
    // onChange is the caller's: only the settings trigger it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [optic, height, tilt, luxMap]);

  // The paint loop: runs while something eases, idles otherwise.
  useEffect(() => {
    const element = canvas.current;
    const box = figure.current;
    const scheme = probe.current;
    if (!element || !box || !scheme) return;
    const renderer = new Renderer(element);
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const start = live.current;
    const mix = Object.fromEntries(
      OPTICS.map((o) => [o, new Tween(o === start.optic ? 1 : 0, 1100)]),
    ) as Record<Optic, Tween>;
    const lift = Object.fromEntries(
      OPTICS.map((o) => [o, new Tween(o === start.optic ? 1 : 0, 450)]),
    ) as Record<Optic, Tween>;
    const map = new Tween(start.luxMap ? 1 : 0, 800);
    const power = new Tween(reduced ? 1 : 0, 1100);
    let switchedOn = reduced;
    let frame = 0;
    let dirty = true;
    let last = { key: '', stats: null as Stats | null };

    const readScheme = () => {
      const dark = getComputedStyle(scheme).color.replace(/\s/g, '') === 'rgb(255,255,255)';
      const palette = dark ? PALETTES.dark : PALETTES.light;
      if (renderer.palette !== palette) {
        renderer.palette = palette;
        renderer.invalidate();
        dirty = true;
      }
    };

    const tick = (now: number) => {
      frame = 0;
      const s = live.current;
      for (const o of OPTICS) {
        mix[o].set(o === s.optic ? 1 : 0, now, reduced);
        lift[o].set(o === s.optic ? 1 : 0, now, reduced);
      }
      map.set(s.luxMap ? 1 : 0, now, reduced);
      let moving = false;
      for (const t of [...Object.values(mix), ...Object.values(lift), map, power]) {
        if (t.step(now)) moving = true;
      }
      if (moving || dirty) {
        dirty = false;
        const m: Mix = {
          narrow: mix.narrow.value,
          wide: mix.wide.value,
          asymmetric: mix.asymmetric.value,
        };
        const lantern: Lantern = {
          at: lanternAt(s.height),
          tilt: rad(s.tilt),
          mix: m,
          power: power.value,
        };
        const key = `${s.height}|${s.tilt}|${m.narrow}|${m.wide}|${m.asymmetric}`;
        const rep = report(lantern);
        const layout = build(s.height, rad(s.tilt), s.optic, {
          narrow: lift.narrow.value,
          wide: lift.wide.value,
          asymmetric: lift.asymmetric.value,
        });
        const head = headPoints(s.height, rad(s.tilt));
        renderer.paint(
          layout,
          { lantern, map: map.value, sky: rep.zones.sky, houses: rep.zones.houses },
          head.lens,
        );
        const ground = renderer.stats(lantern, WINDOW);
        const next: Stats = { zones: rep.zones, rating: rep.rating, ...ground };
        const statsKey = `${key}|${power.value.toFixed(3)}`;
        if (statsKey !== last.key) {
          last = { key: statsKey, stats: next };
          setStats(next);
        }
      }
      if (moving) frame = requestAnimationFrame(tick);
    };
    const request = () => {
      if (!frame) frame = requestAnimationFrame(tick);
    };
    kick.current = () => {
      dirty = true;
      request();
    };

    const resize = () => {
      const { width, height: h } = element.getBoundingClientRect();
      if (width > 0 && h > 0) renderer.resize(width, h);
      dirty = true;
      request();
    };
    const observer = new ResizeObserver(resize);
    observer.observe(element);
    resize();
    readScheme();

    // The lamp comes on once the figure is first seen.
    const seen = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting || switchedOn) return;
        switchedOn = true;
        window.setTimeout(() => {
          power.set(1, performance.now());
          request();
        }, 350);
      },
      { threshold: 0.3 },
    );
    seen.observe(box);

    // A change of colour scheme ends the probe's transition: repaint then.
    const onScheme = () => {
      readScheme();
      request();
    };
    scheme.addEventListener('transitionend', onScheme);
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    media.addEventListener('change', onScheme);

    return () => {
      cancelAnimationFrame(frame);
      observer.disconnect();
      seen.disconnect();
      scheme.removeEventListener('transitionend', onScheme);
      media.removeEventListener('change', onScheme);
      kick.current = () => {};
    };
  }, []);

  // What screen readers hear: the summary line, once the settings rest.
  const summary = stats
    ? `${ratingText(stats.rating)} · road ${pct(stats.zones.road)} · sky ${pct(stats.zones.sky)} · window ${stats.window.toFixed(1)} lx`
    : '';
  useEffect(() => {
    const timer = window.setTimeout(() => setSpoken(summary), 600);
    return () => window.clearTimeout(timer);
  }, [summary]);

  // Dragging on the figure: up and down for the height, left and right for the tilt.
  const drag = useRef<{ x: number; y: number; height: number; tilt: number; id: number } | null>(
    null,
  );
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    take();
    drag.current = { x: event.clientX, y: event.clientY, height, tilt, id: event.pointerId };
    event.currentTarget.setPointerCapture(event.pointerId);
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== event.pointerId) return;
    const scale = 320 / Math.max(240, event.currentTarget.offsetWidth);
    const h = d.height - (event.clientY - d.y) * 0.025 * scale;
    const t = d.tilt + (event.clientX - d.x) * 0.06 * scale;
    setHeight(Math.round(clamp(h, HEIGHT.min, HEIGHT.max) * 10) / 10);
    setTilt(Math.round(clamp(t, TILT.min, TILT.max) * 10) / 10);
  };
  const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (drag.current?.id === event.pointerId) drag.current = null;
  };

  // Any real input hands the component to the reader; keys 1 to 3 and L work anywhere in it.
  const onInput = useEffectEvent((event: Event) => {
    if (event instanceof PointerEvent && !event.isPrimary) return;
    take();
    if (!(event instanceof KeyboardEvent)) return;
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    const index = ['1', '2', '3'].indexOf(event.key);
    if (index >= 0) {
      setOptic(OPTICS[index]);
      event.preventDefault();
    } else if (event.key === 'l' || event.key === 'L') {
      setLuxMap((on) => !on);
      event.preventDefault();
    }
  });
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const listen = (event: Event) => onInput(event);
    const options = { capture: true, passive: false };
    element.addEventListener('pointerdown', listen, options);
    element.addEventListener('wheel', listen, { capture: true, passive: true });
    element.addEventListener('keydown', listen, options);
    return () => {
      element.removeEventListener('pointerdown', listen, options);
      element.removeEventListener('wheel', listen, { capture: true });
      element.removeEventListener('keydown', listen, options);
    };
  }, [root]);

  // The demo: a pointer that plays with the controls, as a reader would.
  const script = useEffectEvent(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!stage || !arrow) return () => {};
    const at = (element: Element | null, fx = 0.5, fy = 0.5): Point => {
      if (!element) return { x: 0, y: 0 };
      const s = stage.getBoundingClientRect();
      const r = element.getBoundingClientRect();
      return { x: r.left - s.left + r.width * fx, y: r.top - s.top + r.height * fy };
    };
    /** Where a slider's thumb is for a value, in the stage's px. */
    const thumb = (input: HTMLInputElement | null, value: number, min: number, max: number) => {
      if (!input) return { x: 0, y: 0 };
      const s = stage.getBoundingClientRect();
      const r = input.getBoundingClientRect();
      const size = 14;
      return {
        x: r.left - s.left + size / 2 + ((value - min) / (max - min)) * (r.width - size),
        y: r.top - s.top + r.height / 2,
      };
    };
    let current = { height: live.current.height, tilt: live.current.tilt };
    return runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** Drags a slider from its value to `to`, the pointer and the value moving together. */
      const slide = async (which: 'height' | 'tilt', to: number, ms: number) => {
        const input = which === 'height' ? heightInput.current : tiltInput.current;
        const range = which === 'height' ? HEIGHT : TILT;
        const from = current[which];
        const set = which === 'height' ? setHeight : setTilt;
        let raf = 0;
        const t0 = performance.now();
        const run = () => {
          const t = clamp((performance.now() - t0) / ms, 0, 1);
          const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
          set(Math.round((from + (to - from) * e) * 10) / 10);
          if (t < 1) raf = requestAnimationFrame(run);
        };
        raf = requestAnimationFrame(run);
        try {
          await glide(thumb(input, to, range.min, range.max), ms);
        } finally {
          cancelAnimationFrame(raf);
        }
        set(to);
        current = { ...current, [which]: to };
      };
      const press = async (element: Element | null, act: () => void) => {
        await glide(at(element, 0.62, 0.62), 900);
        glyph('hand');
        await wait(260);
        act();
        await wait(380);
        glyph('arrow');
      };
      const grab = async (which: 'height' | 'tilt') => {
        const input = which === 'height' ? heightInput.current : tiltInput.current;
        const range = which === 'height' ? HEIGHT : TILT;
        await glide(thumb(input, current[which], range.min, range.max), 850);
        await wait(250);
      };
      await wait(1500);
      for (;;) {
        current = { height: live.current.height, tilt: live.current.tilt };
        await press(cards.current.wide, () => setOptic('wide'));
        await wait(1500);
        await grab('tilt');
        await slide('tilt', 15, 1500);
        await wait(700);
        await press(luxButton.current, () => setLuxMap(true));
        await wait(1900);
        await press(cards.current.narrow, () => setOptic('narrow'));
        await wait(1700);
        await grab('tilt');
        await slide('tilt', 0, 1300);
        await wait(600);
        await press(cards.current.asymmetric, () => setOptic('asymmetric'));
        await wait(1500);
        await grab('height');
        await slide('height', 6.6, 900);
        await wait(250);
        await slide('height', 11.5, 1500);
        await wait(350);
        await slide('height', 8, 1000);
        await wait(700);
        await press(luxButton.current, () => setLuxMap(false));
        await wait(2400);
      }
    });
  });
  useEffect(() => {
    if (!demo) return;
    return script();
  }, [demo]);

  const zones = stats?.zones;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-part="root"
    >
      <span ref={probe} className={styles.probe} aria-hidden="true" />
      <div
        ref={figure}
        className={styles.figure}
        data-part="figure"
        role="img"
        aria-label={`Figure: an LED street light on a ${height.toFixed(1)} m pole, tilted ${tilt.toFixed(1)}°, ${LABELS[optic].toLowerCase()} optic, over a road with pavements, front gardens and a terrace of three houses.${luxMap ? ' Lux map on.' : ''}`}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
      >
        <canvas ref={canvas} className={styles.canvas} aria-hidden="true" />
        <span className={styles.fig} aria-hidden="true" data-part="caption">
          Fig 1
        </span>
        <span className={styles.name} aria-hidden="true">
          LED street light
        </span>
      </div>

      <div className={styles.panel} data-part="controls">
        <div className={styles.sliders}>
          <label className={styles.slider} data-part="height">
            <span className={styles.label}>Pole height</span>
            <output className={styles.value} htmlFor={`${id}-height`}>
              {height.toFixed(1)} m
            </output>
            <input
              ref={heightInput}
              id={`${id}-height`}
              className={styles.range}
              type="range"
              min={HEIGHT.min}
              max={HEIGHT.max}
              step={HEIGHT.step}
              value={height}
              aria-valuetext={`${height.toFixed(1)} metres`}
              onChange={(event) => setHeight(Number(event.target.value))}
            />
          </label>
          <label className={styles.slider} data-part="tilt">
            <span className={styles.label}>Tilt</span>
            <output className={styles.value} htmlFor={`${id}-tilt`}>
              {tilt.toFixed(1)}°
            </output>
            <input
              ref={tiltInput}
              id={`${id}-tilt`}
              className={styles.range}
              type="range"
              min={TILT.min}
              max={TILT.max}
              step={TILT.step}
              value={tilt}
              aria-valuetext={`${tilt.toFixed(1)} degrees`}
              onChange={(event) => setTilt(Number(event.target.value))}
            />
          </label>
        </div>

        <div className={styles.cards} role="radiogroup" aria-label="Optic" data-part="optics">
          {OPTICS.map((name, i) => (
            <label
              key={name}
              ref={(element) => {
                cards.current[name] = element;
              }}
              className={styles.card}
              data-part="card"
              data-selected={name === optic || undefined}
            >
              <input
                className={styles.radio}
                type="radio"
                name={`${id}-optic`}
                value={name}
                checked={name === optic}
                onChange={() => setOptic(name)}
              />
              <span className={styles.cardTitle}>
                {i + 1} · {LABELS[name]}
              </span>
              <span className={styles.cardRating}>{CARD_RATINGS[name]} at 8 m</span>
            </label>
          ))}
        </div>

        <div className={styles.bar} data-part="distribution" aria-hidden="true">
          {ZONES.map((zone) => (
            <span
              key={zone}
              className={styles.segment}
              data-zone={zone}
              style={{ flexGrow: zones ? zones[zone] : 0 }}
            />
          ))}
        </div>
        <ul className={styles.legend} aria-label="Where the light goes" data-part="legend">
          {ZONES.map((zone) => (
            <li key={zone} className={styles.key} data-zone={zone}>
              <span className={styles.swatch} aria-hidden="true" />
              {ZONE_LABELS[zone]} <b>{zones ? pct(zones[zone]) : '0%'}</b>
            </li>
          ))}
        </ul>

        <div className={styles.foot}>
          <dl className={styles.stats} data-part="stats">
            <div>
              <dt>BUG</dt>
              <dd>{stats ? ratingText(stats.rating) : '–'}</dd>
            </div>
            <div>
              <dt>Road avg</dt>
              <dd>{stats ? lux(stats.roadAvg) : '0'} lx</dd>
            </div>
            <div>
              <dt>Road peak</dt>
              <dd>{stats ? lux(stats.roadPeak) : '0'} lx</dd>
            </div>
            <div>
              <dt>Bedroom window</dt>
              <dd>{stats ? lux(stats.window) : '0'} lx</dd>
            </div>
          </dl>
          <button
            ref={luxButton}
            type="button"
            className={styles.lux}
            aria-pressed={luxMap}
            data-part="lux-map"
            onClick={() => setLuxMap((on) => !on)}
          >
            <span className={styles.luxTitle}>Lux map</span>
            <span className={styles.luxState} aria-hidden="true">
              {luxMap ? 'on' : 'off'}
            </span>
          </button>
        </div>
      </div>

      <div className={styles.footer}>
        <span className={styles.hint} data-part="hint">
          Drag <span aria-hidden="true">⇕</span> height · <span aria-hidden="true">⇔</span> tilt
        </span>
        <span className={styles.readout} data-part="readout">
          {summary}
        </span>
        <span className={styles.live} aria-live="polite" aria-atomic="true">
          {spoken}
        </span>
      </div>

      <DemoPointer
        ref={pointer}
        className={styles.pointer}
        arrowClass={styles.arrow}
        handClass={styles.hand}
      />
    </div>
  );
}
```

### street-light/photometry.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The light model of the street light: a point source with an intensity distribution, in metres
 * and lux. It is a teaching model, not a lighting design tool, but every number it gives comes
 * from the same physics: illuminance E = I(direction) · cos(incidence) / distance².
 *
 * - The world: x runs along the street, y across it (towards the houses), z up. The pole stands on
 *   the near pavement; its arm reaches over the road, so "forward" for the lantern is +y.
 * - An optic is a relative intensity f(local direction), in the lantern's own frame (nadir is
 *   straight down). Tilt turns that frame about the street axis, towards the houses.
 * - Every optic emits the same flux (LUMENS). Its candela table is f scaled so that the integral
 *   of I over the sphere is that flux, so a tighter optic is brighter in its beam.
 * - Where the flux goes (road, pavements, behind the pole, gardens, houses, sky) is found by
 *   tracing a fixed grid of directions, each weighted by the solid angle it stands for.
 * - The BUG rating (backlight, uplight, glare) is read from the same directions: the share of the
 *   flux behind the lantern, above the horizon, and at high angles in front, each banded 0 to 3
 *   (4 for the worst glare).
 */

export type Vec = { x: number; y: number; z: number };

/** The three optics, in the order of their cards. */
export const OPTICS = ['narrow', 'wide', 'asymmetric'] as const;
export type Optic = (typeof OPTICS)[number];

/** The flux of the lantern, the same for every optic. */
export const LUMENS = 5000;

/** The street, across: where each band starts and ends, in metres of y. */
export const BANDS = {
  /** The front edge of the near pavement: anything nearer the viewer is behind the pole. */
  behind: 5.1,
  nearKerb: 8.6,
  farKerb: 15,
  wall: 17.8,
  facade: 20.8,
  back: 26.3,
};
/** The road runs the length of the plinth. */
export const ROAD = { x0: 0.5, x1: 30 };
/** The row of houses: a box the rays can hit (roof included, as a box). */
export const HOUSES = { x0: 6.5, x1: 22.7, y0: BANDS.facade, y1: BANDS.back, z1: 8.2 };
/** The pole's foot, and how far the arm reaches out over the road. */
export const POLE = { x: 13.95, y: 8.1, reach: 2 };
/** The road lies below the pavements: a pole's height is measured from the road. */
export const ROAD_LEVEL = -0.42;
/** The upstairs window the stats watch: on the facade, in front of the lantern. */
export const WINDOW: Vec = { x: 13.15, y: BANDS.facade, z: 3.4 };

const smooth = (edge0: number, edge1: number, value: number) => {
  const t = Math.min(1, Math.max(0, (value - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
};

/**
 * The relative intensity of an optic, for a direction in the lantern's frame: `g` is the angle
 * from nadir in radians, `fx` and `fy` the horizontal components along the street and forward
 * (across the road), as shares of the direction.
 */
function relative(optic: Optic, g: number, fx: number, fy: number): number {
  const c = Math.cos(g);
  if (optic === 'narrow') {
    // A round spot: most of the flux within 30° of nadir, nothing past 85°.
    return c > 0 ? Math.pow(c, 11) * (1 - smooth(1.35, 1.48, g)) : 0;
  }
  if (optic === 'wide') {
    // Close to a bare lamp under a flat lens: nearly even out to the horizon of the lantern.
    const s = Math.sin(g);
    return c > 0 ? (1 + 1.6 * s * s * s) * Math.pow(c, 0.12) * (1 - smooth(1.36, 1.5708, g)) : 0;
  }
  // Asymmetric: a batwing along the street that keeps the light on the road: across it, it
  // reaches the far kerb but is cut off before the gardens, and sheds little behind the pole.
  const s = Math.sin(g);
  const along = 1 + 6 * fx * fx * s * s;
  const across = fy >= 0 ? 1 + 2.5 * fy * s : Math.exp(-15 * fy * fy * s);
  // The cut-off: 78° along the street, about 55° towards the houses, 24° behind.
  const reach = fy >= 0 ? 1.36 - 0.4 * fy * fy : 1.36 - 0.95 * fy * fy;
  return c > 0 ? c ** 3 * along * across * (1 - smooth(reach - 0.15, reach, g)) : 0;
}

/** A blend of optics, as a weight for each: an optic change is a cross-fade of these. */
export type Mix = Record<Optic, number>;

export type Lantern = {
  /** The light's centre. */
  at: Vec;
  /** Tilt towards the houses, radians. */
  tilt: number;
  mix: Mix;
  /** 0 off, 1 full: the switch-on ramp. */
  power: number;
};

/** Where the lantern hangs for a pole `height` metres tall. */
export function lanternAt(height: number): Vec {
  return { x: POLE.x, y: POLE.y + POLE.reach, z: height + ROAD_LEVEL };
}

/** Directions sampled over the sphere, in the lantern's frame, with their solid angles. */
type Sample = { g: number; fx: number; fy: number; lx: number; ly: number; lz: number; w: number };
const SAMPLES: Sample[] = (() => {
  const list: Sample[] = [];
  const dg = Math.PI / 120;
  const dc = (Math.PI * 2) / 96;
  for (let i = 0; i < 120; i += 1) {
    const g = (i + 0.5) * dg;
    const s = Math.sin(g);
    for (let j = 0; j < 96; j += 1) {
      const c = (j + 0.5) * dc;
      const fx = Math.cos(c);
      const fy = Math.sin(c);
      list.push({ g, fx, fy, lx: s * fx, ly: s * fy, lz: -Math.cos(g), w: s * dg * dc });
    }
  }
  return list;
})();

/** The integral of each optic's relative intensity over the sphere, for its candela scale. */
const NORMS: Record<Optic, number> = (() => {
  const out = { narrow: 0, wide: 0, asymmetric: 0 };
  for (const optic of OPTICS) {
    let sum = 0;
    for (const p of SAMPLES) sum += relative(optic, p.g, p.fx, p.fy) * p.w;
    out[optic] = sum;
  }
  return out;
})();

/** The candela of the lantern towards a world direction (unit vector). */
/**
 * Where an optic points, for the head's tilt (radians): the wide and asymmetric lenses turn with
 * the head; the narrow one is aimed back at the kerb in the head and follows its tilt only by a
 * third, so its spot stays on the road by the pole.
 */
export function aim(optic: Optic, tilt: number) {
  return optic === 'narrow' ? tilt * 0.3 - 0.03 : tilt;
}

/** The relative intensity of one optic towards a world direction (unit vector). */
function towards(optic: Optic, tilt: number, d: Vec): number {
  const t = aim(optic, tilt);
  const ct = Math.cos(t);
  const st = Math.sin(t);
  // Into the optic's frame: turn back by its aim, about the street axis.
  const ly = ct * d.y + st * d.z;
  const lz = -st * d.y + ct * d.z;
  const g = Math.acos(Math.max(-1, Math.min(1, -lz)));
  const h = Math.hypot(d.x, ly) || 1;
  const sg = Math.sin(g);
  const fx = sg > 1e-6 ? d.x / h : 0;
  const fy = sg > 1e-6 ? ly / h : 0;
  return relative(optic, g, fx, fy) / NORMS[optic];
}

/** The candela of the lantern towards a world direction (unit vector). */
export function intensity(lantern: Lantern, d: Vec): number {
  let value = 0;
  for (const optic of OPTICS) {
    const weight = lantern.mix[optic];
    if (weight > 0) value += weight * towards(optic, lantern.tilt, d);
  }
  return value * LUMENS * lantern.power;
}

/** Illuminance on a surface at `p` facing `n` (unit normal), in lux. */
export function illuminance(lantern: Lantern, p: Vec, n: Vec): number {
  const dx = p.x - lantern.at.x;
  const dy = p.y - lantern.at.y;
  const dz = p.z - lantern.at.z;
  const d2 = dx * dx + dy * dy + dz * dz;
  if (d2 < 0.01) return 0;
  const d = Math.sqrt(d2);
  const cos = -(dx * n.x + dy * n.y + dz * n.z) / d;
  if (cos <= 0) return 0;
  return (intensity(lantern, { x: dx / d, y: dy / d, z: dz / d }) * cos) / d2;
}

export const ZONES = ['road', 'pavements', 'behind', 'gardens', 'houses', 'sky'] as const;
export type Zone = (typeof ZONES)[number];

/** Where a ray from the lantern lands. */
function zoneOf(from: Vec, d: Vec): Zone {
  if (d.z >= -1e-4) return 'sky';
  // The houses: a box; test it before the ground.
  const box = HOUSES;
  let tNear = 0;
  let tFar = Infinity;
  const slabs: [number, number, number, number][] = [
    [from.x, d.x, box.x0, box.x1],
    [from.y, d.y, box.y0, box.y1],
    [from.z, d.z, 0, box.z1],
  ];
  let hit = true;
  for (const [o, v, lo, hi] of slabs) {
    if (Math.abs(v) < 1e-9) {
      if (o < lo || o > hi) hit = false;
      continue;
    }
    const a = (lo - o) / v;
    const b = (hi - o) / v;
    tNear = Math.max(tNear, Math.min(a, b));
    tFar = Math.min(tFar, Math.max(a, b));
  }
  if (hit && tNear <= tFar) return 'houses';
  const t = -from.z / d.z;
  const y = from.y + d.y * t;
  if (y < BANDS.behind) return 'behind';
  if (y < BANDS.nearKerb) return 'pavements';
  if (y < BANDS.farKerb) return 'road';
  if (y < BANDS.wall) return 'pavements';
  return 'gardens';
}

export type Rating = { b: number; u: number; g: number };

export type Report = {
  /** Shares of the flux, 0 to 1. */
  zones: Record<Zone, number>;
  rating: Rating;
};

const band = (value: number, limits: number[]) => {
  let index = 0;
  while (index < limits.length && value >= limits[index]) index += 1;
  return index;
};

/** Where the flux goes, and the BUG rating, for a lantern at full power. */
export function report(lantern: Lantern): Report {
  const zones: Record<Zone, number> = {
    road: 0,
    pavements: 0,
    behind: 0,
    gardens: 0,
    houses: 0,
    sky: 0,
  };
  let total = 0;
  let back = 0;
  let up = 0;
  let glare = 0;
  // Each optic in the blend, through its own aim.
  for (const optic of OPTICS) {
    const weight = lantern.mix[optic];
    if (weight <= 0) continue;
    const t = aim(optic, lantern.tilt);
    const ct = Math.cos(t);
    const st = Math.sin(t);
    for (const p of SAMPLES) {
      const f = (weight * relative(optic, p.g, p.fx, p.fy)) / NORMS[optic];
      if (f <= 0) continue;
      const flux = f * p.w;
      // From the optic's frame to the world: turn by its aim about the street axis.
      const d = { x: p.lx, y: ct * p.ly - st * p.lz, z: st * p.ly + ct * p.lz };
      zones[zoneOf(lantern.at, d)] += flux;
      total += flux;
      if (d.z >= 0) up += flux;
      else if (d.y < -0.4 * Math.hypot(d.x, d.y)) back += flux;
      // Glare: light past 60° from nadir, and light above the horizon counted three times over.
      if (d.z >= 0) glare += 3 * flux;
      else if (-d.z < 0.5) glare += flux;
    }
  }
  if (total > 0) for (const zone of ZONES) zones[zone] /= total;
  const share = (value: number) => (total > 0 ? value / total : 0);
  return {
    zones,
    rating: {
      b: band(share(back), [0.002, 0.12, 0.33]),
      u: band(share(up), [1e-8, 0.005, 0.03]),
      g: band(share(glare), [0.05, 0.25, 0.4, 0.58]),
    },
  };
}

export const ratingText = ({ b, u, g }: Rating) => `B${b} U${u} G${g}`;
```

### street-light/scene.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The diorama, as geometry: a street on a rounded plinth, in metres (x along the street, y across
 * it towards the houses, z up). It only describes shapes: faces with a fill and maybe light on
 * them, and hairlines. renderer.ts projects and paints them.
 *
 * - Every solid is drawn with its three faces the camera can see: the top, the front (facing -y)
 *   and the right end (facing +x). Objects are painted far to near, so a nearer face hides what
 *   is behind it; ground-level shapes go first, in their own layer.
 * - Faces marked `lit` receive the street light: horizontal ones share one illuminance texture of
 *   the whole ground, vertical ones (the facade, the fronts of walls) get a texture of their own.
 */

import { BANDS, HOUSES, POLE, ROAD_LEVEL, lanternAt, type Optic, type Vec } from './photometry';

export type Tone = 'line' | 'faint' | 'bright' | 'none';
export type Fill =
  | 'plinth'
  | 'side'
  | 'road'
  | 'pave'
  | 'kerb'
  | 'grass'
  | 'wall'
  | 'facade'
  | 'roof'
  | 'glass'
  | 'shed'
  | 'metal'
  | 'recess'
  | 'module'
  | 'lens';

export type Face = {
  pts: Vec[];
  fill: Fill;
  /** Painted from the light textures: horizontal faces from the ground's, others from their own. */
  lit?: boolean;
  /** The outline's tone; `none` leaves it unstroked. */
  tone?: Tone;
  /** The points are a cloud: the face is their convex hull once projected (cylinders). */
  hull?: boolean;
  /**
   * Takes the lux map's colours when it is on (the road and the facade); other lit faces keep a
   * softer warm light under the map.
   */
  lux?: boolean;
  /** Draws its fill a little transparent, so the light on what is behind shows (glass). */
  alpha?: number;
};

export type Line = { pts: Vec[]; tone: Tone };

export type Item = {
  depth: number;
  faces: Face[];
  lines: Line[];
  /** A ball (the tree's crown): a shaded disc. */
  sphere?: { c: Vec; r: number };
  /** Drawn in the bright tone and above its neighbours: the picked LED module. */
  picked?: boolean;
};

/** The plinth, its corner radius and thickness. */
export const PLINTH = { x0: 0, y0: 0, x1: 30.5, y1: 28, r: 1.3, depth: 1.0 };

const v = (x: number, y: number, z: number): Vec => ({ x, y, z });

/** The street's well, inside the plinth's rim: the lux map covers it. */
export const WELL = { x0: 0.5, y0: 4.6, x1: 30, y1: 27.4 };
/** The pavements' and gardens' height, level with the plinth's rim, and the road's, sunk below. */
export const PAVE = 0;
export const FLOOR = ROAD_LEVEL;
/** Each house's width, and its heights. */
const HOUSE = 5.4;
const EAVES = 5;
const RIDGE = 8.2;

/** The camera: yaw about z and pitch, in radians. */
export const YAW = (31.8 * Math.PI) / 180;
export const PITCH = (31.7 * Math.PI) / 180;
const CY = Math.cos(YAW);
const SY = Math.sin(YAW);
const CP = Math.cos(PITCH);
const SP = Math.sin(PITCH);

/** World to view units (1 unit = 1 m at the scale of the drawing): x right, y down. */
export function project(p: Vec): [number, number] {
  return [p.x * CY + p.y * SY, (p.x * SY - p.y * CY) * SP - p.z * CP];
}

/** How far from the camera a point is: larger is further, painted first. */
export const depthOf = (x: number, y: number) => -x * SY + y * CY;

/** A circle in a plane: about z (horizontal), or in a wall facing y or x. */
export function circle(c: Vec, r: number, plane: 'z' | 'y' | 'x' = 'z', n = 28): Vec[] {
  const out: Vec[] = [];
  for (let i = 0; i <= n; i += 1) {
    const a = (i / n) * Math.PI * 2;
    const p = Math.cos(a) * r;
    const q = Math.sin(a) * r;
    if (plane === 'z') out.push(v(c.x + p, c.y + q, c.z));
    else if (plane === 'y') out.push(v(c.x + p, c.y, c.z + q));
    else out.push(v(c.x, c.y + p, c.z + q));
  }
  return out;
}

/** A rounded rectangle, flat at height z. */
export function roundRect(
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  r: number,
  z: number,
  n = 7,
): Vec[] {
  const out: Vec[] = [];
  const corners: [number, number, number][] = [
    [x1 - r, y0 + r, -Math.PI / 2],
    [x1 - r, y1 - r, 0],
    [x0 + r, y1 - r, Math.PI / 2],
    [x0 + r, y0 + r, Math.PI],
  ];
  for (const [cx, cy, start] of corners) {
    for (let i = 0; i <= n; i += 1) {
      const a = start + (i / n) * (Math.PI / 2);
      out.push(v(cx + Math.cos(a) * r, cy + Math.sin(a) * r, z));
    }
  }
  out.push(out[0]);
  return out;
}

const rect = (x0: number, y0: number, x1: number, y1: number, z: number): Vec[] => [
  v(x0, y0, z),
  v(x1, y0, z),
  v(x1, y1, z),
  v(x0, y1, z),
];

/** A rectangle in the plane y = const (a wall facing -y), from its corners in x and z. */
const wallRect = (x0: number, z0: number, x1: number, z1: number, y: number): Vec[] => [
  v(x0, y, z0),
  v(x1, y, z0),
  v(x1, y, z1),
  v(x0, y, z1),
];

/** A rectangle in the plane x = const (an end facing +x), from its corners in y and z. */
const endRect = (y0: number, z0: number, y1: number, z1: number, x: number): Vec[] => [
  v(x, y0, z0),
  v(x, y1, z0),
  v(x, y1, z1),
  v(x, y0, z1),
];

type BoxFills = { top: Fill; front: Fill; side: Fill };

/** The three faces of a box the camera sees. */
function box(
  x0: number,
  y0: number,
  z0: number,
  x1: number,
  y1: number,
  z1: number,
  fills: BoxFills,
  lit = false,
  tone: Tone = 'line',
): Face[] {
  return [
    { pts: wallRect(x0, z0, x1, z1, y0), fill: fills.front, lit, tone },
    { pts: endRect(y0, z0, y1, z1, x1), fill: fills.side, tone },
    { pts: rect(x0, y0, x1, y1, z1), fill: fills.top, lit, tone },
  ];
}

/** A standing cylinder: its silhouette and its top. */
function cylinder(c: Vec, r: number, height: number, fill: Fill, tone: Tone = 'line'): Face[] {
  const bottom = circle(c, r, 'z', 20);
  const top = circle(v(c.x, c.y, c.z + height), r, 'z', 20);
  return [
    { pts: [...bottom, ...top], fill, hull: true, tone },
    { pts: top, fill, tone },
  ];
}

const item = (depth: number, faces: Face[], lines: Line[] = []): Item => ({ depth, faces, lines });
const centre = (x0: number, y0: number, x1: number, y1: number) =>
  depthOf((x0 + x1) / 2, (y0 + y1) / 2);

/* ------------------------------------------------------------------------------------------ */

const STONE: BoxFills = { top: 'pave', front: 'kerb', side: 'side' };
const WALL: BoxFills = { top: 'wall', front: 'wall', side: 'side' };

/** The plinth, the road, the pavements and the gardens: everything at ground level. */
function groundLayer(): Item[] {
  const { x0, y0, x1, y1, r, depth } = PLINTH;
  const items: Item[] = [];
  // The slab: its silhouette is the hull of its top and its bottom.
  const top = roundRect(x0, y0, x1, y1, r, 0);
  const bottom = roundRect(x0, y0, x1, y1, r, -depth);
  const lip = roundRect(x0, y0, x1, y1, r, -0.28);
  items.push({
    depth: 100,
    faces: [
      { pts: [...top, ...bottom], fill: 'side', hull: true },
      { pts: top, fill: 'plinth', lit: true },
    ],
    lines: [
      { pts: roundRect(x0 + 0.4, y0 + 0.4, x1 - 0.4, y1 - 0.4, r - 0.4, 0), tone: 'line' },
      // The groove along the plinth's sides: only its front and right halves show.
      { pts: [...lip.slice(24), ...lip.slice(1, 16)], tone: 'faint' },
      // The screws in its corners.
      ...[
        [0.95, 0.95],
        [29.55, 0.95],
        [29.55, 27.05],
        [0.95, 27.05],
      ].map(([x, y]) => ({ pts: circle(v(x, y, 0), 0.2, 'z', 16), tone: 'line' as Tone })),
    ],
  });

  // The street's tray: a sunken well the road and the gardens sit in, with a lip at its right.
  const well = WELL;
  items.push(
    item(99.7, [{ pts: rect(well.x0, well.y0, well.x1, well.y1, FLOOR), fill: 'road', lit: true }]),
    // Its inner wall at the left, the only one the camera faces.
    item(99.6, [
      { pts: endRect(well.y0, FLOOR, BANDS.wall, 0, well.x0), fill: 'side', tone: 'line' },
    ]),
    // The gardens, level with the pavements.
    item(99.5, [
      { pts: rect(well.x0, BANDS.wall, well.x1, well.y1, PAVE), fill: 'grass', lit: true },
    ]),
  );
  // The road.
  items.push(
    item(
      98,
      [
        {
          pts: rect(well.x0, BANDS.nearKerb, well.x1, BANDS.farKerb, FLOOR),
          fill: 'road',
          lit: true,
          lux: true,
        },
      ],
      [
        // The centre line: a dash every 5.5 m.
        ...Array.from({ length: 5 }, (_, i) => {
          const x = 2.6 + i * 5.8;
          return { pts: [v(x, 11.8, FLOOR), v(x + 2.6, 11.8, FLOOR)], tone: 'line' as Tone };
        }),
        // A manhole, and a gully by the far kerb.
        { pts: circle(v(6.6, 13.1, FLOOR), 0.4, 'z', 22), tone: 'line' },
        { pts: circle(v(6.6, 13.1, FLOOR), 0.2, 'z', 18), tone: 'faint' },
        { pts: [...rect(17.6, 14.6, 18.6, 14.95, FLOOR), v(17.6, 14.6, FLOOR)], tone: 'faint' },
        { pts: [...rect(15.2, 8.65, 16.2, 9.0, FLOOR), v(15.2, 8.65, FLOOR)], tone: 'faint' },
      ],
    ),
  );

  // The pavements: low slabs with a joint every metre.
  const slab = (sx0: number, sy0: number, sx1: number, sy1: number, depth: number, lux = false) => {
    const h = PAVE;
    const joints: Line[] = [];
    for (let x = Math.ceil(sx0) + 0.5; x < sx1; x += 1) {
      joints.push({ pts: [v(x, sy0, h), v(x, sy1, h)], tone: 'faint' });
    }
    joints.push({ pts: [v(sx0, (sy0 + sy1) / 2, h), v(sx1, (sy0 + sy1) / 2, h)], tone: 'faint' });
    const [front, side, top] = box(sx0, sy0, FLOOR, sx1, sy1, h, STONE, true);
    return item(depth, [front, side, { ...top, lux }], joints);
  };
  items.push(slab(well.x0, BANDS.farKerb, well.x1, BANDS.wall, 97));
  // The near pavement, under the pole, is part of what the lux map covers.
  items.push(slab(well.x0, BANDS.behind, 27.6, BANDS.nearKerb, 96, true));

  // The garden paths, from the gates to the doors.
  const paths: Line[] = [];
  for (let k = 0; k < 3; k += 1) {
    const x = HOUSES.x0 + k * HOUSE + 0.7;
    paths.push({ pts: [v(x, BANDS.wall + 0.3, 0), v(x, BANDS.facade, 0)], tone: 'faint' });
    paths.push({
      pts: [v(x + 1.1, BANDS.wall + 0.3, 0), v(x + 1.1, BANDS.facade, 0)],
      tone: 'faint',
    });
    for (let y = BANDS.wall + 0.8; y < BANDS.facade; y += 0.55) {
      paths.push({ pts: [v(x, y, 0), v(x + 1.1, y, 0)], tone: 'faint' });
    }
  }
  items.push(item(95, [], paths));
  return items;
}

/** The parts tray at the front of the plinth: three LED modules, the picked one lifted. */
function trays(optic: Optic, lift: Record<Optic, number>): Item[] {
  const out: Item[] = [];
  const optics: Optic[] = ['narrow', 'wide', 'asymmetric'];
  optics.forEach((name, i) => {
    const x0 = 1.2 + i * 4.1;
    const x1 = x0 + 3.7;
    const y0 = 0.85;
    const y1 = 3.05;
    // The recess the module sits in.
    out.push(
      item(90, [{ pts: roundRect(x0, y0, x1, y1, 0.3, 0), fill: 'recess', tone: 'line' }], []),
    );
    const up = lift[name];
    const z0 = up * 0.8;
    const z1 = z0 + 0.16;
    const mx0 = x0 + 0.2;
    const mx1 = x1 - 0.2;
    const my0 = y0 + 0.2;
    const my1 = y1 - 0.2;
    const tone: Tone = up > 0.02 ? 'bright' : 'line';
    const faces: Face[] = box(
      mx0,
      my0,
      z0,
      mx1,
      my1,
      z1,
      { top: 'module', front: 'module', side: 'module' },
      false,
      tone,
    );
    const lines: Line[] = [];
    for (let row = 0; row < 2; row += 1) {
      for (let col = 0; col < 5; col += 1) {
        const c = v(mx0 + 0.38 + col * 0.6, my0 + 0.45 + row * 0.9, z1);
        lines.push({ pts: circle(c, 0.24, 'z', 18), tone });
        if (name === 'narrow')
          lines.push({ pts: circle(v(c.x, c.y, c.z + 0.1), 0.2, 'z', 16), tone });
        if (name === 'asymmetric') {
          lines.push({ pts: circle(v(c.x, c.y + 0.06, c.z + 0.12), 0.22, 'z', 18), tone });
        }
        if (name === 'wide') lines.push({ pts: circle(c, 0.1, 'z', 12), tone });
      }
    }
    out.push({ depth: up > 0.02 ? -50 : 89, faces, lines, picked: up > 0.02 });
  });

  // The engraved plate and the scale bar.
  out.push(
    item(88, box(13.4, 1.15, 0, 17.6, 2.45, 0.06, { top: 'module', front: 'kerb', side: 'side' }), [
      { pts: [v(13.85, 1.55, 0.06), v(15.9, 1.55, 0.06)], tone: 'faint' },
      { pts: [v(13.85, 1.95, 0.06), v(15.0, 1.95, 0.06)], tone: 'faint' },
      { pts: [v(16.0, 1.95, 0.06), v(17.1, 1.95, 0.06)], tone: 'faint' },
    ]),
  );
  const ruler: Line[] = [];
  for (let i = 0; i <= 10; i += 1) {
    const x = 18.4 + i;
    const long = i % 5 === 0;
    ruler.push({ pts: [v(x, 0.55, 0.04), v(x, long ? 0.95 : 0.75, 0.04)], tone: 'line' });
  }
  out.push(
    item(
      87,
      box(
        18.1,
        0.45,
        0,
        28.7,
        1.1,
        0.04,
        { top: 'module', front: 'kerb', side: 'side' },
        false,
        'faint',
      ),
      ruler,
    ),
  );
  return out;
}

/** The low garden walls, with pillars at each gate. */
function walls(): Item[] {
  const out: Item[] = [];
  const y0 = BANDS.wall;
  const y1 = y0 + 0.26;
  const h = 0.62;
  const gate = (k: number) => HOUSES.x0 + k * HOUSE;
  // Front walls, cut at each gate.
  const runs: [number, number][] = [[WELL.x0, HOUSES.x0]];
  for (let k = 0; k < 3; k += 1) {
    runs.push([gate(k), gate(k) + 0.55]);
    runs.push([gate(k) + 1.95, gate(k + 1)]);
  }
  runs.push([HOUSES.x1, 28]);
  for (const [a, b] of runs) {
    // Long runs in pieces, so far-to-near painting holds.
    const pieces = Math.max(1, Math.round((b - a) / 2.8));
    for (let i = 0; i < pieces; i += 1) {
      const pa = a + ((b - a) * i) / pieces;
      const pb = a + ((b - a) * (i + 1)) / pieces;
      out.push(item(centre(pa, y0, pb, y1), box(pa, y0, PAVE, pb, y1, PAVE + h, WALL, true)));
    }
  }
  // Pillars either side of each gate.
  for (let k = 0; k < 3; k += 1) {
    for (const px of [gate(k) + 0.45, gate(k) + 1.95]) {
      out.push(
        item(centre(px, y0 - 0.04, px + 0.32, y1 + 0.04) - 0.01, [
          ...box(px, y0 - 0.04, PAVE, px + 0.32, y1 + 0.04, PAVE + 0.9, WALL, true),
          ...box(px - 0.04, y0 - 0.08, PAVE + 0.9, px + 0.36, y1 + 0.08, PAVE + 1.0, WALL, true),
        ]),
      );
    }
  }
  // Walls between the gardens, at the left end, and the long one at the right.
  for (const x of [WELL.x0, HOUSES.x0, gate(1), gate(2), HOUSES.x1 - 0.24, 27.75]) {
    const end = x > 27 ? WELL.y1 : BANDS.facade;
    const parts = Math.max(1, Math.round((end - y1) / 2.5));
    for (let i = 0; i < parts; i += 1) {
      const a = y1 + ((end - y1) * i) / parts;
      const b = y1 + ((end - y1) * (i + 1)) / parts;
      out.push(item(centre(x, a, x + 0.24, b), box(x, a, 0, x + 0.24, b, h, WALL, true)));
    }
  }
  return out;
}

/** The row of three houses: facade, gable, roof, chimneys, windows and doors. */
function houses(): Item {
  const { x0, x1, y0, y1 } = HOUSES;
  const eaves = EAVES;
  const ridge = RIDGE;
  const ridgeY = (y0 + y1) / 2;
  const faces: Face[] = [];
  const lines: Line[] = [];
  const f = y0 - 0.01;

  faces.push({ pts: wallRect(x0, 0, x1, eaves, y0), fill: 'facade', lit: true, lux: true });
  faces.push({
    pts: [v(x1, y0, 0), v(x1, y1, 0), v(x1, y1, eaves), v(x1, ridgeY, ridge), v(x1, y0, eaves)],
    fill: 'side',
  });

  // Windows: a frame with a sill, a lintel and glazing bars.
  const win = (wx0: number, wz0: number, wx1: number, wz1: number) => {
    faces.push({ pts: wallRect(wx0, wz0, wx1, wz1, f), fill: 'glass', alpha: 0.72 });
    const mid = (wx0 + wx1) / 2;
    const bar = wz0 + (wz1 - wz0) * 0.6;
    const i = 0.07;
    lines.push(
      {
        pts: [
          v(wx0 + i, f, wz0 + i),
          v(wx1 - i, f, wz0 + i),
          v(wx1 - i, f, wz1 - i),
          v(wx0 + i, f, wz1 - i),
          v(wx0 + i, f, wz0 + i),
        ],
        tone: 'line',
      },
      { pts: [v(mid, f, wz0 + i), v(mid, f, wz1 - i)], tone: 'line' },
      { pts: [v(wx0 + i, f, bar), v(wx1 - i, f, bar)], tone: 'line' },
    );
    // The sill sticks out a little; so does the lintel.
    faces.push(...box(wx0 - 0.12, y0 - 0.14, wz0 - 0.12, wx1 + 0.12, y0, wz0, WALL, true));
    faces.push(...box(wx0 - 0.08, y0 - 0.06, wz1 + 0.08, wx1 + 0.08, y0, wz1 + 0.2, WALL, true));
  };
  const door = (dx0: number, dx1: number) => {
    faces.push({ pts: wallRect(dx0, 0.15, dx1, 2.0, f), fill: 'glass', alpha: 0.8 });
    lines.push(
      { pts: [v(dx1 - 0.18, f, 0.95), v(dx1 - 0.18, f, 1.0)], tone: 'line' },
      { pts: [v(dx0 + 0.15, f, 1.6), v(dx1 - 0.15, f, 1.6)], tone: 'faint' },
    );
    faces.push(...box(dx0 - 0.08, y0 - 0.06, 2.08, dx1 + 0.08, y0, 2.28, WALL, true));
    faces.push(...box(dx0 - 0.12, y0 - 0.32, 0, dx1 + 0.12, y0, 0.15, STONE, true));
  };
  for (let k = 0; k < 3; k += 1) {
    const hx = x0 + k * HOUSE;
    door(hx + 0.75, hx + 1.7);
    win(hx + 3.3, 0.85, hx + 4.65, 1.95);
    win(hx + 0.75, 2.85, hx + 1.85, 3.95);
    win(hx + 3.4, 2.85, hx + 4.6, 3.95);
  }
  // Party walls and the band between the floors.
  for (const x of [x0 + HOUSE, x0 + HOUSE * 2]) {
    faces.push(...box(x - 0.09, y0 - 0.06, 0, x + 0.09, y0, eaves, WALL, true));
  }
  lines.push({ pts: [v(x0, f, 0.3), v(x1, f, 0.3)], tone: 'faint' });
  lines.push({ pts: [v(x0, f, 2.5), v(x1, f, 2.5)], tone: 'faint' });
  // A window in the gable end.
  lines.push(
    { pts: [...endRect(22.6, 3.0, 23.5, 3.9, x1 + 0.01), v(x1 + 0.01, 22.6, 3.0)], tone: 'line' },
    { pts: [v(x1 + 0.01, 23.05, 3.0), v(x1 + 0.01, 23.05, 3.9)], tone: 'line' },
  );

  // The roof: its front slope with courses of slates, and the barge at the gable.
  const ox0 = x0 - 0.2;
  const ox1 = x1 + 0.3;
  const front = y0 - 0.22;
  const drop = (ridge - eaves) / (ridgeY - y0);
  const zAt = (y: number) => eaves + (y - y0) * drop;
  faces.push({
    pts: [
      v(ox0, front, zAt(front)),
      v(ox1, front, zAt(front)),
      v(ox1, ridgeY, ridge),
      v(ox0, ridgeY, ridge),
    ],
    fill: 'roof',
  });
  const back = y1 + 0.22;
  faces.push({
    pts: [
      v(ox1, front, zAt(front)),
      v(ox1, ridgeY, ridge),
      v(ox1, back, zAt(front)),
      v(ox1, back, zAt(front) - 0.16),
      v(ox1, ridgeY, ridge - 0.16),
      v(ox1, front, zAt(front) - 0.16),
    ],
    fill: 'side',
  });
  for (let i = 1; i < 8; i += 1) {
    const y = front + ((ridgeY - front) * i) / 8;
    lines.push({ pts: [v(ox0, y, zAt(y)), v(ox1, y, zAt(y))], tone: 'faint' });
  }
  // Chimneys on the ridge, each with two pots.
  for (const cx of [x0 + 0.45, x0 + HOUSE, x0 + HOUSE * 2, x1 - 0.45]) {
    const a = cx - 0.4;
    const b = cx + 0.4;
    const cy0 = ridgeY - 0.32;
    const cy1 = ridgeY + 0.32;
    faces.push(...box(a, cy0, zAt(cy0), b, cy1, ridge + 0.9, WALL));
    faces.push(...box(a - 0.07, cy0 - 0.07, ridge + 0.9, b + 0.07, cy1 + 0.07, ridge + 1.02, WALL));
    faces.push(...cylinder(v(cx - 0.17, ridgeY, ridge + 1.02), 0.11, 0.2, 'wall'));
    faces.push(...cylinder(v(cx + 0.17, ridgeY, ridge + 1.02), 0.11, 0.2, 'wall'));
  }
  return { depth: centre(x0, y0, x1, y1), faces, lines };
}

/** The garden shed at the left. */
function shed(): Item {
  const x0 = 2.0;
  const x1 = 4.1;
  const y0 = 20.0;
  const y1 = 21.8;
  const h = 1.6;
  const peak = 2.35;
  const ry = (y0 + y1) / 2;
  const faces: Face[] = [
    { pts: wallRect(x0, 0, x1, h, y0), fill: 'shed', lit: true },
    {
      pts: [v(x1, y0, 0), v(x1, y1, 0), v(x1, y1, h), v(x1, ry, peak), v(x1, y0, h)],
      fill: 'side',
    },
    {
      pts: [
        v(x0 - 0.15, y0 - 0.2, h - 0.12),
        v(x1 + 0.15, y0 - 0.2, h - 0.12),
        v(x1 + 0.15, ry, peak),
        v(x0 - 0.15, ry, peak),
      ],
      fill: 'roof',
    },
    {
      pts: [
        v(x1 + 0.15, y0 - 0.2, h - 0.12),
        v(x1 + 0.15, ry, peak),
        v(x1 + 0.15, y1 + 0.2, h - 0.12),
      ],
      fill: 'side',
    },
  ];
  const f = y0 - 0.01;
  const lines: Line[] = [
    {
      pts: [v(2.45, f, 0.75), v(3.2, f, 0.75), v(3.2, f, 1.3), v(2.45, f, 1.3), v(2.45, f, 0.75)],
      tone: 'line',
    },
    { pts: [v(2.82, f, 0.75), v(2.82, f, 1.3)], tone: 'line' },
    {
      pts: [
        v(x1 + 0.01, 20.5, 0),
        v(x1 + 0.01, 20.5, 1.35),
        v(x1 + 0.01, 21.2, 1.35),
        v(x1 + 0.01, 21.2, 0),
      ],
      tone: 'line',
    },
  ];
  return { depth: centre(x0, y0, x1, y1), faces, lines };
}

/** The round tree in the end garden. */
function tree(): Item {
  const c = v(25.9, 19.6, 0);
  return {
    depth: depthOf(c.x, c.y),
    faces: cylinder(c, 0.1, 3.0, 'metal'),
    lines: [],
    sphere: { c: v(c.x, c.y, 4.25), r: 1.4 },
  };
}

/** The feeder pillar on the pavement, beside the pole. */
export const PILLAR = { x0: 16.7, y0: 6.85, x1: 17.35, y1: 7.35, z0: PAVE, z1: 1.35 };
function pillar(): Item {
  const { x0, y0, x1, y1, z0, z1 } = PILLAR;
  const f = y0 - 0.01;
  const metal: BoxFills = { top: 'metal', front: 'metal', side: 'side' };
  return item(
    depthOf((x0 + x1) / 2, (y0 + y1) / 2),
    [
      ...box(x0, y0, z0, x1, y1, z1, metal, true),
      ...box(x0 - 0.05, y0 - 0.05, z1, x1 + 0.05, y1 + 0.05, z1 + 0.07, metal),
    ],
    [
      {
        pts: [
          v(x0 + 0.09, f, z0 + 0.12),
          v(x1 - 0.09, f, z0 + 0.12),
          v(x1 - 0.09, f, z1 - 0.1),
          v(x0 + 0.09, f, z1 - 0.1),
          v(x0 + 0.09, f, z0 + 0.12),
        ],
        tone: 'line',
      },
      { pts: [v(x1 - 0.18, f, 0.75), v(x1 - 0.18, f, 0.85)], tone: 'line' },
    ],
  );
}

/** Where the lantern's lens is, for a pole of `height` and a head tilted by `tilt` radians. */
export function headPoints(height: number, tilt: number) {
  const base = v(POLE.x, POLE.y, PAVE);
  const lens = lanternAt(height);
  const top = v(POLE.x, POLE.y, lens.z - 0.35);
  // The head: a flat lozenge along y, turned by the tilt about the street axis.
  const outline: Vec[] = [];
  const glass: Vec[] = [];
  const ct = Math.cos(tilt);
  const st = Math.sin(tilt);
  const place = (along: number, across: number, up: number): Vec => ({
    x: lens.x + across,
    y: lens.y + along * ct - up * st,
    z: lens.z + along * st + up * ct,
  });
  for (let i = 0; i <= 28; i += 1) {
    const a = (i / 28) * Math.PI * 2;
    // A lens shape, pointed at both ends.
    const pinch = 1 - 0.45 * Math.cos(a) ** 2;
    outline.push(place(Math.cos(a) * 0.72, Math.sin(a) * 0.3 * pinch, 0.06));
    glass.push(place(Math.cos(a) * 0.44 + 0.06, Math.sin(a) * 0.15 * pinch, -0.02));
  }
  return { base, top, lens, outline, glass, place };
}

/** The pole, its arm and its head. */
function pole(height: number, tilt: number): Item {
  const { base, top, outline, glass, place } = headPoints(height, tilt);
  const faces: Face[] = [
    ...box(base.x - 0.22, base.y - 0.22, base.z, base.x + 0.22, base.y + 0.22, base.z + 0.3, {
      top: 'metal',
      front: 'metal',
      side: 'side',
    }),
    ...cylinder(v(base.x, base.y, base.z + 0.3), 0.11, 0.9, 'metal'),
    ...cylinder(v(base.x, base.y, base.z + 1.2), 0.075, top.z - base.z - 1.2, 'metal'),
    ...cylinder(v(base.x, base.y, height * 0.42), 0.1, 0.35, 'metal'),
  ];
  // The arm: a curve from the top of the pole out to the back of the head.
  const arm: Vec[] = [];
  const end = place(-0.55, 0, 0.04);
  for (let i = 0; i <= 12; i += 1) {
    const t = i / 12;
    const u = 1 - t;
    const ctrl = v(POLE.x, POLE.y, top.z + 0.47);
    arm.push({
      x: POLE.x,
      y: u * u * top.y + 2 * u * t * ctrl.y + t * t * end.y,
      z: u * u * top.z + 2 * u * t * ctrl.z + t * t * end.z,
    });
  }
  const below: Vec[] = arm.map((p) => ({ ...p, z: p.z - 0.09 }));
  faces.push({ pts: [...arm, ...below.reverse()], fill: 'metal' });
  faces.push({ pts: outline, fill: 'metal', tone: 'bright' });
  return {
    depth: depthOf(POLE.x, POLE.y),
    faces,
    lines: [{ pts: glass, tone: 'bright' }],
  };
}

export type Layout = { ground: Item[]; items: Item[] };

/** The whole diorama, for the pole's height (m), the tilt (radians) and the picked module. */
export function build(
  height: number,
  tilt: number,
  optic: Optic,
  lift: Record<Optic, number>,
): Layout {
  const ground = groundLayer();
  const items: Item[] = [...walls(), houses(), shed(), tree(), pillar(), pole(height, tilt)];
  const tray = trays(optic, lift);
  for (const t of tray) (t.picked ? items : ground).push(t);
  items.sort((a, b) => b.depth - a.depth);
  ground.sort((a, b) => b.depth - a.depth);
  return { ground, items };
}
```

### street-light/renderer.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * Paints the diorama on a canvas at the device's pixel ratio: fills, light, hairlines, then the
 * glow of the lantern. Nothing here holds React state; street-light.tsx calls paint() with the
 * current lantern and blends whenever something moved.
 *
 * - The light is a texture of illuminance. The ground has one, 0.25 m a texel, laid on every
 *   horizontal face through the affine map of the ground plane; each lit wall has its own. A texel
 *   is either warm light (its strength eased from the lux) or a colour of the lux map, on a
 *   logarithmic scale from 0.5 to 100 lx; the two cross-fade.
 * - Isolux lines (1, 2, 5, 10, 20, 50, 100, 200 lx) come from marching squares over the same
 *   grids, faint in the warm view, white on the map.
 * - Every face is opaque and painted far to near, so the light on a face hides with it.
 * - The glow: a soft cone from the lens to the pool it lights (an ellipse from the moments of the
 *   ground's illuminance), a halo on the lens, and a haze in the sky above the roofs, as strong
 *   as the share of the flux that goes up or hits the houses.
 */

import { BANDS, illuminance, type Lantern, type Vec } from './photometry';
import {
  FLOOR,
  PAVE,
  PILLAR,
  PLINTH,
  WELL,
  project,
  type Face,
  type Fill,
  type Item,
  type Layout,
  type Tone,
} from './scene';

type RGB = [number, number, number];

export type Palette = {
  fills: Record<Fill, string>;
  tones: Record<Exclude<Tone, 'none'>, string>;
  /** The warm light on surfaces, and how it is laid: added on a dark scheme, tinted on light. */
  warm: RGB;
  glow: RGB;
  additive: boolean;
  crown: [string, string];
  shadow: string;
};

export const PALETTES: Record<'dark' | 'light', Palette> = {
  dark: {
    fills: {
      plinth: '#151515',
      side: '#111111',
      road: '#0a0a0a',
      pave: '#141414',
      kerb: '#121212',
      grass: '#0f0f0f',
      wall: '#161616',
      facade: '#121212',
      roof: '#131313',
      glass: '#090909',
      shed: '#141414',
      metal: '#151515',
      recess: '#0b0b0b',
      module: '#181818',
      lens: '#1d1d1d',
    },
    tones: {
      line: 'rgba(255, 255, 255, 0.3)',
      faint: 'rgba(255, 255, 255, 0.075)',
      bright: '#e9e9e9',
    },
    warm: [236, 211, 168],
    glow: [255, 226, 180],
    additive: true,
    crown: ['#262626', '#0c0c0c'],
    shadow: 'rgba(0, 0, 0, 0.75)',
  },
  light: {
    fills: {
      plinth: '#f4f3ef',
      side: '#e4e2dc',
      road: '#dddbd4',
      pave: '#efede8',
      kerb: '#e9e7e1',
      grass: '#ebe9e3',
      wall: '#f2f0eb',
      facade: '#f0eee9',
      roof: '#ecebe6',
      glass: '#d6d4cd',
      shed: '#efede8',
      metal: '#efede8',
      recess: '#dcdad3',
      module: '#f7f6f2',
      lens: '#ecebe6',
    },
    tones: { line: 'rgba(0, 0, 0, 0.42)', faint: 'rgba(0, 0, 0, 0.12)', bright: '#151515' },
    warm: [232, 165, 70],
    glow: [240, 170, 70],
    additive: false,
    crown: ['#f5f4f0', '#d9d7d0'],
    shadow: 'rgba(60, 50, 30, 0.22)',
  },
};

/** The lux map's colours, low to high: deep violet, magenta, coral, orange, yellow, cream. */
const RAMP: [number, RGB][] = [
  [0, [40, 10, 82]],
  [0.2, [92, 22, 150]],
  [0.4, [172, 44, 150]],
  [0.55, [228, 76, 118]],
  [0.7, [246, 128, 72]],
  [0.85, [252, 196, 92]],
  [1, [255, 246, 214]],
];
function ramp(t: number): RGB {
  for (let i = 1; i < RAMP.length; i += 1) {
    const [b, cb] = RAMP[i];
    if (t <= b) {
      const [a, ca] = RAMP[i - 1];
      const f = (t - a) / (b - a);
      return [
        ca[0] + (cb[0] - ca[0]) * f,
        ca[1] + (cb[1] - ca[1]) * f,
        ca[2] + (cb[2] - ca[2]) * f,
      ];
    }
  }
  return RAMP[RAMP.length - 1][1];
}
const smooth = (a: number, b: number, x: number) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};
/** How strongly a surface glows warm, for its lux. */
const warmth = (e: number) => {
  const p = Math.pow(Math.max(e, 0), 1.5);
  return (0.86 * p) / (p + 42);
};
/** Where a lux value sits on the map's scale, and how opaque it is there. */
const mapT = (e: number) => Math.min(1, Math.max(0, (Math.log10(Math.max(e, 1e-3)) + 0.3) / 2.3));
const mapA = (e: number) => smooth(0.5, 1.8, e) * 0.94;

/** How much of the warm light stays on the faces the lux map leaves out, once it is on. */
const UNDER_MAP = 0.3;

export const LEVELS = [1, 2, 5, 10, 20, 50, 100, 200];

/** The ground texture's extent, in metres, and its texel. */
/** The figure's design size, its scale (px a metre) and where the world's origin falls. */
const FIGURE = { w: 1080, h: 896, k: 23.5, ox: 63.4, oy: 597.4 };
const G = { x0: -2, y0: -2, x1: 32.5, y1: 29.5, res: 0.25 };
const GW = Math.round((G.x1 - G.x0) / G.res);
const GH = Math.round((G.y1 - G.y0) / G.res);

type Segment = [number, number, number, number];

/** Marching squares over a grid of values, at one level: segments in texel units. */
function contours(values: Float32Array, w: number, h: number, level: number, out: Segment[]) {
  for (let j = 0; j < h - 1; j += 1) {
    for (let i = 0; i < w - 1; i += 1) {
      const a = values[j * w + i];
      const b = values[j * w + i + 1];
      const c = values[(j + 1) * w + i + 1];
      const d = values[(j + 1) * w + i];
      const code =
        (a >= level ? 1 : 0) | (b >= level ? 2 : 0) | (c >= level ? 4 : 0) | (d >= level ? 8 : 0);
      if (code === 0 || code === 15) continue;
      const lerp = (p: number, q: number) => (level - p) / (q - p);
      const top: [number, number] = [i + lerp(a, b), j];
      const right: [number, number] = [i + 1, j + lerp(b, c)];
      const bottom: [number, number] = [i + lerp(d, c), j + 1];
      const left: [number, number] = [i, j + lerp(a, d)];
      const add = (p: [number, number], q: [number, number]) => out.push([p[0], p[1], q[0], q[1]]);
      switch (code) {
        case 1:
        case 14:
          add(left, top);
          break;
        case 2:
        case 13:
          add(top, right);
          break;
        case 3:
        case 12:
          add(left, right);
          break;
        case 4:
        case 11:
          add(right, bottom);
          break;
        case 6:
        case 9:
          add(top, bottom);
          break;
        case 7:
        case 8:
          add(left, bottom);
          break;
        case 5:
          add(left, top);
          add(right, bottom);
          break;
        case 10:
          add(top, right);
          add(left, bottom);
          break;
      }
    }
  }
}

/** A lit wall's own texture. */
type WallLight = {
  canvas: HTMLCanvasElement;
  image: ImageData;
  values: Float32Array;
  w: number;
  h: number;
  segments: Segment[];
  version: number;
};

export type Frame = {
  lantern: Lantern;
  /** 0 the warm view, 1 the lux map. */
  map: number;
  /** Shares of the flux that go up and that hit the houses, for the sky's haze. */
  sky: number;
  houses: number;
};

export type GroundStats = { roadAvg: number; roadPeak: number; window: number };

export class Renderer {
  private ctx: CanvasRenderingContext2D;
  private warmCanvas = document.createElement('canvas');
  private mapCanvas = document.createElement('canvas');
  private warmImage: ImageData;
  private mapImage: ImageData;
  private values = new Float32Array(GW * GH);
  private groundSegments: Segment[] = [];
  private walls = new Map<string, WallLight>();
  private version = 0;
  private lightKey = '';
  private pool = { cx: 0, cy: 0, a: 1, b: 1, angle: 0, sum: 0 };
  /** CSS px of the canvas, and the view's scale and offset. */
  private w = 0;
  private h = 0;
  private k = 1;
  private ox = 0;
  private oy = 0;
  palette: Palette = PALETTES.dark;

  constructor(private canvas: HTMLCanvasElement) {
    this.ctx = canvas.getContext('2d')!;
    for (const c of [this.warmCanvas, this.mapCanvas]) {
      c.width = GW;
      c.height = GH;
    }
    this.warmImage = new ImageData(GW, GH);
    this.mapImage = new ImageData(GW, GH);
  }

  /** Fits the drawing to the canvas's CSS size. */
  resize(width: number, height: number) {
    const dpr = Math.min(2.5, window.devicePixelRatio || 1);
    this.w = width;
    this.h = height;
    this.canvas.width = Math.round(width * dpr);
    this.canvas.height = Math.round(height * dpr);
    // The figure is designed 1080 × 896 px: 23.5 px a metre, the world's origin (the plinth's
    // front-left corner) at 63.4, 597.4. Other sizes scale it, centred.
    const scale = Math.min(width / FIGURE.w, height / FIGURE.h);
    this.k = FIGURE.k * scale;
    this.ox = width / 2 + (FIGURE.ox - FIGURE.w / 2) * scale;
    this.oy = height / 2 + (FIGURE.oy - FIGURE.h / 2) * scale;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  /** World to canvas CSS px. */
  private px(p: Vec): [number, number] {
    const [u, v] = project(p);
    return [this.ox + this.k * u, this.oy + this.k * v];
  }

  /** Forgets the light textures: their colours change with the palette. */
  invalidate() {
    this.lightKey = '';
    this.walls.clear();
  }

  /** Recomputes the light textures, when the lantern moved. */
  private light(lantern: Lantern) {
    const key = `${lantern.at.z.toFixed(4)}|${lantern.tilt.toFixed(5)}|${lantern.mix.narrow.toFixed(4)}|${lantern.mix.wide.toFixed(4)}|${lantern.power.toFixed(4)}`;
    if (key === this.lightKey) return false;
    this.lightKey = key;
    this.version += 1;
    const up = { x: 0, y: 0, z: 1 };
    const { values } = this;
    const warm = this.warmImage.data;
    const lux = this.mapImage.data;
    const pal = this.palette;
    let sum = 0;
    let sx = 0;
    let sy = 0;
    let sxx = 0;
    let syy = 0;
    let sxy = 0;
    for (let j = 0; j < GH; j += 1) {
      const y = G.y0 + (j + 0.5) * G.res;
      for (let i = 0; i < GW; i += 1) {
        const x = G.x0 + (i + 0.5) * G.res;
        const e = illuminance(lantern, { x, y, z: 0 }, up);
        const n = j * GW + i;
        values[n] = e;
        const o = n * 4;
        const a = warmth(e);
        warm[o] = pal.warm[0];
        warm[o + 1] = pal.warm[1];
        warm[o + 2] = pal.warm[2];
        warm[o + 3] = a * 255;
        const inWell = x > WELL.x0 && x < WELL.x1 && y > WELL.y0 && y < WELL.y1;
        const [r, g, b] = ramp(mapT(e));
        lux[o] = r;
        lux[o + 1] = g;
        lux[o + 2] = b;
        lux[o + 3] = inWell ? mapA(e) * 255 : 0;
        if (inWell) {
          sum += e;
          sx += e * x;
          sy += e * y;
          sxx += e * x * x;
          syy += e * y * y;
          sxy += e * x * y;
        }
      }
    }
    this.warmCanvas.getContext('2d')!.putImageData(this.warmImage, 0, 0);
    this.mapCanvas.getContext('2d')!.putImageData(this.mapImage, 0, 0);
    // The pool: the mean and spread of the light in the well.
    if (sum > 0) {
      const cx = sx / sum;
      const cy = sy / sum;
      const vxx = sxx / sum - cx * cx;
      const vyy = syy / sum - cy * cy;
      const vxy = sxy / sum - cx * cy;
      const tr = (vxx + vyy) / 2;
      const det = Math.sqrt(Math.max(0, ((vxx - vyy) / 2) ** 2 + vxy * vxy));
      this.pool = {
        cx,
        cy,
        a: Math.sqrt(Math.max(0.01, tr + det)),
        b: Math.sqrt(Math.max(0.01, tr - det)),
        angle: 0.5 * Math.atan2(2 * vxy, vxx - vyy),
        sum,
      };
    }
    this.groundSegments = [];
    for (const level of LEVELS) contours(values, GW, GH, level, this.groundSegments);
    return true;
  }

  /** Road average and peak, and the bedroom window's lux. */
  stats(lantern: Lantern, window: Vec): GroundStats {
    let sum = 0;
    let count = 0;
    let peak = 0;
    for (let j = 0; j < GH; j += 1) {
      const y = G.y0 + (j + 0.5) * G.res;
      if (y < BANDS.nearKerb || y > BANDS.farKerb) continue;
      for (let i = 0; i < GW; i += 1) {
        const x = G.x0 + (i + 0.5) * G.res;
        if (x < WELL.x0 || x > WELL.x1) continue;
        const e = this.values[j * GW + i];
        sum += e;
        count += 1;
        if (e > peak) peak = e;
      }
    }
    return {
      roadAvg: count ? sum / count : 0,
      roadPeak: peak,
      window: illuminance(lantern, window, { x: 0, y: -1, z: 0 }),
    };
  }

  private path(points: [number, number][], close: boolean) {
    const { ctx } = this;
    ctx.beginPath();
    points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
    if (close) ctx.closePath();
  }

  private hull(points: [number, number][]): [number, number][] {
    const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
    const cross = (o: number[], a: number[], b: number[]) =>
      (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
    const lower: [number, number][] = [];
    for (const p of pts) {
      while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
        lower.pop();
      lower.push(p);
    }
    const upper: [number, number][] = [];
    for (let i = pts.length - 1; i >= 0; i -= 1) {
      const p = pts[i];
      while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
        upper.pop();
      upper.push(p);
    }
    return [...lower.slice(0, -1), ...upper.slice(0, -1)];
  }

  /** The affine map of the ground plane at height z, for drawing a ground texture. */
  private groundTransform(z: number) {
    const o = this.px({ x: G.x0, y: G.y0, z });
    const ex = this.px({ x: G.x0 + G.res, y: G.y0, z });
    const ey = this.px({ x: G.x0, y: G.y0 + G.res, z });
    return [ex[0] - o[0], ex[1] - o[1], ey[0] - o[0], ey[1] - o[1], o[0], o[1]] as const;
  }

  /** A lit wall's texture: illuminance over the face, a texel per 0.25 m or so. */
  private wall(face: Face, lantern: Lantern, map: number): WallLight {
    const [p0, p1, , p3] = face.pts;
    const key = face.pts
      .map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)},${p.z.toFixed(2)}`)
      .join(';');
    const U = { x: p1.x - p0.x, y: p1.y - p0.y, z: p1.z - p0.z };
    const V = { x: p3.x - p0.x, y: p3.y - p0.y, z: p3.z - p0.z };
    let entry = this.walls.get(key);
    if (!entry) {
      const w = Math.max(2, Math.min(96, Math.ceil(Math.hypot(U.x, U.y, U.z) / 0.25)));
      const h = Math.max(2, Math.min(48, Math.ceil(Math.hypot(V.x, V.y, V.z) / 0.25)));
      const canvas = document.createElement('canvas');
      canvas.width = w;
      canvas.height = h;
      entry = {
        canvas,
        image: new ImageData(w, h),
        values: new Float32Array(w * h),
        w,
        h,
        segments: [],
        version: -1,
      };
      this.walls.set(key, entry);
    }
    const tag = this.version * 1000 + Math.round(map * 999);
    if (entry.version === tag) return entry;
    entry.version = tag;
    // The face's normal, towards the camera's side.
    let n = {
      x: U.y * V.z - U.z * V.y,
      y: U.z * V.x - U.x * V.z,
      z: U.x * V.y - U.y * V.x,
    };
    const len = Math.hypot(n.x, n.y, n.z) || 1;
    n = { x: n.x / len, y: n.y / len, z: n.z / len };
    if (n.y > 0 || (Math.abs(n.y) < 1e-6 && n.x < 0)) n = { x: -n.x, y: -n.y, z: -n.z };
    const { w, h, values } = entry;
    const data = entry.image.data;
    const pal = this.palette;
    for (let j = 0; j < h; j += 1) {
      const fv = (j + 0.5) / h;
      for (let i = 0; i < w; i += 1) {
        const fu = (i + 0.5) / w;
        const p = {
          x: p0.x + U.x * fu + V.x * fv,
          y: p0.y + U.y * fu + V.y * fv,
          z: p0.z + U.z * fu + V.z * fv,
        };
        const e = illuminance(
          lantern,
          { x: p.x + n.x * 0.02, y: p.y + n.y * 0.02, z: p.z + n.z * 0.02 },
          n,
        );
        values[j * w + i] = e;
        const aw = warmth(e) * (1 - map);
        const al = mapA(e) * map;
        const [r, g, b] = ramp(mapT(e));
        const a = aw + al;
        const o = (j * w + i) * 4;
        data[o] = a > 0 ? (pal.warm[0] * aw + r * al) / a : 0;
        data[o + 1] = a > 0 ? (pal.warm[1] * aw + g * al) / a : 0;
        data[o + 2] = a > 0 ? (pal.warm[2] * aw + b * al) / a : 0;
        data[o + 3] = Math.min(1, a) * 255;
      }
    }
    entry.canvas.getContext('2d')!.putImageData(entry.image, 0, 0);
    entry.segments = [];
    for (const level of LEVELS) contours(values, w, h, level, entry.segments);
    return entry;
  }

  private stroke(tone: Tone | undefined) {
    const { ctx } = this;
    if (tone === 'none') return;
    ctx.strokeStyle = this.palette.tones[tone ?? 'line'];
    ctx.stroke();
  }

  private contourStyle(map: number, power: number, fade = 1) {
    const glow = this.palette.additive ? [250, 232, 200] : [150, 100, 40];
    const lum = this.palette.additive ? [255, 255, 255] : [255, 255, 255];
    const r = glow[0] + (lum[0] - glow[0]) * map;
    const g = glow[1] + (lum[1] - glow[1]) * map;
    const b = glow[2] + (lum[2] - glow[2]) * map;
    const a = (0.2 + 0.62 * map) * Math.min(1, power * 1.5) * fade;
    return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a.toFixed(3)})`;
  }

  /** Draws the ground's contours, in the current clip. */
  private groundContours(map: number, power: number, fade = 1, z = 0) {
    const { ctx } = this;
    const [a, b, c, d, e, f] = this.groundTransform(z);
    ctx.beginPath();
    for (const [x0, y0, x1, y1] of this.groundSegments) {
      // Texel centres: +0.5.
      ctx.moveTo(e + a * (x0 + 0.5) + c * (y0 + 0.5), f + b * (x0 + 0.5) + d * (y0 + 0.5));
      ctx.lineTo(e + a * (x1 + 0.5) + c * (y1 + 0.5), f + b * (x1 + 0.5) + d * (y1 + 0.5));
    }
    ctx.lineWidth = 1;
    ctx.strokeStyle = this.contourStyle(map, power, fade);
    ctx.stroke();
  }

  private face(face: Face, frame: Frame) {
    const { ctx } = this;
    let pts = face.pts.map((p) => this.px(p));
    if (face.hull) pts = this.hull(pts);
    this.path(pts, true);
    ctx.fillStyle = this.palette.fills[face.fill];
    if (face.alpha !== undefined) ctx.globalAlpha = face.alpha;
    ctx.fill();
    ctx.globalAlpha = 1;
    if (face.lit && frame.lantern.power > 0.001) {
      const flat = face.pts.every((p) => Math.abs(p.z - face.pts[0].z) < 1e-6);
      ctx.save();
      ctx.clip();
      if (flat) {
        const [a, b, c, d, e, f] = this.groundTransform(face.pts[0].z);
        ctx.save();
        ctx.transform(a, b, c, d, e, f);
        ctx.imageSmoothingEnabled = true;
        const warm = face.lux ? 1 - frame.map : 1 - UNDER_MAP * frame.map;
        if (warm > 0) {
          ctx.globalAlpha = warm;
          ctx.drawImage(this.warmCanvas, 0, 0);
        }
        if (frame.map > 0 && face.lux) {
          ctx.globalAlpha = frame.map;
          ctx.drawImage(this.mapCanvas, 0, 0);
        }
        ctx.restore();
      } else if (face.pts.length === 4) {
        const light = this.wall(face, frame.lantern, face.lux ? frame.map : 0);
        if (!face.lux) ctx.globalAlpha = 1 - UNDER_MAP * frame.map;
        const [p0, p1, , p3] = face.pts.map((p) => this.px(p));
        ctx.save();
        ctx.transform(
          (p1[0] - p0[0]) / light.w,
          (p1[1] - p0[1]) / light.w,
          (p3[0] - p0[0]) / light.h,
          (p3[1] - p0[1]) / light.h,
          p0[0],
          p0[1],
        );
        ctx.drawImage(light.canvas, 0, 0);
        ctx.restore();
        ctx.globalAlpha = 1;
        if (face.lux && light.segments.length && light.w > 8) {
          ctx.beginPath();
          const sx = (p1[0] - p0[0]) / light.w;
          const sy = (p1[1] - p0[1]) / light.w;
          const tx = (p3[0] - p0[0]) / light.h;
          const ty = (p3[1] - p0[1]) / light.h;
          for (const [x0, y0, x1, y1] of light.segments) {
            ctx.moveTo(
              p0[0] + sx * (x0 + 0.5) + tx * (y0 + 0.5),
              p0[1] + sy * (x0 + 0.5) + ty * (y0 + 0.5),
            );
            ctx.lineTo(
              p0[0] + sx * (x1 + 0.5) + tx * (y1 + 0.5),
              p0[1] + sy * (x1 + 0.5) + ty * (y1 + 0.5),
            );
          }
          ctx.strokeStyle = this.contourStyle(frame.map, frame.lantern.power);
          ctx.stroke();
        }
      }
      ctx.restore();
      this.path(pts, true);
    }
    this.stroke(face.tone);
  }

  private item(it: Item, frame: Frame) {
    const { ctx } = this;
    for (const face of it.faces) this.face(face, frame);
    if (it.sphere) {
      const [cx, cy] = this.px(it.sphere.c);
      const r = it.sphere.r * this.k;
      const g = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.4, r * 0.1, cx, cy, r);
      g.addColorStop(0, this.palette.crown[0]);
      g.addColorStop(1, this.palette.crown[1]);
      ctx.beginPath();
      ctx.arc(cx, cy, r, 0, Math.PI * 2);
      ctx.fillStyle = g;
      ctx.fill();
      // The light on the crown, on the side facing the lantern.
      const at = frame.lantern.at;
      const c = it.sphere.c;
      const d = Math.hypot(at.x - c.x, at.y - c.y, at.z - c.z);
      const towards = { x: (at.x - c.x) / d, y: (at.y - c.y) / d, z: (at.z - c.z) / d };
      const p = {
        x: c.x + towards.x * it.sphere.r,
        y: c.y + towards.y * it.sphere.r,
        z: c.z + towards.z * it.sphere.r,
      };
      const e = illuminance(frame.lantern, p, towards);
      const a = warmth(e) * (1 - frame.map) + mapA(e) * frame.map * 0.6;
      if (a > 0.01) {
        const [lx, ly] = this.px(p);
        const col = frame.map > 0.5 ? ramp(mapT(e)) : this.palette.warm;
        const lg = ctx.createRadialGradient(lx, ly, 0, lx, ly, r * 1.6);
        lg.addColorStop(0, `rgba(${col.map((n) => n | 0).join(',')}, ${a.toFixed(3)})`);
        lg.addColorStop(1, `rgba(${col.map((n) => n | 0).join(',')}, 0)`);
        ctx.save();
        ctx.clip();
        ctx.fillStyle = lg;
        ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
        ctx.restore();
        ctx.beginPath();
        ctx.arc(cx, cy, r, 0, Math.PI * 2);
      }
      this.stroke('line');
    }
    for (const line of it.lines) {
      if (line.tone === 'none') continue;
      this.path(
        line.pts.map((p) => this.px(p)),
        false,
      );
      this.stroke(line.tone);
    }
  }

  /** The dark of the pole and the feeder pillar on the pavement, cast from the lens. */
  private shadows(lantern: Lantern) {
    if (lantern.power < 0.01) return;
    const { ctx } = this;
    const L = lantern.at;
    const onGround = (p: Vec, z: number): Vec => {
      const t = (L.z - z) / Math.max(0.05, L.z - p.z);
      return { x: L.x + (p.x - L.x) * t, y: L.y + (p.y - L.y) * t, z };
    };
    const z = PAVE;
    ctx.save();
    ctx.globalAlpha = Math.min(1, lantern.power);
    ctx.fillStyle = this.palette.shadow;
    // The pillar: the hull of its foot and its top's shadow.
    const { x0, y0, x1, y1, z1 } = PILLAR;
    const corners = [
      { x: x0, y: y0 },
      { x: x1, y: y0 },
      { x: x1, y: y1 },
      { x: x0, y: y1 },
    ];
    const pts = [
      ...corners.map((c) => this.px({ ...c, z })),
      ...corners.map((c) => this.px(onGround({ ...c, z: z1 }, z))),
    ];
    this.path(this.hull(pts), true);
    ctx.fill();
    ctx.restore();
  }

  /** Paints one frame. */
  paint(layout: Layout, frame: Frame, lens: Vec) {
    const { ctx } = this;
    const pal = this.palette;
    this.light(frame.lantern);
    ctx.clearRect(0, 0, this.w, this.h);
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.lineWidth = 1;
    const power = frame.lantern.power;
    const [gr, gg, gb] = pal.glow;

    // The haze in the sky, from the flux that goes up and the light the houses throw back.
    const haze =
      Math.min(0.55, frame.sky * 6 + frame.houses * 1.3) * power * (pal.additive ? 1 : 0.45);
    if (haze > 0.005) {
      const [hx, hy] = this.px({ x: 20, y: 30, z: 10 });
      const r = this.k * 30;
      const g = ctx.createRadialGradient(hx, hy, 0, hx, hy, r);
      g.addColorStop(
        0,
        `rgba(${gr * 0.5}, ${gg * 0.42}, ${gb * 0.3}, ${(haze * 0.75).toFixed(3)})`,
      );
      g.addColorStop(
        0.55,
        `rgba(${gr * 0.5}, ${gg * 0.42}, ${gb * 0.3}, ${(haze * 0.32).toFixed(3)})`,
      );
      g.addColorStop(1, `rgba(${gr * 0.5}, ${gg * 0.42}, ${gb * 0.3}, 0)`);
      ctx.fillStyle = g;
      ctx.fillRect(0, 0, this.w, this.h);
    }

    // The plinth's shadow on the stage.
    {
      const pts = [
        { x: PLINTH.x0 + 1, y: PLINTH.y0 + 1 },
        { x: PLINTH.x1 - 1, y: PLINTH.y0 + 1 },
        { x: PLINTH.x1 - 1, y: PLINTH.y1 - 1 },
        { x: PLINTH.x0 + 1, y: PLINTH.y1 - 1 },
      ].map((p) => this.px({ ...p, z: -PLINTH.depth }));
      ctx.save();
      ctx.shadowColor = pal.additive ? 'rgba(0,0,0,0.9)' : 'rgba(60,50,30,0.25)';
      ctx.shadowBlur = this.k * 1.8;
      ctx.shadowOffsetY = this.k * 0.9;
      this.path(pts, true);
      ctx.fillStyle = pal.fills.side;
      ctx.fill();
      ctx.restore();
    }

    for (const it of layout.ground) this.item(it, frame);
    this.shadows(frame.lantern);
    // The isolux lines on the ground: faint and warm over the whole well, white over the road
    // when the lux map is on (the map covers the road and the near pavement).
    if (power > 0.01) {
      const band = (y0: number, y1: number, z: number) =>
        this.path(
          [
            this.px({ x: WELL.x0, y: y0, z }),
            this.px({ x: WELL.x1, y: y0, z }),
            this.px({ x: WELL.x1, y: y1, z }),
            this.px({ x: WELL.x0, y: y1, z }),
          ],
          true,
        );
      // Each band at its own height: the road sits below the pavements and gardens.
      const passes: [number, number, number, number, number][] = [
        [WELL.y0, BANDS.behind, FLOOR, 0, 1 - UNDER_MAP * frame.map],
        [BANDS.behind, BANDS.nearKerb, PAVE, 0, 1 - frame.map],
        [BANDS.farKerb, WELL.y1, PAVE, 0, 1 - UNDER_MAP * frame.map],
        [BANDS.nearKerb, BANDS.farKerb, FLOOR, 0, 1 - frame.map],
        [BANDS.behind, BANDS.nearKerb, PAVE, 1, frame.map],
        [BANDS.nearKerb, BANDS.farKerb, FLOOR, 1, frame.map],
      ];
      for (const [y0, y1, z, map, fade] of passes) {
        if (fade <= 0) continue;
        ctx.save();
        band(y0, y1, z);
        ctx.clip();
        this.groundContours(map, power, fade, z);
        ctx.restore();
      }
    }
    for (const it of layout.items) this.item(it, frame);

    if (power < 0.01) return;
    const additive = pal.additive;
    ctx.save();
    ctx.globalCompositeOperation = additive ? 'lighter' : 'source-over';
    // The cone: from the lens to the pool it lights.
    const [lx, ly] = this.px(lens);
    const { cx, cy, a, b, angle } = this.pool;
    const ring: [number, number][] = [];
    for (let i = 0; i < 24; i += 1) {
      const t = (i / 24) * Math.PI * 2;
      const ex = Math.cos(t) * a * 1.5;
      const ey = Math.sin(t) * b * 1.5;
      ring.push(
        this.px({
          x: cx + ex * Math.cos(angle) - ey * Math.sin(angle),
          y: cy + ex * Math.sin(angle) + ey * Math.cos(angle),
          z: 0,
        }),
      );
    }
    const [pcx, pcy] = this.px({ x: cx, y: cy, z: 0 });
    const cone = ctx.createLinearGradient(lx, ly, pcx, pcy);
    const strength = (additive ? 0.13 : 0.1) * power * (1 + frame.map * 0.6);
    // On the lux map the cone takes the map's warm pink.
    const tint = ramp(0.58);
    const [cr, cg, cb] = [0, 1, 2].map((i) =>
      Math.round(pal.glow[i] + (tint[i] - pal.glow[i]) * frame.map),
    );
    cone.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, ${strength.toFixed(3)})`);
    cone.addColorStop(1, `rgba(${cr}, ${cg}, ${cb}, 0)`);
    this.path(this.hull([[lx, ly], ...ring]), true);
    ctx.fillStyle = cone;
    ctx.fill();
    // A wide, faint haze of lit air around the head, on either view.
    {
      const r = this.k * 6.5;
      const air = ctx.createRadialGradient(lx, ly, 0, lx, ly, r);
      air.addColorStop(0, `rgba(${gr}, ${gg}, ${gb}, ${(0.16 * power).toFixed(3)})`);
      air.addColorStop(0.45, `rgba(${gr}, ${gg}, ${gb}, ${(0.05 * power).toFixed(3)})`);
      air.addColorStop(1, `rgba(${gr}, ${gg}, ${gb}, 0)`);
      ctx.fillStyle = air;
      ctx.fillRect(lx - r, ly - r, r * 2, r * 2);
    }
    // The halo on the lens.
    const r = this.k * 1.5;
    const halo = ctx.createRadialGradient(lx, ly, 0, lx, ly, r);
    halo.addColorStop(0, `rgba(255, 250, 236, ${(0.7 * power).toFixed(3)})`);
    halo.addColorStop(0.15, `rgba(${gr}, ${gg}, ${gb}, ${(0.32 * power).toFixed(3)})`);
    halo.addColorStop(1, `rgba(${gr}, ${gg}, ${gb}, 0)`);
    ctx.fillStyle = halo;
    ctx.fillRect(lx - r, ly - r, r * 2, r * 2);
    ctx.restore();
  }
}
```

### street-light/demo-pointer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the eye of the
 * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (nda-badge.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the badge. It is
 * decorative: what it does shows on the badge itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### street-light/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### street-light/street-light.module.css

```css
/* Street light (street-light.tsx): a near-black technical figure, "Fig 1", over a panel of
   controls set in small, tracked monospace capitals.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). The canvas cannot read tokens, so a hidden probe turns white or black with
   the scheme and the script repaints when its colour changes.

   Sizes are the figure's own at about 1080 px wide: 13 px labels tracked 0.1em, 14 to 15 px
   values. Below 600 px the sliders and the optic cards stack. */

.root {
  /* The stage, and the panel's hairlines. */
  --surface: #111;
  --line: #2a2a2a;
  --edge: #363636;
  --edge-strong: #d8d8d8;
  --track: #3d3d3d;
  /* Words: values, labels, and the dim ones. */
  --ink: #f0f0f0;
  --muted: #777;
  --soft: #a8a8a8;
  /* Where the light goes: one colour per zone. */
  --road: #e4c28b;
  --pavements: #998b76;
  --behind: #6c635a;
  --gardens: #5b5e50;
  --houses: #e1484b;
  --sky: #6e99ff;
  --probe: #fff;
  --focus: #e4c28b;
  /* The demo pointer, as the system draws it. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.35);
  position: relative;
  container-type: inline-size;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  background: var(--surface);
  color: var(--ink);
  font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
  font-size: 13px;
  line-height: 17px;
  font-variant-numeric: tabular-nums slashed-zero;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f4f3ef, #111);
    --line: light-dark(#d6d4cd, #2a2a2a);
    --edge: light-dark(#cfcdc6, #363636);
    --edge-strong: light-dark(#1b1b1b, #d8d8d8);
    --track: light-dark(#c4c2bb, #3d3d3d);
    --ink: light-dark(#161616, #f0f0f0);
    --muted: light-dark(#7a7973, #777);
    --soft: light-dark(#55544f, #a8a8a8);
    --road: light-dark(#d9a85a, #e4c28b);
    --pavements: light-dark(#a8977c, #998b76);
    --behind: light-dark(#7d7368, #6c635a);
    --gardens: light-dark(#6f735f, #5b5e50);
    --probe: light-dark(#000, #fff);
    --focus: light-dark(#a8681c, #e4c28b);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* Turns white on a dark scheme, black on a light one; its transition tells the script. */
.probe {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  color: var(--probe);
  transition: color 1ms;
  pointer-events: none;
}

/* The figure: the canvas fills it; its caption sits in its top corners. */
.figure {
  position: relative;
  aspect-ratio: 1080 / 896;
  min-height: 260px;
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}
.figure:active {
  cursor: grabbing;
}
.canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
.fig,
.name {
  position: absolute;
  top: 24px;
  font-size: 13px;
  line-height: 17px;
  pointer-events: none;
}
.fig {
  left: 27px;
  color: var(--soft);
}
.name {
  right: 27px;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  margin-right: -0.12em;
}

/* The panel. */
.panel {
  margin: 14px 31px 0;
  padding: 20px 19px 15px;
  border: 1px solid var(--line);
}
.sliders {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: 33px;
}
.slider {
  display: grid;
  grid-template-columns: 1fr auto;
  row-gap: 10px;
  align-items: baseline;
}
.label,
.stats dt {
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.1em;
}
.value {
  color: var(--ink);
  font-size: 14px;
  font-weight: 500;
}
/* The slider: a 2 px rule and a hollow ring for the thumb. */
.range {
  grid-column: 1 / -1;
  width: 100%;
  height: 20px;
  margin: 0;
  background: transparent;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
  touch-action: pan-y;
}
.range::-webkit-slider-runnable-track {
  height: 2px;
  background: var(--track);
}
.range::-moz-range-track {
  height: 2px;
  background: var(--track);
}
.range::-webkit-slider-thumb {
  width: 14px;
  height: 14px;
  margin-top: -6px;
  border: 1.5px solid var(--edge-strong);
  border-radius: 50%;
  background: var(--surface);
  appearance: none;
  -webkit-appearance: none;
  transition: transform 0.15s ease;
}
.range::-moz-range-thumb {
  box-sizing: border-box;
  width: 14px;
  height: 14px;
  border: 1.5px solid var(--edge-strong);
  border-radius: 50%;
  background: var(--surface);
  transition: transform 0.15s ease;
}
.range:active::-webkit-slider-thumb {
  transform: scale(1.15);
}
.range:active::-moz-range-thumb {
  transform: scale(1.15);
}
.range:focus-visible {
  outline: none;
}
.range:focus-visible::-webkit-slider-thumb {
  box-shadow:
    0 0 0 3px var(--surface),
    0 0 0 4.5px var(--focus);
}
.range:focus-visible::-moz-range-thumb {
  box-shadow:
    0 0 0 3px var(--surface),
    0 0 0 4.5px var(--focus);
}

/* The optic cards. */
.cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 7px;
  margin-top: 21px;
}
.card {
  position: relative;
  display: grid;
  padding: 9px 14px 7px;
  border: 1px solid var(--edge);
  cursor: pointer;
  transition: border-color 0.2s ease;
}
.card:hover {
  border-color: color-mix(in srgb, var(--edge-strong) 45%, var(--edge));
}
.card[data-selected] {
  border-color: var(--edge-strong);
}
.card:has(.radio:focus-visible) {
  outline: 1.5px solid var(--focus);
  outline-offset: 2px;
}
.radio {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
  margin: 0;
  pointer-events: none;
}
.cardTitle {
  color: var(--ink);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.cardRating {
  margin-top: 2px;
  color: var(--muted);
}

/* The distribution bar and its legend. */
.bar {
  display: flex;
  gap: 1px;
  height: 10px;
  margin-top: 19px;
  background: var(--surface);
  overflow: hidden;
}
.segment {
  flex: 0 1 0;
  min-width: 0;
  background: var(--zone);
}
.legend {
  display: flex;
  flex-wrap: wrap;
  column-gap: 21px;
  row-gap: 4px;
  margin: 8px 0 0;
  padding: 0;
  list-style: none;
  color: var(--muted);
}
.key {
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
}
.key b {
  margin-left: 0.6em;
  color: var(--ink);
  font-weight: 700;
}
.swatch {
  width: 10px;
  height: 10px;
  margin-right: 9px;
  background: var(--zone);
}
.segment[data-zone='road'],
.key[data-zone='road'] {
  --zone: var(--road);
}
.segment[data-zone='pavements'],
.key[data-zone='pavements'] {
  --zone: var(--pavements);
}
.segment[data-zone='behind'],
.key[data-zone='behind'] {
  --zone: var(--behind);
}
.segment[data-zone='gardens'],
.key[data-zone='gardens'] {
  --zone: var(--gardens);
}
.segment[data-zone='houses'],
.key[data-zone='houses'] {
  --zone: var(--houses);
}
.segment[data-zone='sky'],
.key[data-zone='sky'] {
  --zone: var(--sky);
}

/* The numbers, and the lux map's switch. */
.foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  margin-top: 19px;
}
.stats {
  display: flex;
  flex-wrap: wrap;
  column-gap: 28px;
  row-gap: 10px;
  margin: 0 0 6px;
}
.stats div {
  display: grid;
  row-gap: 8px;
}
.stats dd {
  margin: 0;
  color: var(--ink);
  font-size: 15px;
  white-space: nowrap;
}
.lux {
  display: grid;
  flex: none;
  justify-items: start;
  width: 90px;
  padding: 9px 13px 8px;
  border: 1px solid var(--edge);
  border-radius: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.2s ease;
}
.lux:hover {
  border-color: color-mix(in srgb, var(--edge-strong) 45%, var(--edge));
}
.lux[aria-pressed='true'] {
  border-color: var(--edge-strong);
}
.lux:focus-visible {
  outline: 1.5px solid var(--focus);
  outline-offset: 2px;
}
.luxTitle {
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  white-space: nowrap;
}
.luxState {
  margin-top: 2px;
  color: var(--muted);
}

/* The hint and the live line under the panel. */
.footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 6px 24px;
  padding: 30px 27px 23px;
}
.hint {
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.1em;
}
.readout {
  color: var(--soft);
  font-size: 14px;
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The demo pointer. */
.pointer {
  width: 22px;
  height: 22px;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer:not([data-glyph='hand']) .hand {
  visibility: hidden;
}

/* Narrow: the panel's columns stack. */
@container (max-width: 600px) {
  .fig,
  .name {
    top: 16px;
    font-size: 11px;
  }
  .fig {
    left: 16px;
  }
  .name {
    right: 16px;
  }
  .panel {
    margin: 8px 16px 0;
    padding: 16px 14px 14px;
  }
  .sliders {
    grid-template-columns: 1fr;
    row-gap: 14px;
  }
  .cards {
    grid-template-columns: 1fr;
    gap: 6px;
    margin-top: 16px;
  }
  .card {
    grid-template-columns: auto 1fr;
    column-gap: 12px;
    align-items: baseline;
    padding: 9px 12px;
  }
  .cardRating {
    margin-top: 0;
    justify-self: end;
  }
  .foot {
    flex-wrap: wrap;
    align-items: flex-start;
    margin-top: 20px;
  }
  .stats {
    display: grid;
    grid-template-columns: 1fr 1fr;
    column-gap: 16px;
    row-gap: 12px;
    margin: 0;
  }
  .footer {
    padding: 18px 16px 18px;
    font-size: 12px;
  }
  .readout {
    font-size: 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .card,
  .lux,
  .range::-webkit-slider-thumb {
    transition: none;
  }
}
```