# World map

> A game’s map screen: a navy grid over contour lines, the player’s arrow and view cone, a pulsing destination, stamps, beaming pins, names by zoom, all pannable and zoomable.

The screen a player opens to see where they are, where they are going and what they have marked, on a tablet with a bronze bezel and a cyan glow. A navy surface carries a fine grid, an invented island traced as contour lines, a river and dashed borders; the player is a gold arrow with a view cone, the destination a gold disc whose dashed ring turns and pulses while it is set, stamps are cyan diamond icons and pins come in five colours, each standing a beam of light up off the map. The cursor reticle snaps open around whatever it rests on and names it. Drag or use the arrow keys to pan, the wheel, a pinch or +/- to zoom, and click or press Enter to place a pin.

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

Category: Games · Screens.

## Anatomy

1. **Title and tabs**: The screen’s title in cyan between the neighbouring screens, on the L and R shoulder keys; they are labels, not controls.
2. **Player**: A gold arrow turned to the player’s heading, under a view cone that breathes every 3.2 s; the reticle names it You Are Here.
3. **Destination**: A gold disc; while it is set a dashed ring turns around it and an echo pulses out every 1.8 s. A on it clears it, A again sets it.
4. **Stamp**: A cyan icon between chevrons for a tower, shrine, camp or village; A on it makes it the destination.
5. **Pin**: A pin the player dropped, one per colour, standing a beam of its colour up off the map; A on it removes it.
6. **Reticle callout**: Within 26 px of a marker the reticle’s four corners snap to it and spring open, and its name slides in with what A does there.
7. **Pin palette**: Five swatches in a `radiogroup` (or keys 1 to 5): the next pin’s colour; a placed colour is greyed.
8. **Button hints**: Move, Sensor (Y), Back (B), Select (A) and Zoom as real buttons, except Move; Select greys out where A has nothing to do.

## When to use

- A game’s full-screen map, opened from the pause menu, where the player checks where they stand and where they are heading.
- Planning a route: setting a destination from a marked place, dropping a few coloured pins, zooming out to see the regions.
- A world with more names than fit at once: regions zoomed out, places at the middle zoom, small spots only up close.
- A screen read with a controller as well as a mouse: a reticle that snaps to markers and names them, with A, B and Y hints at the foot.
- A diegetic map drawn on a device in the world, with its own bezel, clock and readouts around the view.

## When not to use

- Real places, addresses or directions. Use a web map library with real tiles, search and accessible routing.
- Showing the surroundings while playing. Use a minimap or radar in a HUD corner; this is a full screen that stops the game.
- A handful of locations to pick from. Use a list or a fast-travel menu; panning to find a name is slower than reading one.
- Data plotted on a geography, such as a choropleth or a store finder. Use a chart or a plain map that leads with the data.
- Unlimited markers or notes. Pins here are one per colour, five at most; use a marker list when players keep many.

## Installation

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

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

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 { WorldMap, type MapPin, type MapStamp } from './world-map';

const STAMPS: MapStamp[] = [
  { id: 'well', name: 'Old Well', x: 980, y: 690, kind: 'shrine' },
  { id: 'camp', name: 'River Camp', x: 1190, y: 940, kind: 'camp' },
];

export function MapScreen({ onClose }: { onClose: () => void }) {
  return (
    <WorldMap
      stamps={STAMPS}
      player={{ x: 1090, y: 705, heading: 40 }}
      defaultDestination={{ x: 1240, y: 600, label: 'Meet the ferryman' }}
      time="06:42 PM"
      temperature="+12 °C"
      autoplay={false}
      onPinsChange={(pins: MapPin[]) => console.log(pins)}
      onBack={onClose}
    />
  );
}
```

## States

- **Destination set**: The defaults with `autoplay={false}`: centred just past the destination, so the reticle opens around it and offers A: Clear.
- **Destination not followed**: `defaultTracked={false}`: the disc dims, the ring and echo stop, and A reads Select.
- **No destination**: `defaultDestination={null}`: it centres on the player, and the reticle names You Are Here with no action.
- **On a stamp**: `defaultCenter` on a stamp: its name and A: Select, which would make it the destination.
- **On a pin**: `defaultCenter` on the blue pin: A: Remove.
- **On open ground**: Nothing within 26 px: the reticle stays tight and A places a pin of the chosen colour.
- **All pins placed**: Five pins in `defaultPins`: every swatch is greyed and Select is disabled on open ground; A there shakes the reticle.
- **Zoomed out**: `defaultZoom={0.5}`: only the regions are named, with the grid at its coarse step.
- **Zoomed in**: `defaultZoom={2.25}`: places and small spots are named; markers keep their size.
- **Sensor on**: `defaultSensor`: the readout on the right reads On, and the Sensor hint is pressed.
- **Narrow**: On a 480 px stage the unit stops at 0.7 px: the screen keeps its height, shows less of the map and drops Move and the tab names.

## How it works

- The camera is three registered custom properties (@property, typed <number>): the centre’s x and y in world units and the zoom. The terrain’s SVG group, the HTML layer of markers and each marker’s counter-scale (1 / zoom, so markers keep their size) are all written from them, so one CSS transition moves the whole map as one: 150 ms for the wheel and the arrow keys, 560 ms for a step of the zoom. A drag writes them with no transition, so the ground stays under the finger.
- The island is a height field: a long oval of land falling into the sea at its rim, gaussian hills, a lake and a bay carved out, and three octaves of seeded value noise. Marching squares trace it every 0.07 of height on a 16-unit lattice, the segments are joined into runs and smoothed once (Chaikin); level 0 is the coast, every other level a main line. Under it, a ground texture of dense rings is traced the same way from a noise field that wraps at 440 units, so its pattern tile repeats without a seam. Both are traced once, after mount.
- The grid is cut to the part of the world in view: lines every 27.5 and 55 units, small crosses in each cell, larger ones where major lines meet and the largest every fourth major line. Every stroke on the map is non-scaling, so lines stay one px wide at any zoom; below 0.75 the fine lines drop out.
- The reticle is four clip-path triangles around a point. Within 26 px of a marker it snaps to it: the triangles spring from 24 px apart to 70 (260 ms, an overshoot curve) and the marker’s name and what A does there slide in on the side with room, wrapping if neither side has enough. On empty ground A places a pin of the chosen colour; with all five placed the reticle shakes.
- A placed pin drops in from above at 1.5× with a flash and its beam grows up from it (scaleY, 700 ms); the palette greys that colour and moves to the next free one. The destination’s ring is a repeating conic gradient masked to a thin circle: it turns once every 14 s, and an echo ring swells and fades every 1.8 s while the destination is set.
- Names show by zoom tier through data-zoom on the map: regions below 0.75, places from 0.75, small spots from 1.4; each fades and settles in. The player’s arrow and cone turn by a rotate transition with a small overshoot, and the cone breathes.
- Every size is a multiple of --u, 1 px on a 1014 px screen and down to 0.7 px on a narrow stage, where the screen keeps its 572-unit height, the corners shrink, the tab names and the Move hint go and the map simply shows less. The bezel is bronze relief: bars with ring bosses and cyan gems over a dark stone backing and a blurred blue glow, and a running relief of worn bars and studs along both bands.
- A demo plans a short trip while the map is on screen and untouched: it reads a shrine, drops pins, zooms in and out, sets a camp as the destination, turns the sensor on and clears a pin; any input hands it to the reader. Loops pause off screen and in a hidden tab, and reduced motion drops every glide and pulse.

## API reference

- **places** (`MapPlace[]`, default `sixteen sample names`): The names on the map: { id, name, x, y, tier }. Tier 1 (regions) shows zoomed out, tier 2 (places) from the middle zoom, tier 3 (small spots) zoomed in. x and y are world units on a 2400 × 1500 world.
- **stamps** (`MapStamp[]`, default `five sample stamps`): Marked locations: { id, name, x, y, kind }, kind being tower, shrine, camp or village. A on a stamp makes it the destination.
- **player** (`MapPlayer`, default `{ x: 1091, y: 705, heading: 52 }`): Where the player stands, and which way they face in degrees clockwise from north.
- **defaultDestination** (`MapDestination | null`, default `{ x: 1240, y: 600, label: 'Follow the beacon' }`): The destination at first, with the name shown beside the reticle, or null for none.
- **defaultPins** (`MapPin[]`, default `one blue pin`): The pins at first: { color, x, y }, at most one per colour (red, blue, amber, green, pink).
- **defaultTracked** (`boolean`, default `true`): Whether the destination opens set, its ring turning and pulsing (true), or shown dimmed and not followed (false). Passing it turns the demo off.
- **defaultSensor** (`boolean`, default `false`): Whether the sensor opens on. Passing it turns the demo off.
- **defaultZoom** (`number`, default `1`): The zoom it opens at, from 0.5 to 2.4.
- **defaultCenter** (`MapPoint`): The world point it opens centred on; by default just past the destination, or the player when there is none.
- **time** (`string`, default `'09:10 AM'`): The clock in the lower left corner.
- **temperature** (`string`, default `'+28 °F'`): The thermometer under the clock.
- **title** (`string`, default `'Map'`): The screen’s title, in the top band.
- **tabs** (`[string, string]`, default `['Tools', 'Album']`): The neighbouring screens either side of the title, on the L and R shoulder keys.
- **onPinsChange** (`(pins: MapPin[]) => void`): Called with all pins whenever one is placed or removed.
- **onDestinationChange** (`(destination: MapDestination | null) => void`): Called when the destination is set, moved to a stamp or cleared (null).
- **onTarget** (`(target: MapTarget | null) => void`): Called when the reticle snaps to a marker (destination, stamp, pin or player), or leaves it (null).
- **onSensorChange** (`(on: boolean) => void`): Called when the sensor is turned on or off (Y or the Sensor hint).
- **onZoomChange** (`(zoom: number) => void`): Called when the zoom changes.
- **onBack** (`() => void`): Called on B, Escape or the Back hint.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short planning session: a shrine read, pins dropped, a zoom in and out, a destination set, the sensor turned on.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the screen’s edge, shadow and glow on the page; by default it follows the page. The screen itself stays dark.
- **className** (`string`): Extra class names on the root.

## Accessibility

- The map is one focusable application region with a roledescription of map and a label that gives its keys: the arrow keys pan (Shift pans further), + and − zoom, A, Enter or Space selects what the reticle rests on or places a pin, Delete removes a pin, 1 to 5 choose the pin colour, Y toggles the sensor, B or Escape goes back.
- From the keyboard the reticle sits in the middle of the map and the map moves under it, as a game cursor does; whatever it snaps to is announced in a polite live region with what A does there, and so is every pin placed or removed, destination set or cleared, zoom step and sensor change.
- The pin palette is a radio group (arrow keys move within it, the placed colours say so), and each pin colour also has its own dark mark (a target, a chevron, a wave, a cross, a triangle), so the colours read apart without colour vision.
- The control hints are real buttons with their shortcuts in aria-keyshortcuts; Sensor is a toggle button with aria-pressed. The tabs, bezel, terrain, markers and invented script are decorative and hidden from assistive tech.
- Focus rings are visible on the map, the palette and every hint. Reduced motion drops the glides, the reticle’s spring, the ring and echo pulses, the cone’s breathing and the pin drop, and the demo does not play; loops pause off screen.

## Source

### world-map/world-map.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import {
  Corner,
  FaceButton,
  KeyCap,
  PinGlyph,
  PlayerArrow,
  Relief,
  Script,
  StampIcon,
  Stick,
  ViewCone,
} from './glyphs';
import {
  BORDERS,
  contours,
  DEFAULT_DESTINATION,
  DEFAULT_PINS,
  DEFAULT_PLACES,
  DEFAULT_PLAYER,
  DEFAULT_STAMPS,
  PIN_COLORS,
  RIVER,
  TEXTURE_SIZE,
  texture,
  WORLD,
  type MapDestination,
  type MapPin,
  type MapPlace,
  type MapPlayer,
  type MapPoint,
  type MapStamp,
  type PinColor,
  type StampKind,
} from './terrain';
import { useAutoplay } from './use-autoplay';
import styles from './world-map.module.css';

/*
 * World map: the screen a player opens to see where they are, where they are going and what they
 * have marked, on a tablet-like device with a bronze bezel and a cyan glow.
 *
 * - The map is a navy surface with a two-level grid and crosses, an invented island traced as
 *   contour lines, a river and dashed region borders. The player is a gold arrow with a view cone;
 *   the destination is a gold disc with a dashed ring that pulses while it is set; stamps are cyan
 *   diamond icons; pins come in five colours, one each, and stand a beam of their colour up off
 *   the map so they read from far away.
 * - The camera is three registered custom properties (centre x, y and zoom), so a glide is a CSS
 *   transition and everything that hangs off them (the terrain's SVG group, the marker layer and
 *   the counter-scale that keeps markers at screen size) moves as one. A drag sets them with no
 *   transition, so the map stays under the finger.
 * - The cursor reticle is four cyan corner triangles. Resting on nothing it is tight; within
 *   26 px of a marker it snaps to it, springs open around it, and the marker's name slides in
 *   beside it with what A does there (select a destination, set a stamp as destination, remove a
 *   pin). On empty ground A (or a click) places a pin of the chosen colour.
 * - Names show by zoom: regions zoomed out, places from the middle zoom, small spots zoomed in.
 * - Drag or arrow keys pan; the wheel, a pinch or +/- zoom about the cursor; 1–5 choose a pin
 *   colour; Y turns the sensor on and off; B or Escape calls onBack.
 * - Every size is a multiple of --u, 1 px on a 1014 px screen, less on a narrower stage (down to
 *   0.7 px, where the screen keeps its height and shows less of the map).
 * - A demo plays a short planning session while it is on screen and untouched; any input hands
 *   it to the reader.
 */

export type {
  MapDestination,
  MapPin,
  MapPlace,
  MapPlayer,
  MapPoint,
  MapStamp,
  PinColor,
  StampKind,
};
export {
  DEFAULT_DESTINATION,
  DEFAULT_PINS,
  DEFAULT_PLACES,
  DEFAULT_PLAYER,
  DEFAULT_STAMPS,
  PIN_COLORS,
  WORLD,
};

/** What the reticle can rest on. */
export type MapTarget =
  | { kind: 'destination'; destination: MapDestination }
  | { kind: 'stamp'; stamp: MapStamp }
  | { kind: 'pin'; pin: MapPin }
  | { kind: 'player'; player: MapPlayer };

export type WorldMapProps = {
  className?: string;
  /** The names on the map, each with its zoom tier. */
  places?: MapPlace[];
  /** The marked locations. */
  stamps?: MapStamp[];
  /** Where the player stands and which way they face. */
  player?: MapPlayer;
  /** The destination at first, or null for none. */
  defaultDestination?: MapDestination | null;
  /** The pins at first, at most one per colour. */
  defaultPins?: MapPin[];
  /** Whether the destination opens set (true) or shown but not followed (false). Turns the demo off. */
  defaultTracked?: boolean;
  /** Whether the sensor opens on. Turns the demo off. */
  defaultSensor?: boolean;
  /** The zoom it opens at, from 0.5 to 2.4. */
  defaultZoom?: number;
  /** The world point it opens centred on (by default, just past the destination). */
  defaultCenter?: MapPoint;
  /** The corner's clock and thermometer. */
  time?: string;
  temperature?: string;
  /** The screen's title and the tabs either side of it. */
  title?: string;
  tabs?: [string, string];
  /** Called with all pins whenever one is placed or removed. */
  onPinsChange?: (pins: MapPin[]) => void;
  /** Called when the destination is set, moved or cleared (null). */
  onDestinationChange?: (destination: MapDestination | null) => void;
  /** Called when the reticle snaps to a marker, or leaves it (null). */
  onTarget?: (target: MapTarget | null) => void;
  /** Called when the sensor is turned on or off. */
  onSensorChange?: (on: boolean) => void;
  /** Called when the zoom changes. */
  onZoomChange?: (zoom: number) => void;
  /** Called on B, Escape or the Back hint. */
  onBack?: () => void;
  /** Plays a short planning session while on screen and untouched. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

/** The full-size screen, in px: bands top and bottom, the map between. */
const SCREEN_W = 1014;
const MAP_H = 482;
const Z_MIN = 0.5;
const Z_MAX = 2.4;
const Z_STEPS = [0.5, 0.75, 1, 1.5, 2.25];
/** How near (screen px) the reticle must come to a marker to snap to it. */
const SNAP = 26;
const PAN_STEP = 90;

const COLOR_NAMES: Record<PinColor, string> = {
  red: 'Red',
  blue: 'Blue',
  amber: 'Amber',
  green: 'Green',
  pink: 'Pink',
};

type Camera = { x: number; y: number; z: number };
type Glide = 'none' | 'fast' | 'slow';
type Size = { w: number; u: number };
type Target = {
  key: string;
  x: number;
  y: number;
  name: string;
  action?: string;
  target: MapTarget;
};

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

function clampCamera(cam: Camera, w: number): Camera {
  const z = clamp(cam.z, Z_MIN, Z_MAX);
  const halfW = w / 2 / z;
  const halfH = MAP_H / 2 / z;
  const x = WORLD.width < halfW * 2 ? WORLD.width / 2 : clamp(cam.x, halfW, WORLD.width - halfW);
  const y = WORLD.height < halfH * 2 ? WORLD.height / 2 : clamp(cam.y, halfH, WORLD.height - halfH);
  return { x, y, z };
}

/** The grid lines and crosses that fall inside the view, as path data in world units. */
function gridPaths(cam: Camera, w: number) {
  const fine = cam.z >= 0.75;
  const major = 55;
  const minor = fine ? 27.5 : 55;
  const margin = 120 / cam.z;
  const x0 = Math.max(0, cam.x - w / 2 / cam.z - margin);
  const x1 = Math.min(WORLD.width, cam.x + w / 2 / cam.z + margin);
  const y0 = Math.max(0, cam.y - MAP_H / 2 / cam.z - margin);
  const y1 = Math.min(WORLD.height, cam.y + MAP_H / 2 / cam.z + margin);
  const r = (value: number) => Math.round(value * 10) / 10;
  let majors = '';
  let minors = '';
  let crosses = '';
  let joints = '';
  let stars = '';
  for (let x = Math.ceil(x0 / minor) * minor; x <= x1; x += minor) {
    const line = `M${r(x)} ${r(y0)}V${r(y1)}`;
    if (Math.abs(x / major - Math.round(x / major)) < 0.01) majors += line;
    else minors += line;
  }
  for (let y = Math.ceil(y0 / minor) * minor; y <= y1; y += minor) {
    const line = `M${r(x0)} ${r(y)}H${r(x1)}`;
    if (Math.abs(y / major - Math.round(y / major)) < 0.01) majors += line;
    else minors += line;
  }
  // Small crosses at the middle of each cell, larger ones where major lines meet, and the
  // largest every fourth major line.
  const arm = 2.4 / cam.z;
  const mid = 3.4 / cam.z;
  const big = 5 / cam.z;
  const cell = minor;
  for (let x = Math.floor(x0 / cell) * cell + cell / 2; x <= x1; x += cell)
    for (let y = Math.floor(y0 / cell) * cell + cell / 2; y <= y1; y += cell)
      crosses += `M${r(x - arm)} ${r(y)}h${r(arm * 2)}M${r(x)} ${r(y - arm)}v${r(arm * 2)}`;
  for (let x = Math.ceil(x0 / major) * major; x <= x1; x += major)
    for (let y = Math.ceil(y0 / major) * major; y <= y1; y += major) {
      const star = Math.round(x / major) % 4 === 0 && Math.round(y / major) % 4 === 0;
      const a = star ? big : mid;
      const d = `M${r(x - a)} ${r(y)}h${r(a * 2)}M${r(x)} ${r(y - a)}v${r(a * 2)}`;
      if (star) stars += d;
      else joints += d;
    }
  return { majors, minors, crosses, joints, stars };
}

type Step =
  | { at: number; do: 'reset' }
  | { at: number; do: 'camera'; cam: Camera }
  | { at: number; do: 'cursor'; to: MapPoint | null }
  | { at: number; do: 'select' | 'sensor' }
  | { at: number; do: 'heading'; to: number };

/** The demo: a short planning session, as moves at ms from its start. */
const DEMO: Step[] = [
  { at: 0, do: 'reset' },
  { at: 1000, do: 'cursor', to: { x: 991, y: 683 } },
  { at: 2400, do: 'cursor', to: { x: 1108, y: 802 } },
  { at: 3100, do: 'select' },
  { at: 4200, do: 'camera', cam: { x: 1250, y: 820, z: 1.5 } },
  { at: 5600, do: 'cursor', to: { x: 1336, y: 866 } },
  { at: 6300, do: 'select' },
  { at: 7300, do: 'cursor', to: { x: 1190, y: 940 } },
  { at: 8500, do: 'select' },
  { at: 9600, do: 'camera', cam: { x: 1220, y: 760, z: 0.6 } },
  { at: 10400, do: 'cursor', to: null },
  { at: 11300, do: 'sensor' },
  { at: 12300, do: 'heading', to: 128 },
  { at: 13300, do: 'camera', cam: { x: 1180, y: 760, z: 1 } },
  { at: 14300, do: 'cursor', to: { x: 1108, y: 802 } },
  { at: 15300, do: 'select' },
  { at: 16600, do: 'reset' },
];
const DEMO_LENGTH = 18600;

const KEYS: Record<string, [number, number]> = {
  ArrowUp: [0, -1],
  ArrowDown: [0, 1],
  ArrowLeft: [-1, 0],
  ArrowRight: [1, 0],
};

export function WorldMap({
  className,
  places = DEFAULT_PLACES,
  stamps = DEFAULT_STAMPS,
  player: playerProp = DEFAULT_PLAYER,
  defaultDestination = DEFAULT_DESTINATION,
  defaultPins = DEFAULT_PINS,
  defaultTracked,
  defaultSensor,
  defaultZoom = 1,
  defaultCenter,
  time = '09:10 AM',
  temperature = '+28 °F',
  title = 'Map',
  tabs = ['Tools', 'Album'],
  onPinsChange,
  onDestinationChange,
  onTarget,
  onSensorChange,
  onZoomChange,
  onBack,
  autoplay = true,
  colorScheme,
}: WorldMapProps) {
  const id = useId().replace(/:/g, '');
  const { ref, take, playing, visible } = useAutoplay<HTMLDivElement>();
  // Opening in a given state locks it: the demo would reset it.
  const demo = playing && autoplay && defaultTracked === undefined && defaultSensor === undefined;

  // Only the first render's defaults count.
  const [start] = useState(() => {
    const center =
      defaultCenter ??
      (defaultDestination
        ? { x: defaultDestination.x + 15, y: defaultDestination.y + 15 }
        : { x: playerProp.x, y: playerProp.y });
    return {
      cam: { x: center.x, y: center.y, z: clamp(defaultZoom, Z_MIN, Z_MAX) },
      pins: defaultPins,
      destination: defaultDestination,
      tracked: defaultTracked ?? true,
      sensor: defaultSensor ?? false,
    };
  });

  const [size, setSize] = useState<Size>({ w: SCREEN_W, u: 1 });
  const [cam, setCam] = useState<Camera>(start.cam);
  const [glide, setGlide] = useState<Glide>('none');
  /** The reticle's point in map px, or null for the middle of the map. */
  const [cursor, setCursor] = useState<MapPoint | null>(null);
  const [cursorGlide, setCursorGlide] = useState(false);
  const [pins, setPins] = useState<MapPin[]>(start.pins);
  const [color, setColor] = useState<PinColor>(
    () => PIN_COLORS.find((c) => !start.pins.some((pin) => pin.color === c)) ?? 'red',
  );
  const [destination, setDestination] = useState<MapDestination | null>(start.destination);
  const [tracked, setTracked] = useState(start.tracked);
  const [sensor, setSensor] = useState(start.sensor);
  const [heading, setHeading] = useState(playerProp.heading);
  const [refusals, setRefusals] = useState(0);
  const [fresh, setFresh] = useState<PinColor | null>(null);
  const [said, setSaid] = useState('');

  const screenRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<HTMLDivElement>(null);
  const camRef = useRef(cam);
  const sizeRef = useRef(size);
  useEffect(() => {
    camRef.current = cam;
    sizeRef.current = size;
  }, [cam, size]);

  // The map's width in screen px (units) and the size of one unit, from the drawn screen.
  useEffect(() => {
    const screen = screenRef.current;
    const map = mapRef.current;
    if (!screen || !map) return;
    const measure = () => {
      const u = screen.getBoundingClientRect().height / 572;
      if (!u) return;
      const w = map.getBoundingClientRect().width / u;
      setSize((now) => (Math.abs(now.w - w) < 0.5 && Math.abs(now.u - u) < 0.001 ? now : { w, u }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(screen);
    return () => observer.disconnect();
  }, []);

  // Keep the camera inside the world when the map's width changes.
  useEffect(() => {
    setCam((now) => {
      const next = clampCamera(now, size.w);
      return next.x === now.x && next.y === now.y && next.z === now.z ? now : next;
    });
  }, [size.w]);

  const toScreen = useCallback(
    (p: MapPoint, c: Camera = cam) => ({
      x: (p.x - c.x) * c.z + size.w / 2,
      y: (p.y - c.y) * c.z + MAP_H / 2,
    }),
    [cam, size.w],
  );
  const toWorld = useCallback(
    (p: MapPoint, c: Camera = cam) => ({
      x: (p.x - size.w / 2) / c.z + c.x,
      y: (p.y - MAP_H / 2) / c.z + c.y,
    }),
    [cam, size.w],
  );

  const targets = useMemo<Target[]>(() => {
    const list: Target[] = [];
    if (destination)
      list.push({
        key: 'destination',
        ...destination,
        name: destination.label,
        action: tracked ? 'Clear' : 'Select',
        target: { kind: 'destination', destination },
      });
    for (const pin of pins)
      list.push({
        key: `pin-${pin.color}`,
        x: pin.x,
        y: pin.y,
        name: `${COLOR_NAMES[pin.color]} Pin`,
        action: 'Remove',
        target: { kind: 'pin', pin },
      });
    for (const stamp of stamps)
      list.push({
        key: `stamp-${stamp.id}`,
        x: stamp.x,
        y: stamp.y,
        name: stamp.name,
        action: destination?.x === stamp.x && destination?.y === stamp.y ? undefined : 'Select',
        target: { kind: 'stamp', stamp },
      });
    list.push({
      key: 'player',
      x: playerProp.x,
      y: playerProp.y,
      name: 'You Are Here',
      target: { kind: 'player', player: { ...playerProp, heading } },
    });
    return list;
  }, [destination, tracked, pins, stamps, playerProp, heading]);

  const point = cursor ?? { x: size.w / 2, y: MAP_H / 2 };
  const nearest = useCallback(
    (p: MapPoint, c: Camera = cam) => {
      let best: Target | null = null;
      let bestD = SNAP;
      for (const target of targets) {
        const s = toScreen(target, c);
        const d = Math.hypot(s.x - p.x, s.y - p.y);
        if (d < bestD) {
          bestD = d;
          best = target;
        }
      }
      return best;
    },
    [targets, toScreen, cam],
  );
  const snapped = nearest(point);

  // Tell the reader and the host which marker the reticle rests on.
  const lastTarget = useRef<string | null>(null);
  const reportTarget = useEffectEvent((target: Target | null) => {
    onTarget?.(target?.target ?? null);
    if (target) setSaid(target.action ? `${target.name}. A: ${target.action}.` : `${target.name}.`);
  });
  useEffect(() => {
    const key = snapped?.key ?? null;
    if (key === lastTarget.current) return;
    lastTarget.current = key;
    reportTarget(snapped);
  }, [snapped]);

  const moveCamera = useCallback(
    (next: Camera, how: Glide) => {
      const c = clampCamera(next, sizeRef.current.w);
      setGlide(how);
      setCam(c);
      if (c.z !== camRef.current.z) onZoomChange?.(c.z);
      camRef.current = c;
    },
    [onZoomChange],
  );

  /** Zooms to `z` keeping the world point under `about` (map px) where it is. */
  const zoomTo = useCallback(
    (z: number, about: MapPoint, how: Glide) => {
      const c = camRef.current;
      const nz = clamp(z, Z_MIN, Z_MAX);
      const w = sizeRef.current.w;
      const world = { x: (about.x - w / 2) / c.z + c.x, y: (about.y - MAP_H / 2) / c.z + c.y };
      moveCamera(
        { x: world.x - (about.x - w / 2) / nz, y: world.y - (about.y - MAP_H / 2) / nz, z: nz },
        how,
      );
    },
    [moveCamera],
  );

  const stepZoom = useCallback(
    (dir: 1 | -1, about: MapPoint) => {
      const z = camRef.current.z;
      const next =
        dir > 0
          ? (Z_STEPS.find((step) => step > z + 0.01) ?? Z_MAX)
          : ([...Z_STEPS].reverse().find((step) => step < z - 0.01) ?? Z_MIN);
      zoomTo(next, about, 'slow');
      setSaid(`Zoom ${Math.round(clamp(next, Z_MIN, Z_MAX) * 100)}%`);
    },
    [zoomTo],
  );

  const commitPins = useCallback(
    (next: MapPin[]) => {
      setPins(next);
      onPinsChange?.(next);
    },
    [onPinsChange],
  );

  /** A, Enter or a click at map point `p`: act on the marker there, or place a pin. */
  const activate = useCallback(
    (p: MapPoint) => {
      const target = nearest(p);
      if (target) {
        const t = target.target;
        if (t.kind === 'pin') {
          commitPins(pins.filter((pin) => pin.color !== t.pin.color));
          setColor(t.pin.color);
          setSaid(`${COLOR_NAMES[t.pin.color]} pin removed`);
        } else if (t.kind === 'destination') {
          const on = !tracked;
          setTracked(on);
          onDestinationChange?.(on ? t.destination : null);
          setSaid(on ? `Destination set: ${t.destination.label}` : 'Destination cleared');
        } else if (t.kind === 'stamp') {
          if (!target.action) return;
          const next = { x: t.stamp.x, y: t.stamp.y, label: t.stamp.name };
          setDestination(next);
          setTracked(true);
          onDestinationChange?.(next);
          setSaid(`Destination set: ${t.stamp.name}`);
        }
        return;
      }
      const free = [color, ...PIN_COLORS].find((c) => !pins.some((pin) => pin.color === c));
      if (!free) {
        setRefusals((count) => count + 1);
        setSaid('No pins left. Remove one first.');
        return;
      }
      const world = toWorld(p);
      const next = [...pins, { color: free, x: Math.round(world.x), y: Math.round(world.y) }];
      commitPins(next);
      setFresh(free);
      const after = [...PIN_COLORS, ...PIN_COLORS]
        .slice(PIN_COLORS.indexOf(free) + 1)
        .find((c) => !next.some((pin) => pin.color === c));
      if (after) setColor(after);
      setSaid(`${COLOR_NAMES[free]} pin placed`);
    },
    [nearest, pins, color, tracked, toWorld, commitPins, onDestinationChange],
  );

  const toggleSensor = useCallback(() => {
    setSensor((on) => {
      onSensorChange?.(!on);
      setSaid(on ? 'Sensor off' : 'Sensor on');
      return !on;
    });
  }, [onSensorChange]);

  const back = useCallback(() => {
    setSaid('Back');
    onBack?.();
  }, [onBack]);

  // The demo, on its own clock: it restarts from the top each time it is handed back.
  const step = useEffectEvent((item: Step) => {
    if (item.do === 'reset') {
      moveCamera(start.cam, 'slow');
      setCursorGlide(true);
      setCursor(null);
      setPins(start.pins);
      setColor(PIN_COLORS.find((c) => !start.pins.some((pin) => pin.color === c)) ?? 'red');
      setDestination(start.destination);
      setTracked(start.tracked);
      setSensor(start.sensor);
      setHeading(playerProp.heading);
    } else if (item.do === 'camera') moveCamera(item.cam, 'slow');
    else if (item.do === 'cursor') {
      setCursorGlide(true);
      setCursor(item.to ? toScreen(item.to, camRef.current) : null);
    } else if (item.do === 'select') activate(point);
    else if (item.do === 'sensor') toggleSensor();
    else if (item.do === 'heading') setHeading(item.to);
  });
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (from: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), from + item.at));
      ids.push(window.setTimeout(() => run(0), from + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);

  // Pointer: drag pans, a tap acts, two fingers pinch; a mouse moves the reticle.
  const pointers = useRef(new Map<number, MapPoint>());
  const drag = useRef<{
    from: MapPoint;
    cam: Camera;
    moved: boolean;
    pinch?: { d: number; z: number };
  } | null>(null);
  const local = (event: PointerEvent<HTMLDivElement>): MapPoint => {
    const rect = event.currentTarget.getBoundingClientRect();
    const u = sizeRef.current.u;
    return { x: (event.clientX - rect.left) / u, y: (event.clientY - rect.top) / u };
  };

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    take();
    if (event.button !== 0) return;
    const p = local(event);
    event.currentTarget.setPointerCapture(event.pointerId);
    pointers.current.set(event.pointerId, p);
    if (pointers.current.size === 2) {
      const [a, b] = [...pointers.current.values()];
      drag.current = {
        from: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
        cam: camRef.current,
        moved: true,
        pinch: { d: Math.hypot(a.x - b.x, a.y - b.y), z: camRef.current.z },
      };
    } else drag.current = { from: p, cam: camRef.current, moved: false };
  };
  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const p = local(event);
    if (event.pointerType === 'mouse') {
      take();
      setCursorGlide(false);
      setCursor(p);
    }
    const d = drag.current;
    if (!d || !pointers.current.has(event.pointerId)) return;
    pointers.current.set(event.pointerId, p);
    if (d.pinch && pointers.current.size === 2) {
      const [a, b] = [...pointers.current.values()];
      const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
      const z = clamp((d.pinch.z * Math.hypot(a.x - b.x, a.y - b.y)) / d.pinch.d, Z_MIN, Z_MAX);
      const w = sizeRef.current.w;
      const world = {
        x: (d.from.x - w / 2) / d.cam.z + d.cam.x,
        y: (d.from.y - MAP_H / 2) / d.cam.z + d.cam.y,
      };
      moveCamera(
        { x: world.x - (mid.x - w / 2) / z, y: world.y - (mid.y - MAP_H / 2) / z, z },
        'none',
      );
      return;
    }
    const dx = p.x - d.from.x;
    const dy = p.y - d.from.y;
    if (!d.moved && Math.hypot(dx, dy) < 5) return;
    d.moved = true;
    moveCamera({ x: d.cam.x - dx / d.cam.z, y: d.cam.y - dy / d.cam.z, z: d.cam.z }, 'none');
  };
  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    pointers.current.delete(event.pointerId);
    if (pointers.current.size) {
      // One finger left after a pinch: carry on as a drag from where it is.
      const [rest] = [...pointers.current.values()];
      drag.current = { from: rest, cam: camRef.current, moved: true };
      return;
    }
    drag.current = null;
    if (d && !d.moved && event.type === 'pointerup') {
      const p = local(event);
      setCursorGlide(event.pointerType !== 'mouse');
      setCursor(p);
      activate(p);
    }
  };

  // The wheel zooms about the pointer. It needs a non-passive listener to keep the page still.
  const onWheel = useEffectEvent((event: WheelEvent) => {
    event.preventDefault();
    take();
    const map = mapRef.current;
    if (!map) return;
    const rect = map.getBoundingClientRect();
    const u = sizeRef.current.u;
    const p = { x: (event.clientX - rect.left) / u, y: (event.clientY - rect.top) / u };
    const delta = event.deltaMode === 1 ? event.deltaY * 33 : event.deltaY;
    zoomTo(camRef.current.z * Math.exp(-clamp(delta, -240, 240) * 0.0024), p, 'fast');
  });
  useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    const listener = (event: WheelEvent) => onWheel(event);
    map.addEventListener('wheel', listener, { passive: false });
    return () => map.removeEventListener('wheel', listener);
  }, []);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    take();
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    const onMap = event.target === mapRef.current;
    const pan = KEYS[key];
    if (pan && onMap) {
      event.preventDefault();
      const c = camRef.current;
      const by = (event.shiftKey ? PAN_STEP * 3 : PAN_STEP) / c.z;
      setCursorGlide(true);
      setCursor(null);
      moveCamera({ x: c.x + pan[0] * by, y: c.y + pan[1] * by, z: c.z }, 'fast');
    } else if (key === '+' || key === '=' || key === '-' || key === '_') {
      event.preventDefault();
      stepZoom(key === '+' || key === '=' ? 1 : -1, point);
    } else if (key === 'a' || (onMap && (key === 'Enter' || key === ' '))) {
      event.preventDefault();
      activate(point);
    } else if (key === 'y') {
      event.preventDefault();
      toggleSensor();
    } else if (key === 'b' || key === 'Escape') {
      event.preventDefault();
      back();
    } else if (/^[1-5]$/.test(key)) {
      event.preventDefault();
      const c = PIN_COLORS[Number(key) - 1];
      setColor(c);
      setSaid(
        `${COLOR_NAMES[c]} pin${pins.some((pin) => pin.color === c) ? ', placed' : ''} chosen`,
      );
    } else if ((key === 'Delete' || key === 'Backspace') && onMap) {
      const target = nearest(point);
      if (target?.target.kind === 'pin') {
        event.preventDefault();
        activate(point);
      }
    }
  };

  // When a placed pin has had its moment, it stops being fresh.
  useEffect(() => {
    if (!fresh) return;
    const timer = window.setTimeout(() => setFresh(null), 900);
    return () => window.clearTimeout(timer);
  }, [fresh]);

  const grid = useMemo(
    () => gridPaths(cam, size.w),
    // The grid is only re-cut when the view moves by a good part of a cell or the zoom changes.
    [Math.round(cam.x / 40), Math.round(cam.y / 40), cam.z, size.w],
  );
  // The contours are traced after mount, so the server's HTML stays light.
  const [land, setLand] = useState<(ReturnType<typeof contours> & { ground: string }) | null>(null);
  useEffect(() => setLand({ ...contours(), ground: texture() }), []);

  const reticle = snapped ? toScreen(snapped) : point;
  // The name goes on the side with room for it, and wraps if neither side has enough.
  const roomRight = size.w - reticle.x - 52;
  const roomLeft = reticle.x - 52;
  const labelLeft = roomRight < 280 && roomLeft > roomRight;
  const room = Math.max(120, labelLeft ? roomLeft : roomRight);
  const zoomTier = cam.z < 0.75 ? 'far' : cam.z < 1.4 ? 'mid' : 'near';
  const freeCount = PIN_COLORS.filter((c) => !pins.some((pin) => pin.color === c)).length;

  const vars = {
    '--world-map-cx': cam.x,
    '--world-map-cy': cam.y,
    '--world-map-z': cam.z,
    '--vw': size.w,
    '--vh': MAP_H,
  } as CSSProperties;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-paused={!visible || undefined}
      data-part="root"
    >
      <div ref={screenRef} className={styles.screen} onKeyDown={onKeyDown} data-part="screen">
        <div className={styles.band} data-edge="top" data-part="top-band">
          <span className={styles.relief} data-edge="top" aria-hidden="true">
            <Relief id={`${id}-rt`} width={size.w} />
          </span>
          <div className={styles.tabs} data-part="tabs">
            <span className={styles.tab} aria-hidden="true">
              <span>{tabs[0]}</span>
              <KeyCap letter="L" />
            </span>
            <h2 className={styles.title} data-part="title">
              {title}
            </h2>
            <span className={styles.tab} aria-hidden="true">
              <KeyCap letter="R" />
              <span>{tabs[1]}</span>
            </span>
          </div>
        </div>

        <div
          ref={mapRef}
          className={styles.map}
          style={vars}
          tabIndex={0}
          role="application"
          aria-roledescription="map"
          aria-label={`${title}. Arrow keys pan, plus and minus zoom, A or Enter selects or places a pin, 1 to 5 choose the pin colour, Y the sensor, B goes back.`}
          aria-describedby={`${id}-live`}
          data-glide={glide}
          data-zoom={zoomTier}
          data-sensor={sensor || undefined}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
          data-part="map"
        >
          <svg
            className={styles.terrain}
            viewBox={`0 0 ${Math.round(size.w * 10) / 10} ${MAP_H}`}
            aria-hidden="true"
            data-part="terrain"
          >
            {land && (
              <defs>
                <pattern
                  id={`${id}-ground`}
                  width={TEXTURE_SIZE}
                  height={TEXTURE_SIZE}
                  patternUnits="userSpaceOnUse"
                >
                  <path className={styles.ground} d={land.ground} />
                </pattern>
              </defs>
            )}
            <g className={styles.world}>
              {land && (
                <rect
                  width={WORLD.width}
                  height={WORLD.height}
                  fill={`url(#${id}-ground)`}
                  stroke="none"
                />
              )}
              <path className={styles.gridMinor} d={grid.minors} />
              <path className={styles.gridMajor} d={grid.majors} />
              <path className={styles.contourFaint} d={land?.faint} />
              <path className={styles.contour} d={land?.main} />
              <path className={styles.border} d={BORDERS.join('')} />
              <path className={styles.river} d={RIVER} />
              <path className={styles.coast} d={land?.coast} />
              <path className={styles.cross} d={grid.crosses} />
              <path className={styles.joint} d={grid.joints} />
              <path className={styles.star} d={grid.stars} />
            </g>
          </svg>

          <div className={styles.layer} aria-hidden="true">
            {places.map((place) => (
              <span
                key={place.id}
                className={styles.place}
                data-tier={place.tier}
                style={{ '--x': place.x, '--y': place.y } as CSSProperties}
                data-part="place-label"
              >
                <span className={styles.placeInner}>
                  <span>{place.name}</span>
                  <Script count={Math.max(4, Math.round(place.name.length * 0.8))} seed={place.x} />
                </span>
              </span>
            ))}
            {stamps.map((stamp) => (
              <span
                key={stamp.id}
                className={styles.marker}
                style={{ '--x': stamp.x, '--y': stamp.y } as CSSProperties}
                data-part="stamp"
              >
                <span className={styles.stamp}>
                  <StampIcon kind={stamp.kind} />
                </span>
              </span>
            ))}
            {pins.map((pin) => (
              <span
                key={pin.color}
                className={styles.marker}
                style={{ '--x': pin.x, '--y': pin.y } as CSSProperties}
                data-part="pin"
              >
                <span
                  className={styles.pin}
                  data-color={pin.color}
                  data-fresh={fresh === pin.color || undefined}
                >
                  <span className={styles.beam} />
                  <PinGlyph color={pin.color} />
                </span>
              </span>
            ))}
            <span
              className={styles.marker}
              style={{ '--x': playerProp.x, '--y': playerProp.y } as CSSProperties}
              data-part="player"
            >
              <span
                className={styles.player}
                style={{ '--heading': `${heading}deg` } as CSSProperties}
              >
                <span className={styles.cone}>
                  <ViewCone id={`${id}-cone`} />
                </span>
                <span className={styles.arrow} data-part="arrow">
                  <PlayerArrow />
                </span>
              </span>
            </span>
            {destination && (
              <span
                key={`${destination.x}-${destination.y}`}
                className={styles.marker}
                style={{ '--x': destination.x, '--y': destination.y } as CSSProperties}
                data-part="destination"
              >
                <span className={styles.destination} data-tracked={tracked || undefined}>
                  <span className={styles.ring} />
                  <span className={styles.echo} />
                  <span className={styles.disc} data-part="disc" />
                </span>
              </span>
            )}
          </div>

          <span
            className={styles.reticle}
            style={{ '--rx': reticle.x, '--ry': reticle.y, '--room': room } as CSSProperties}
            data-open={snapped ? true : undefined}
            data-glide={cursorGlide || snapped ? true : undefined}
            data-flip={labelLeft || undefined}
            aria-hidden="true"
            data-part="reticle"
          >
            <span
              key={refusals}
              className={styles.corners}
              data-refused={refusals > 0 || undefined}
            >
              <i />
              <i />
              <i />
              <i />
            </span>
            {snapped && (
              <span key={snapped.key} className={styles.callout} data-part="callout">
                <span className={styles.calloutName}>{snapped.name}</span>
                {snapped.action && (
                  <span className={styles.calloutAction}>
                    <FaceButton letter="A" />
                    <span>{snapped.action}</span>
                  </span>
                )}
              </span>
            )}
          </span>

          <div className={styles.glow} aria-hidden="true" />
        </div>

        <div className={styles.clock} data-part="clock">
          <div
            className={styles.palette}
            role="radiogroup"
            aria-label="Pin colour"
            data-part="palette"
          >
            {PIN_COLORS.map((c, at) => {
              const placed = pins.some((pin) => pin.color === c);
              return (
                <button
                  key={c}
                  type="button"
                  role="radio"
                  aria-checked={color === c}
                  aria-label={`${COLOR_NAMES[c]} pin${placed ? ', placed' : ''}`}
                  aria-keyshortcuts={String(at + 1)}
                  tabIndex={color === c ? 0 : -1}
                  className={styles.swatch}
                  data-color={c}
                  data-placed={placed || undefined}
                  onClick={() => {
                    take();
                    setColor(c);
                  }}
                  onKeyDown={(event) => {
                    const dir =
                      event.key === 'ArrowRight' || event.key === 'ArrowDown'
                        ? 1
                        : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
                          ? -1
                          : 0;
                    if (!dir) return;
                    event.preventDefault();
                    event.stopPropagation();
                    const next = PIN_COLORS[(at + dir + PIN_COLORS.length) % PIN_COLORS.length];
                    setColor(next);
                    const group = event.currentTarget.parentElement;
                    (group?.children[PIN_COLORS.indexOf(next)] as HTMLElement | undefined)?.focus();
                  }}
                >
                  <PinGlyph color={c} />
                </button>
              );
            })}
          </div>
          <span className={styles.clockLine}>
            <span>{time}</span>
            <Script count={6} seed={3} />
          </span>
          <span className={styles.clockLine}>
            <span>{temperature}</span>
            <Script count={13} seed={8} />
          </span>
        </div>

        <div className={styles.status} data-part="sensor-status">
          <span className={styles.sensorLine}>
            <span className={styles.sensorWord}>{sensor ? 'On' : 'Off'}</span>
            <span className={styles.sensorIcon} key={String(sensor)} data-on={sensor || undefined}>
              <svg viewBox="0 0 18 18" aria-hidden="true">
                {sensor ? (
                  <g fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
                    <circle cx="9" cy="9" r="2" fill="currentColor" stroke="none" />
                    <path d="M4.6 4.6 A6.2 6.2 0 0 0 4.6 13.4 M13.4 4.6 A6.2 6.2 0 0 1 13.4 13.4" />
                    <path
                      d="M2.2 2.2 A9.6 9.6 0 0 0 2.2 15.8 M15.8 2.2 A9.6 9.6 0 0 1 15.8 15.8"
                      opacity="0.6"
                    />
                  </g>
                ) : (
                  <path
                    d="M2.4 2.4 L15.6 15.6 M15.6 2.4 L2.4 15.6"
                    stroke="currentColor"
                    strokeWidth="1.8"
                    strokeLinecap="round"
                  />
                )}
              </svg>
            </span>
          </span>
          <span className={styles.sensorScript}>
            <Script count={17} seed={5} />
          </span>
        </div>

        <div className={styles.band} data-edge="bottom" data-part="bottom-band">
          <div className={styles.hints} data-part="hints">
            <span className={styles.hint} data-optional="true" data-stick="true">
              <span>Move</span>
              <Stick letter="L" />
            </span>
            <button
              type="button"
              className={styles.hint}
              aria-keyshortcuts="Y"
              aria-label="Sensor"
              aria-pressed={sensor}
              data-short="true"
              onClick={() => {
                take();
                toggleSensor();
              }}
            >
              <span>Sensor</span>
              <FaceButton letter="Y" />
            </button>
            <button
              type="button"
              className={styles.hint}
              aria-keyshortcuts="B Escape"
              onClick={() => {
                take();
                back();
              }}
            >
              <span>Back</span>
              <FaceButton letter="B" />
            </button>
            <button
              type="button"
              className={styles.hint}
              aria-keyshortcuts="A Enter"
              aria-label={
                snapped?.action ? `${snapped.action} ${snapped.name}` : `Place ${color} pin`
              }
              aria-disabled={(!snapped && !freeCount) || (snapped && !snapped.action) || undefined}
              onClick={() => {
                take();
                activate(point);
              }}
            >
              <span>Select</span>
              <FaceButton letter="A" />
            </button>
            <button
              type="button"
              className={styles.hint}
              aria-keyshortcuts="+ -"
              data-stick="true"
              aria-label="Zoom in"
              onClick={() => {
                take();
                if (cam.z >= Z_STEPS.at(-1)! - 0.01) zoomTo(Z_STEPS[0], point, 'slow');
                else stepZoom(1, point);
              }}
            >
              <span>Zoom</span>
              <Stick letter="R" zoom />
            </button>
          </div>
          <span className={styles.relief} data-edge="bottom" aria-hidden="true">
            <Relief id={`${id}-rb`} width={size.w} />
          </span>
        </div>

        {(['tl', 'tr', 'bl', 'br'] as const).map((which) => (
          <span key={which} className={styles.corner} data-corner={which} aria-hidden="true">
            <Corner id={`${id}-${which}`} which={which} />
          </span>
        ))}
        <p className={styles.live} id={`${id}-live`} aria-live="polite">
          {said}
        </p>
      </div>
    </div>
  );
}
```

### world-map/glyphs.tsx

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

import type { PinColor, StampKind } from './terrain';

/*
 * The map's art, small SVGs drawn in px of the full-size screen (the CSS sizes each one, so they
 * scale with it).
 *
 * - Controls: a face button (a disc with its letter), a stick (a ringed disc, with up and down
 *   arrows for zoom) and a shoulder key cap.
 * - The player: a gold arrowhead with a notched base, and its view cone, a soft wedge of light.
 * - Stamps: a pale icon held in a cyan diamond bracket (a tower, a shrine arch, a tent, a house).
 * - Pins: a rounded square in the pin's colour with a dark mark of its own (a target, a chevron,
 *   a wave, a cross, a triangle), so the colours read apart for colour-blind players too.
 * - Script: a strip of an invented, decorative script, the small print a game puts under a word.
 * - The bezel: bronze relief bars, ring bosses with a cyan gem, and the running relief of the
 *   top and bottom bands, all drawn as thick round strokes over a shadow and under a highlight.
 */

/** A face button: a disc with its letter, upright and bold. */
export function FaceButton({ letter }: { letter: string }) {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <circle cx="10" cy="10" r="10" fill="currentColor" />
      <text
        x="10"
        y="14.2"
        textAnchor="middle"
        fontSize="11.5"
        fontWeight="700"
        fontStyle="normal"
        fill="var(--ink)"
      >
        {letter}
      </text>
    </svg>
  );
}

/** A stick: a disc with its letter on a ring; `zoom` adds the up and down arrows. */
export function Stick({ letter, zoom }: { letter: string; zoom?: boolean }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      {zoom && (
        <g fill="currentColor">
          <path d="M12 0.4 L15 3.6 H9 Z" />
          <path d="M12 23.6 L15 20.4 H9 Z" />
        </g>
      )}
      <circle cx="12" cy="12" r="8.6" fill="none" stroke="currentColor" strokeWidth="1.6" />
      <circle cx="12" cy="12" r="6.4" fill="currentColor" />
      {!zoom && (
        <path
          d="M4.6 17.4 A9.4 9.4 0 0 0 19.4 17.4"
          fill="none"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
        />
      )}
      <text
        x="12"
        y="15"
        textAnchor="middle"
        fontSize="8.4"
        fontWeight="800"
        fontStyle="normal"
        fill="var(--ink)"
      >
        {letter}
      </text>
    </svg>
  );
}

/** A shoulder key cap. */
export function KeyCap({ letter }: { letter: string }) {
  return (
    <svg viewBox="0 0 20 18" aria-hidden="true">
      <path
        d="M3 0.6 H17 Q19.4 0.6 19.4 3 V15 Q19.4 17.4 17 17.4 H3 Q0.6 17.4 0.6 15 V3 Q0.6 0.6 3 0.6 Z"
        fill="currentColor"
      />
      <text
        x="10"
        y="13.2"
        textAnchor="middle"
        fontSize="11"
        fontWeight="700"
        fontStyle="normal"
        fill="var(--ink)"
      >
        {letter}
      </text>
    </svg>
  );
}

/** The player's arrowhead, pointing up (north); the CSS turns it. */
export function PlayerArrow() {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <path
        d="M10 1.6 L16.8 17.6 Q10 14.4 3.2 17.6 Z"
        fill="var(--gold)"
        stroke="var(--gold-deep)"
        strokeWidth="1"
        strokeLinejoin="round"
      />
      <path d="M10 5.4 L13.6 14.6 Q10 13.2 10 13.2 Z" fill="var(--gold-light)" opacity="0.7" />
    </svg>
  );
}

/** The view cone: a soft wedge of light, 64° wide, from the centre of a 160-unit box upward. */
export function ViewCone({ id }: { id: string }) {
  const r = 78;
  const a = (32 * Math.PI) / 180;
  const x = Math.sin(a) * r;
  const y = Math.cos(a) * r;
  return (
    <svg viewBox="-80 -80 160 160" aria-hidden="true">
      <defs>
        <radialGradient id={id} cx="0" cy="0" r={r} gradientUnits="userSpaceOnUse">
          <stop offset="0" stopColor="var(--cone)" stopOpacity="0.62" />
          <stop offset="0.55" stopColor="var(--cone)" stopOpacity="0.26" />
          <stop offset="1" stopColor="var(--cone)" stopOpacity="0" />
        </radialGradient>
      </defs>
      <path d={`M0 0 L${-x} ${-y} A${r} ${r} 0 0 1 ${x} ${-y} Z`} fill={`url(#${id})`} />
    </svg>
  );
}

/** A stamp: a pale icon in a cyan diamond bracket. */
export function StampIcon({ kind }: { kind: StampKind }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M7.4 5.2 L1.6 12 L7.4 18.8 M16.6 5.2 L22.4 12 L16.6 18.8"
        fill="none"
        stroke="var(--stamp)"
        strokeWidth="2.2"
        strokeLinejoin="round"
      />
      {kind === 'shrine' && (
        <>
          <path d="M6.4 20 V10.4 A5.6 5.6 0 0 1 17.6 10.4 V20 Z" fill="var(--stamp-light)" />
          <path d="M10.4 20 V13.6 A1.6 1.6 0 0 1 13.6 13.6 V20 Z" fill="var(--stamp-deep)" />
        </>
      )}
      {kind === 'tower' && (
        <>
          <path d="M12 1.4 L15.2 8.4 H8.8 Z" fill="var(--stamp-light)" />
          <path d="M9.4 9.8 H14.6 L16.4 21.4 H7.6 Z" fill="var(--stamp-light)" />
          <path d="M11 13 H13 V17 H11 Z" fill="var(--stamp-deep)" />
        </>
      )}
      {kind === 'camp' && (
        <>
          <path d="M12 3.6 L20 20 H4 Z" fill="var(--stamp-light)" />
          <path d="M12 11 L14.8 20 H9.2 Z" fill="var(--stamp-deep)" />
        </>
      )}
      {kind === 'village' && (
        <>
          <path d="M12 3.4 L19.4 10 V20 H4.6 V10 Z" fill="var(--stamp-light)" />
          <path d="M10.2 13.4 H13.8 V20 H10.2 Z" fill="var(--stamp-deep)" />
        </>
      )}
    </svg>
  );
}

const PIN_MARKS: Record<PinColor, string> = {
  red: 'M8 4.6 A3.4 3.4 0 1 1 7.99 4.6 M8 7.4 V8.6',
  blue: 'M4.6 10 L8 6.4 L11.4 10',
  amber: 'M4 8.6 Q5 6.4 6 8.6 T8 8.6 T10 8.6 T12 8.6',
  green: 'M8 4.6 V11.4 M4.6 8 H11.4',
  pink: 'M8 4.6 L11.6 11 H4.4 Z',
};

/** A pin: a rounded square in its colour with its own dark mark. */
export function PinGlyph({ color }: { color: PinColor }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true">
      <rect x="0.8" y="0.8" width="14.4" height="14.4" rx="3.6" fill="currentColor" />
      <rect
        x="0.8"
        y="0.8"
        width="14.4"
        height="14.4"
        rx="3.6"
        fill="none"
        stroke="var(--pin-edge)"
        strokeWidth="1.2"
      />
      <path
        d={PIN_MARKS[color]}
        fill="none"
        stroke="var(--pin-mark)"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

// The invented script: eight small marks on a 4 × 5 box, one stroke each.
const SCRIPT = [
  'M0.5 0.5 H3.5 V4.5 H0.5',
  'M0.5 0.5 V4.5 H3.5 M2 0.5 V3',
  'M0.5 2.5 H3.5 M2 0.5 V4.5',
  'M0.5 0.5 H3.5 M0.5 2.5 H3.5 M0.5 4.5 H3.5',
  'M3.5 0.5 H0.5 V2.5 H3.5 V4.5 H0.5',
  'M0.5 4.5 V0.5 H3.5 V4.5 M2 2.5 V4.5',
  'M0.5 0.5 L3.5 4.5 M3.5 0.5 V2',
  'M0.5 0.5 V4.5 M2 0.5 V4.5 M3.5 0.5 V4.5',
];

/** A strip of the invented script, `count` marks long, picked by `seed`. */
export function Script({ count, seed = 1 }: { count: number; seed?: number }) {
  const marks = Array.from({ length: count }, (_, at) => {
    const h = Math.imul(seed * 31 + at * 17 + 7, 2654435761) >>> 0;
    return SCRIPT[(h >>> 7) % SCRIPT.length];
  });
  return (
    <svg viewBox={`0 0 ${count * 5.4} 5`} aria-hidden="true">
      <g fill="none" stroke="currentColor" strokeWidth="1.15" strokeLinecap="square">
        {marks.map((d, at) => (
          <path key={at} d={d} transform={`translate(${at * 5.4} 0)`} />
        ))}
      </g>
    </svg>
  );
}

/*
 * The bezel's corners. Each is drawn as if it sat at the top left (the corner itself at 0, 0),
 * then mirrored into place. `bars` are thick round strokes over a dark stone backing, `bosses`
 * ring studs with a cyan gem, `studs` small round pegs.
 */
type Ornament = {
  w: number;
  h: number;
  bars: string;
  bosses: [number, number, number][];
  studs: [number, number, number][];
};

export const CORNERS: Record<'tl' | 'tr' | 'bl' | 'br', Ornament> = {
  tl: {
    w: 164,
    h: 132,
    bars: 'M62 14 H104 L112 22 H154 M62 40 H84 L92 48 H124 M14 62 V86 L22 94 V124 M40 62 V74 L48 82 V98',
    bosses: [[30, 30, 20]],
    studs: [
      [136, 42, 4],
      [108, 66, 3.4],
      [62, 104, 3.4],
    ],
  },
  tr: {
    w: 120,
    h: 228,
    bars: 'M60 14 H92 L100 22 H116 M60 40 H74 L82 48 H100 M14 64 V108 L22 116 V164 L14 172 V220 M40 64 V86 L48 94 V130',
    bosses: [
      [30, 30, 20],
      [40, 186, 11],
    ],
    studs: [
      [64, 116, 3.4],
      [104, 70, 3],
      [62, 150, 3],
    ],
  },
  bl: {
    w: 110,
    h: 78,
    bars: 'M48 12 H70 L78 20 H104 M48 34 H60 L66 40 H82 M12 46 V72',
    bosses: [[24, 23, 15]],
    studs: [
      [92, 38, 3],
      [32, 62, 3],
    ],
  },
  br: {
    w: 74,
    h: 136,
    bars: 'M50 12 H70 M12 50 V82 L20 90 V130 M36 50 V62 L44 70 V92',
    bosses: [[25, 25, 16]],
    studs: [
      [58, 40, 3],
      [52, 108, 3],
    ],
  },
};

/** Pits in the cast metal: x, y, radius on a 23 × 19 tile. */
const GRAIN: [number, number, number][] = [
  [2, 3, 0.9],
  [9, 1.5, 0.6],
  [15, 5, 1.1],
  [20, 2, 0.7],
  [5, 9, 0.7],
  [12, 11, 1],
  [19, 10, 0.6],
  [3, 15, 1.1],
  [9, 17, 0.6],
  [16, 15, 0.8],
  [22, 17, 0.5],
];

export function Corner({ id, which }: { id: string; which: keyof typeof CORNERS }) {
  const o = CORNERS[which];
  const flipX = which === 'tr' || which === 'br';
  const flipY = which === 'bl' || which === 'br';
  const transform =
    flipX || flipY
      ? `translate(${flipX ? o.w : 0} ${flipY ? o.h : 0}) scale(${flipX ? -1 : 1} ${flipY ? -1 : 1})`
      : undefined;
  // The light falls from the top left of the screen whatever the mirror, so the metal's gradient
  // runs in the drawing's own (mirrored) frame from the side that faces the light.
  const lx = flipX ? 1 : 0;
  const ly = flipY ? 1 : 0;
  const bossWidth = (r: number) => (r > 14 ? 12 : 8);
  return (
    <svg viewBox={`0 0 ${o.w} ${o.h}`} aria-hidden="true" overflow="visible">
      <defs>
        <linearGradient id={`${id}-metal`} x1={lx} y1={ly} x2={1 - lx} y2={1 - ly}>
          <stop offset="0" stopColor="var(--bronze-light)" />
          <stop offset="0.5" stopColor="var(--bronze)" />
          <stop offset="1" stopColor="var(--bronze-deep)" />
        </linearGradient>
        <radialGradient id={`${id}-gem`} cx="0.38" cy="0.34" r="0.7">
          <stop offset="0" stopColor="var(--gem-light)" />
          <stop offset="0.5" stopColor="var(--gem)" />
          <stop offset="1" stopColor="var(--gem-deep)" />
        </radialGradient>
        <filter id={`${id}-glow`} x="-40%" y="-40%" width="180%" height="180%">
          <feGaussianBlur stdDeviation="7" />
        </filter>
        <pattern id={`${id}-grain`} width="23" height="19" patternUnits="userSpaceOnUse">
          {GRAIN.map(([x, y, r], at) => (
            <circle key={at} cx={x} cy={y} r={r} fill="var(--bronze-grain)" />
          ))}
        </pattern>
      </defs>
      <g transform={transform}>
        {/* The glow behind, then the dark stone the relief stands on. */}
        <g filter={`url(#${id}-glow)`} opacity="0.9">
          <path
            d={o.bars}
            stroke="var(--bezel-glow)"
            strokeWidth="34"
            fill="none"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
          {o.bosses.map(([x, y, r]) => (
            <circle key={`g${x}-${y}`} cx={x} cy={y} r={r + 12} fill="var(--bezel-glow)" />
          ))}
        </g>
        <g
          fill="var(--bezel-stone)"
          stroke="var(--bezel-stone)"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d={o.bars} strokeWidth="25" fill="none" />
          {o.bosses.map(([x, y, r]) => (
            <circle key={`p${x}-${y}`} cx={x} cy={y} r={r + 9} stroke="none" />
          ))}
          {o.studs.map(([x, y, r]) => (
            <circle key={`q${x}-${y}`} cx={x} cy={y} r={r + 5} stroke="none" />
          ))}
        </g>
        {/* Shadow, metal, highlight. */}
        <g fill="none" strokeLinecap="round" strokeLinejoin="round">
          <path
            d={o.bars}
            stroke="var(--bronze-shadow)"
            strokeWidth="14.6"
            transform="translate(1.4 2.2)"
          />
          {o.bosses.map(([x, y, r]) => (
            <circle
              key={`s${x}-${y}`}
              cx={x + 1.4}
              cy={y + 2.2}
              r={r}
              stroke="var(--bronze-shadow)"
              strokeWidth={bossWidth(r) + 1}
            />
          ))}
          <path d={o.bars} stroke={`url(#${id}-metal)`} strokeWidth="13" />
          <path d={o.bars} stroke={`url(#${id}-grain)`} strokeWidth="13" opacity="0.3" />
          {o.bosses.map(([x, y, r]) => (
            <circle
              key={`m${x}-${y}`}
              cx={x}
              cy={y}
              r={r}
              stroke={`url(#${id}-metal)`}
              strokeWidth={bossWidth(r)}
            />
          ))}
          {o.bosses.map(([x, y, r]) => (
            <circle
              key={`n${x}-${y}`}
              cx={x}
              cy={y}
              r={r}
              stroke={`url(#${id}-grain)`}
              strokeWidth={bossWidth(r)}
              opacity="0.3"
            />
          ))}
          <path
            d={o.bars}
            stroke="var(--bronze-shine)"
            strokeWidth="1.8"
            transform={`translate(${flipX ? 1.2 : -1.2} ${flipY ? 2 : -2})`}
            opacity="0.7"
          />
          {o.bosses.map(([x, y, r]) => (
            <circle
              key={`h${x}-${y}`}
              cx={x}
              cy={y}
              r={r + bossWidth(r) / 2 - 1.4}
              stroke="var(--bronze-shine)"
              strokeWidth="1.4"
              strokeDasharray={`${r * 1.6} ${r * 10}`}
              transform={`rotate(${flipX ? (flipY ? 20 : 110) : flipY ? -70 : 200} ${x} ${y})`}
              opacity="0.7"
            />
          ))}
        </g>
        {o.bosses.map(([x, y, r]) => (
          <g key={`c${x}-${y}`}>
            <circle cx={x} cy={y} r={r - bossWidth(r) / 2} fill="var(--bezel-deep)" />
            <circle cx={x} cy={y} r={r * 0.4} fill={`url(#${id}-gem)`} />
          </g>
        ))}
        {o.studs.map(([x, y, r]) => (
          <g key={`t${x}-${y}`}>
            <circle cx={x + 0.9} cy={y + 1.4} r={r} fill="var(--bronze-shadow)" />
            <circle cx={x} cy={y} r={r} fill={`url(#${id}-metal)`} />
          </g>
        ))}
      </g>
    </svg>
  );
}

/** The running relief along a band, `width` px long: worn bars and studs, repeated every 96 px. */
export function Relief({ id, width }: { id: string; width: number }) {
  const bars = 'M5 8.6 H27 M38 7.2 H58 M69 8.8 H91';
  return (
    <svg viewBox={`0 0 ${width} 16`} preserveAspectRatio="xMinYMid slice" aria-hidden="true">
      <defs>
        <linearGradient id={`${id}-metal`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="var(--bronze-light)" />
          <stop offset="0.55" stopColor="var(--bronze)" />
          <stop offset="1" stopColor="var(--bronze-deep)" />
        </linearGradient>
        <pattern id={id} width="96" height="16" patternUnits="userSpaceOnUse">
          <g fill="none" strokeLinecap="round">
            <path
              d={bars}
              stroke="var(--bronze-shadow)"
              strokeWidth="10"
              transform="translate(0.8 1.2)"
            />
            <path d={bars} stroke={`url(#${id}-metal)`} strokeWidth="8.4" />
            <path
              d={bars}
              stroke="var(--bronze-shine)"
              strokeWidth="1.2"
              transform="translate(0 -2.6)"
              opacity="0.6"
            />
            <path
              d="M14 8.6 H18 M47 7.2 H50 M79 8.8 H82"
              stroke="var(--bronze-deep)"
              strokeWidth="2.4"
            />
          </g>
          {[32.5, 63.5, 0, 96].map((x) => (
            <g key={x}>
              <circle cx={x + 0.6} cy="9" r="2.6" fill="var(--bronze-shadow)" />
              <circle cx={x} cy="8.2" r="2.4" fill={`url(#${id}-metal)`} />
            </g>
          ))}
        </pattern>
      </defs>
      <rect width="100%" height="100%" fill={`url(#${id})`} />
    </svg>
  );
}
```

### world-map/terrain.ts

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

/*
 * The world under the map: an invented island, its contour lines, a river, its region borders and
 * the names on it. Everything is in world units, 1 unit = 1 px of the map at zoom 1, on a
 * 2400 × 1500 world.
 *
 * - The ground is a height field: a long oval of land that falls into the sea at its rim, a few
 *   hills and a mountain mass added as gaussians, a lake and a bay carved out the same way, and
 *   two octaves of seeded value noise so no line is a perfect curve.
 * - Contours are traced from that field by marching squares on a 20-unit lattice, joined into
 *   runs, smoothed once (Chaikin) and written as SVG path data with one decimal. Level 0 is the
 *   coast; the others are the faint height lines. The work is done once, on first use.
 */

export const WORLD = { width: 2400, height: 1500 };

/** A name on the map. Tier 1 shows zoomed out, tier 2 from the middle zoom, tier 3 zoomed in. */
export type MapPlace = { id: string; name: string; x: number; y: number; tier: 1 | 2 | 3 };

export type StampKind = 'tower' | 'shrine' | 'camp' | 'village';
/** A marked location: a cyan diamond icon that the cursor can select. */
export type MapStamp = { id: string; name: string; x: number; y: number; kind: StampKind };

export type PinColor = 'red' | 'blue' | 'amber' | 'green' | 'pink';
export const PIN_COLORS: PinColor[] = ['red', 'blue', 'amber', 'green', 'pink'];
/** A pin the player placed: one per colour. */
export type MapPin = { color: PinColor; x: number; y: number };

export type MapPoint = { x: number; y: number };
/** Where the player stands and which way they face (degrees clockwise from north). */
export type MapPlayer = MapPoint & { heading: number };
/** The destination: a point and the name of what waits there. */
export type MapDestination = MapPoint & { label: string };

export const DEFAULT_PLACES: MapPlace[] = [
  { id: 'ashfall', name: 'Ashfall Highlands', x: 1790, y: 330, tier: 1 },
  { id: 'lowmere', name: 'Lowmere Vale', x: 690, y: 1040, tier: 1 },
  { id: 'saltreach', name: 'Saltreach Coast', x: 1960, y: 1190, tier: 1 },
  { id: 'northwood', name: 'Northwood', x: 820, y: 330, tier: 1 },
  { id: 'bellwater', name: 'Bellwater Lake', x: 1360, y: 1010, tier: 2 },
  { id: 'harrow', name: 'Harrow Bridge', x: 1080, y: 860, tier: 2 },
  { id: 'quarry', name: 'Old Quarry', x: 1560, y: 600, tier: 2 },
  { id: 'windmere', name: 'Windmere Pass', x: 1060, y: 470, tier: 2 },
  { id: 'fenwick', name: 'Fenwick Mill', x: 560, y: 820, tier: 2 },
  { id: 'gull', name: 'Gull Point', x: 2110, y: 860, tier: 2 },
  { id: 'pines', name: 'Twin Pines', x: 1230, y: 520, tier: 3 },
  { id: 'spring', name: 'Cold Spring', x: 1440, y: 690, tier: 3 },
  { id: 'hut', name: 'Fisher’s Hut', x: 1250, y: 1090, tier: 3 },
  { id: 'lookout', name: 'Lookout Rock', x: 880, y: 610, tier: 3 },
  { id: 'ember', name: 'Ember Camp', x: 1660, y: 800, tier: 3 },
  { id: 'steps', name: 'Mossy Steps', x: 790, y: 880, tier: 3 },
];

export const DEFAULT_STAMPS: MapStamp[] = [
  { id: 'hollow', name: 'Hollow Shrine', x: 991, y: 683, kind: 'shrine' },
  { id: 'signal', name: 'Signal Tower', x: 1500, y: 470, kind: 'tower' },
  { id: 'riverside', name: 'Riverside Camp', x: 1190, y: 940, kind: 'camp' },
  { id: 'fenwick-village', name: 'Fenwick', x: 640, y: 760, kind: 'village' },
  { id: 'tidewater', name: 'Tidewater Shrine', x: 1760, y: 990, kind: 'shrine' },
];

export const DEFAULT_PLAYER: MapPlayer = { x: 1091, y: 705, heading: 52 };
export const DEFAULT_DESTINATION: MapDestination = { x: 1240, y: 600, label: 'Follow the beacon' };
export const DEFAULT_PINS: MapPin[] = [{ color: 'blue', x: 1404, y: 772 }];

/** The river, from the highland springs to the lake and out to the southern sea. */
export const RIVER =
  'M1690 470 C1660 520 1600 520 1580 570 S1600 650 1540 690 S1430 700 1440 770 S1500 840 1430 880 S1360 930 1350 990 M1300 1060 C1270 1100 1220 1090 1200 1140 S1240 1220 1190 1270 S1110 1320 1140 1390 S1160 1450 1130 1490';
/** Region borders, dashed. */
export const BORDERS = [
  'M1330 140 C1360 260 1300 360 1360 470 S1480 640 1600 720 S1860 840 2000 780',
  'M400 620 C520 640 640 600 760 660 S940 780 1000 900 S1040 1080 980 1260',
];

// Seeded value noise.
function hash(ix: number, iy: number) {
  let h = (ix * 374761393 + iy * 668265263) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
}
function noise(x: number, y: number) {
  const ix = Math.floor(x);
  const iy = Math.floor(y);
  const fx = x - ix;
  const fy = y - iy;
  const sx = fx * fx * (3 - 2 * fx);
  const sy = fy * fy * (3 - 2 * fy);
  const a = hash(ix, iy);
  const b = hash(ix + 1, iy);
  const c = hash(ix, iy + 1);
  const d = hash(ix + 1, iy + 1);
  return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
}

const BUMPS: [number, number, number, number][] = [
  // x, y, radius, height
  [1780, 420, 300, 0.8],
  [1560, 300, 180, 0.45],
  [880, 380, 220, 0.42],
  [620, 1060, 220, 0.38],
  [1140, 660, 160, 0.26],
  [1960, 1080, 200, 0.4],
  [1360, 970, 120, -0.75],
  [290, 330, 280, -0.7],
  [2200, 1420, 260, -0.5],
  [1020, 1360, 160, -0.35],
];

function height(x: number, y: number) {
  const dx = (x - 1200) / 1120;
  const dy = (y - 760) / 690;
  let h = 0.5 * (1 - Math.sqrt(dx * dx + dy * dy)) + 0.05;
  for (const [bx, by, r, a] of BUMPS) {
    const q = ((x - bx) ** 2 + (y - by) ** 2) / (r * r);
    h += a * Math.exp(-q);
  }
  h +=
    (noise(x / 260, y / 260) - 0.5) * 0.22 +
    (noise(x / 90 + 40, y / 90) - 0.5) * 0.1 +
    (noise(x / 38 + 9, y / 38 + 3) - 0.5) * 0.05;
  return h;
}

const STEP = 16;
/** Height lines every 0.07, from the sea floor up; every other one is a main line. */
const LEVELS = Array.from({ length: 21 }, (_, at) => Math.round((at - 5) * 7) / 100);

type Pt = [number, number];

/** Marching squares at one level, joined into runs. */
function trace(
  field: Float32Array,
  cols: number,
  rows: number,
  level: number,
  step = STEP,
): Pt[][] {
  const at = (i: number, j: number) => field[j * (cols + 1) + i];
  const segments: [Pt, Pt][] = [];
  const lerp = (a: number, b: number) => (level - a) / (b - a);
  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      const a = at(i, j);
      const b = at(i + 1, j);
      const c = at(i + 1, j + 1);
      const d = at(i, j + 1);
      const code =
        (a > level ? 8 : 0) | (b > level ? 4 : 0) | (c > level ? 2 : 0) | (d > level ? 1 : 0);
      if (code === 0 || code === 15) continue;
      const x = i * step;
      const y = j * step;
      const top: Pt = [x + lerp(a, b) * step, y];
      const right: Pt = [x + step, y + lerp(b, c) * step];
      const bottom: Pt = [x + lerp(d, c) * step, y + step];
      const left: Pt = [x, y + lerp(a, d) * step];
      switch (code) {
        case 1:
        case 14:
          segments.push([left, bottom]);
          break;
        case 2:
        case 13:
          segments.push([bottom, right]);
          break;
        case 3:
        case 12:
          segments.push([left, right]);
          break;
        case 4:
        case 11:
          segments.push([top, right]);
          break;
        case 5:
          segments.push([left, top], [bottom, right]);
          break;
        case 6:
        case 9:
          segments.push([top, bottom]);
          break;
        case 7:
        case 8:
          segments.push([left, top]);
          break;
        case 10:
          segments.push([top, right], [left, bottom]);
          break;
      }
    }
  }
  // Join segments that share an end into runs.
  const key = (p: Pt) => `${Math.round(p[0] * 10)},${Math.round(p[1] * 10)}`;
  const ends = new Map<string, number[]>();
  segments.forEach(([p, q], index) => {
    for (const e of [key(p), key(q)]) {
      const list = ends.get(e);
      if (list) list.push(index);
      else ends.set(e, [index]);
    }
  });
  const used = new Uint8Array(segments.length);
  const runs: Pt[][] = [];
  const extend = (run: Pt[]) => {
    for (;;) {
      const tail = run[run.length - 1];
      const next = (ends.get(key(tail)) ?? []).find((index) => !used[index]);
      if (next === undefined) return;
      used[next] = 1;
      const [p, q] = segments[next];
      run.push(key(p) === key(tail) ? q : p);
    }
  };
  segments.forEach(([p, q], index) => {
    if (used[index]) return;
    used[index] = 1;
    const run: Pt[] = [p, q];
    extend(run);
    run.reverse();
    extend(run);
    if (run.length > 3) runs.push(run);
  });
  return runs;
}

/** One pass of Chaikin's corner cutting, keeping the ends of an open run. */
function smooth(run: Pt[]): Pt[] {
  const closed = Math.hypot(run[0][0] - run.at(-1)![0], run[0][1] - run.at(-1)![1]) < 0.5;
  const out: Pt[] = closed ? [] : [run[0]];
  for (let k = 0; k < run.length - 1; k++) {
    const [ax, ay] = run[k];
    const [bx, by] = run[k + 1];
    out.push(
      [ax * 0.75 + bx * 0.25, ay * 0.75 + by * 0.25],
      [ax * 0.25 + bx * 0.75, ay * 0.25 + by * 0.75],
    );
  }
  if (closed) out.push(out[0]);
  else out.push(run.at(-1)!);
  return out;
}

const toPath = (runs: Pt[][]) =>
  runs
    .map(
      (run) =>
        'M' +
        run.map(([x, y]) => `${Math.round(x * 10) / 10} ${Math.round(y * 10) / 10}`).join('L'),
    )
    .join('');

let cache: { coast: string; main: string; faint: string } | null = null;

/** The coast and the height lines as path data, traced once and kept. */
export function contours() {
  if (cache) return cache;
  const cols = Math.ceil(WORLD.width / STEP);
  const rows = Math.ceil(WORLD.height / STEP);
  const field = new Float32Array((cols + 1) * (rows + 1));
  for (let j = 0; j <= rows; j++)
    for (let i = 0; i <= cols; i++) field[j * (cols + 1) + i] = height(i * STEP, j * STEP);
  const lines = LEVELS.map((level) => toPath(trace(field, cols, rows, level).map(smooth)));
  cache = {
    coast: lines[LEVELS.indexOf(0)],
    main: lines.filter((_, at) => LEVELS[at] !== 0 && at % 2 === 1).join(''),
    faint: lines.filter((_, at) => at % 2 === 0).join(''),
  };
  return cache;
}

/** The size of the ground texture's tile, in world units. */
export const TEXTURE_SIZE = 440;
let textureCache: string | null = null;

/**
 * The faint ground texture under everything: dense rings traced from a noise field that wraps at
 * the tile's edges, so the tile repeats without a seam.
 */
export function texture() {
  if (textureCache) return textureCache;
  const step = 5.5;
  const cols = TEXTURE_SIZE / step;
  const wrapped = (x: number, y: number, period: number) => {
    const ix = Math.floor(x);
    const iy = Math.floor(y);
    const fx = x - ix;
    const fy = y - iy;
    const sx = fx * fx * (3 - 2 * fx);
    const sy = fy * fy * (3 - 2 * fy);
    const h = (i: number, j: number) =>
      hash((((i % period) + period) % period) + 911, (((j % period) + period) % period) + 37);
    const a = h(ix, iy);
    const b = h(ix + 1, iy);
    const c = h(ix, iy + 1);
    const d = h(ix + 1, iy + 1);
    return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
  };
  const field = new Float32Array((cols + 1) * (cols + 1));
  for (let j = 0; j <= cols; j++)
    for (let i = 0; i <= cols; i++) {
      const x = (i % cols) * step;
      const y = (j % cols) * step;
      field[j * (cols + 1) + i] =
        wrapped(x / 55, y / 55, 8) * 0.75 + wrapped(x / 27.5, y / 27.5, 16) * 0.25;
    }
  const runs: Pt[][] = [];
  for (let level = 0.16; level < 0.9; level += 0.055)
    runs.push(...trace(field, cols, cols, level, step).map(smooth));
  textureCache = toPath(runs);
  return textureCache;
}
```

### world-map/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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```

### world-map/world-map.module.css

```css
/* World map (world-map.tsx): a game's map screen on a tablet-like device, centred on a plain
   stage. Every size is a multiple of --u, 1 px on a full 1014 × 572 screen; on a narrower stage
   --u shrinks down to 0.7 px, then the screen keeps its height and shows less of the map.

   The device is dark on any page, as a game draws it: navy map, black bands, bronze bezel, cyan
   type. Only what touches the page follows the colour scheme, through light-dark(): the screen's
   edge, its shadow and the reach of its glow.

   The camera is three registered custom properties. The terrain's SVG group, the marker layer and
   each marker's counter-scale read them, so one transition (data-glide) moves them all together,
   and a drag (no transition) keeps the map under the finger. Strokes on the map are
   non-scaling, so lines stay one device-independent px wide at any zoom. */

@property --world-map-cx {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
@property --world-map-cy {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
@property --world-map-z {
  syntax: '<number>';
  inherits: true;
  initial-value: 1;
}

.root {
  /* The device. */
  --band: #151515;
  --speck: rgb(255 255 255 / 0.1);
  --speck-dim: rgb(255 255 255 / 0.055);
  --sea: #081519;
  --sea-deep: #061014;
  --cyan: #21bcff;
  --cyan-glow: rgb(33 170 255 / 0.55);
  --pale: #bdefff;
  --pale-glow: rgb(26 160 255 / 0.95);
  --reticle: #c4f1fb;
  --ink: #062033;
  --edge-light: rgb(0 110 255 / 0.78);
  --edge-side: rgb(0 96 230 / 0.36);
  /* The map's lines. */
  --grid-minor: rgb(40 120 220 / 0.07);
  --grid-major: rgb(30 115 235 / 0.19);
  --cross: rgb(30 120 245 / 0.36);
  --joint: rgb(40 130 250 / 0.46);
  --star: rgb(70 150 255 / 0.62);
  --contour: rgb(110 175 215 / 0.1);
  --contour-faint: rgb(110 175 215 / 0.055);
  --ground: rgb(110 160 185 / 0.058);
  --coast: rgb(110 195 245 / 0.38);
  --river: rgb(50 140 255 / 0.36);
  --border: rgb(150 205 245 / 0.2);
  /* Markers. */
  --gold: #ffcf2e;
  --gold-deep: #9a6a06;
  --gold-light: #fff3a8;
  --gold-glow: rgb(255 190 40 / 0.8);
  --cone: #f3dc86;
  --label: #dcc153;
  --label-script: rgb(220 193 83 / 0.62);
  --stamp: #33c2ff;
  --stamp-light: #c4f1ff;
  --stamp-deep: #1588ce;
  --stamp-glow: rgb(30 170 255 / 0.85);
  --pin-spent: #4c4b46;
  --pin-spent-mark: #2b2a27;
  /* The bezel. */
  --bronze-light: #cba675;
  --bronze: #8f6f47;
  --bronze-deep: #4f3b25;
  --bronze-grain: #2a1d10;
  --bronze-shadow: #120d08;
  --bronze-shine: #f4dcae;
  --bezel-glow: #1e7bff;
  --bezel-deep: #0b0f14;
  --bezel-stone: #121417;
  --gem-light: #b8f1ff;
  --gem: #20a8ff;
  --gem-deep: #0a3c9a;
  /* On the page itself. */
  --edge: rgb(0 0 0 / 0);
  --shadow: rgb(20 30 50 / 0.3);
  --halo: rgb(60 180 255 / 0.32);
  --focus: #8db4ff;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 400px;
  padding: 28px 0;
  box-sizing: border-box;
  color: var(--cyan);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.1));
    --shadow: light-dark(rgb(20 30 50 / 0.3), rgb(0 0 0 / 0.6));
    --halo: light-dark(rgb(60 180 255 / 0.32), rgb(40 150 255 / 0.4));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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;
}

/* The device: bands, map, bezel. */
.screen {
  --u: clamp(0.7px, 100cqi / 1014, 1px);
  /* The bezel's corners scale with --o, a little smaller than --u on a narrow stage. */
  --o: var(--u);
  position: relative;
  width: min(100%, calc(1014 * var(--u)));
  height: calc(572 * var(--u));
  border-radius: calc(5 * var(--u));
  background: var(--band);
  box-shadow:
    0 0 0 1px var(--edge),
    0 0 calc(28 * var(--u)) var(--halo),
    0 calc(12 * var(--u)) calc(40 * var(--u)) var(--shadow);
  user-select: none;
  -webkit-user-select: none;
}

.band {
  position: absolute;
  left: 0;
  right: 0;
  z-index: 1;
  background-color: var(--band);
  background-image:
    radial-gradient(var(--speck) 0.6px, transparent 1px),
    radial-gradient(var(--speck-dim) 0.5px, transparent 1px);
  background-size:
    7px 6px,
    11px 9px;
  background-position:
    0 0,
    3px 4px;
}
.band[data-edge='top'] {
  top: 0;
  height: calc(46 * var(--u));
  border-radius: calc(5 * var(--u)) calc(5 * var(--u)) 0 0;
}
.band[data-edge='bottom'] {
  bottom: 0;
  height: calc(44 * var(--u));
  border-radius: 0 0 calc(5 * var(--u)) calc(5 * var(--u));
}
.relief {
  position: absolute;
  left: 0;
  right: 0;
  height: calc(16 * var(--u));
  pointer-events: none;
}
.relief[data-edge='top'] {
  top: calc(1 * var(--u));
}
.relief[data-edge='bottom'] {
  bottom: calc(1 * var(--u));
}
.relief svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* The tabs: the screen's title between the two neighbouring screens and their shoulder keys. */
.tabs {
  position: absolute;
  left: 50%;
  top: calc(22 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(91 * var(--u));
  height: calc(22 * var(--u));
  transform: translateX(calc(-50% - 16 * var(--u)));
  white-space: nowrap;
}
.title {
  margin: 0;
  font-size: calc(19.5 * var(--u));
  font-weight: 400;
  line-height: 1;
  color: var(--cyan);
  text-shadow: 0 0 calc(6 * var(--u)) var(--cyan-glow);
}
.tab {
  display: inline-flex;
  align-items: center;
  gap: calc(8 * var(--u));
  font-size: calc(14 * var(--u));
  font-style: italic;
  font-weight: 600;
  color: var(--cyan);
  text-shadow: 0 0 calc(5 * var(--u)) var(--cyan-glow);
}
.tab svg {
  width: calc(19 * var(--u));
  height: calc(17 * var(--u));
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--cyan-glow));
}

/* The map. */
.map {
  position: absolute;
  top: calc(46 * var(--u));
  left: 0;
  right: 0;
  height: calc(482 * var(--u));
  overflow: hidden;
  background: radial-gradient(70% 80% at 50% 50%, transparent, var(--sea-deep)), var(--sea);
  touch-action: none;
  cursor: none;
  outline: none;
  animation: mapIn 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.map:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.map[data-glide='fast'] {
  transition:
    --world-map-cx 150ms cubic-bezier(0.2, 0.7, 0.3, 1),
    --world-map-cy 150ms cubic-bezier(0.2, 0.7, 0.3, 1),
    --world-map-z 150ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.map[data-glide='slow'] {
  transition:
    --world-map-cx 560ms cubic-bezier(0.22, 0.8, 0.24, 1),
    --world-map-cy 560ms cubic-bezier(0.22, 0.8, 0.24, 1),
    --world-map-z 560ms cubic-bezier(0.22, 0.8, 0.24, 1);
}
@keyframes mapIn {
  from {
    opacity: 0;
    filter: brightness(1.8);
  }
}

.terrain {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.world {
  transform: translate(calc(var(--vw) * 0.5px), calc(var(--vh) * 0.5px)) scale(var(--world-map-z))
    translate(calc(var(--world-map-cx) * -1px), calc(var(--world-map-cy) * -1px));
}
.world path {
  fill: none;
  vector-effect: non-scaling-stroke;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.world .gridMinor {
  stroke: var(--grid-minor);
  stroke-width: 1px;
}
.world .gridMajor {
  stroke: var(--grid-major);
  stroke-width: 1px;
}
.world .cross {
  stroke: var(--cross);
  stroke-width: 1px;
  stroke-linecap: butt;
}
.world .joint {
  stroke: var(--joint);
  stroke-width: 1px;
  stroke-linecap: butt;
}
.ground {
  fill: none;
  stroke: var(--ground);
  stroke-width: 1.1px;
}
.world .star {
  stroke: var(--star);
  stroke-width: 1.5px;
  stroke-linecap: butt;
}
.world .contourFaint {
  stroke: var(--contour-faint);
  stroke-width: 1px;
}
.world .contour {
  stroke: var(--contour);
  stroke-width: 1px;
}
.world .coast {
  stroke: var(--coast);
  stroke-width: 1.4px;
}
.world .river {
  stroke: var(--river);
  stroke-width: 2.2px;
}
.world .border {
  stroke: var(--border);
  stroke-width: 1px;
  stroke-dasharray: 6 5;
}

/* Markers hang off the camera and scale back by 1 / zoom, so they keep their size. */
.layer {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  transform-origin: 0 0;
  transform: translate(calc(var(--vw) * 0.5 * var(--u)), calc(var(--vh) * 0.5 * var(--u)))
    scale(var(--world-map-z))
    translate(calc(var(--world-map-cx) * -1 * var(--u)), calc(var(--world-map-cy) * -1 * var(--u)));
  pointer-events: none;
}
.marker,
.place {
  position: absolute;
  left: calc(var(--x) * var(--u));
  top: calc(var(--y) * var(--u));
  width: 0;
  height: 0;
  transform: scale(calc(1 / var(--world-map-z)));
}

/* Names, by zoom tier. */
.placeInner {
  position: absolute;
  left: 0;
  top: 0;
  display: grid;
  justify-items: center;
  gap: calc(2 * var(--u));
  translate: -50% -50%;
  font-size: calc(15.5 * var(--u));
  font-weight: 500;
  line-height: 1.1;
  white-space: nowrap;
  color: var(--label);
  text-shadow:
    0 0 calc(2 * var(--u)) #000,
    0 calc(1 * var(--u)) calc(4 * var(--u)) rgb(0 0 0 / 0.9);
  opacity: 0;
  scale: 0.92;
  transition:
    opacity 280ms ease-out,
    scale 380ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
.placeInner svg {
  height: calc(4.4 * var(--u));
  width: auto;
  color: var(--label-script);
}
.place[data-tier='1'] .placeInner {
  font-size: calc(20 * var(--u));
  letter-spacing: 0.01em;
}
.place[data-tier='3'] .placeInner {
  font-size: calc(13 * var(--u));
}
.map[data-zoom='far'] .place[data-tier='1'] .placeInner,
.map[data-zoom='mid'] .place[data-tier='2'] .placeInner,
.map[data-zoom='near'] .place[data-tier='2'] .placeInner,
.map[data-zoom='near'] .place[data-tier='3'] .placeInner {
  opacity: 1;
  scale: 1;
}

/* Stamps. */
.stamp {
  position: absolute;
  left: calc(-12 * var(--u));
  top: calc(-12 * var(--u));
  width: calc(24 * var(--u));
  height: calc(24 * var(--u));
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--stamp-glow));
}
.stamp svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* Pins and their beams. */
.pin {
  --pin-c: #f88b68;
  --pin-mark: #c94116;
  --pin-beam: #e0401c;
  --pin-edge: rgb(255 255 255 / 0.35);
  position: absolute;
  left: calc(-8.5 * var(--u));
  top: calc(-8.5 * var(--u));
  width: calc(17 * var(--u));
  height: calc(17 * var(--u));
  color: var(--pin-c);
}
.pin[data-color='blue'],
.swatch[data-color='blue'] {
  --pin-c: #9eacfe;
  --pin-mark: #1839da;
  --pin-beam: #2c4df0;
}
.pin[data-color='amber'],
.swatch[data-color='amber'] {
  --pin-c: #fdde92;
  --pin-mark: #ba841d;
  --pin-beam: #e7a443;
}
.pin[data-color='green'],
.swatch[data-color='green'] {
  --pin-c: #a2f1ba;
  --pin-mark: #299744;
  --pin-beam: #5ad57c;
}
.pin[data-color='pink'],
.swatch[data-color='pink'] {
  --pin-c: #feacf7;
  --pin-mark: #c74ac6;
  --pin-beam: #e57ee2;
}
.pin svg {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  filter: drop-shadow(0 0 calc(3.5 * var(--u)) var(--pin-beam));
}
.beam {
  position: absolute;
  left: 50%;
  bottom: 50%;
  width: calc(18 * var(--u));
  height: calc(104 * var(--u));
  margin-left: calc(-9 * var(--u));
  background:
    linear-gradient(to right, transparent 40%, rgb(255 255 255 / 0.5) 50%, transparent 60%),
    linear-gradient(to top, var(--pin-beam) 10%, transparent);
  -webkit-mask:
    linear-gradient(to right, transparent, #000 38% 62%, transparent),
    linear-gradient(to top, #000 70%, transparent);
  -webkit-mask-composite: source-in;
  mask:
    linear-gradient(to right, transparent, #000 38% 62%, transparent) intersect,
    linear-gradient(to top, #000 70%, transparent);
  opacity: 0.9;
  transform-origin: 50% 100%;
}
.pin[data-fresh] svg {
  animation: pinDrop 520ms cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
.pin[data-fresh] .beam {
  animation: beamUp 700ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms both;
}
@keyframes pinDrop {
  0% {
    opacity: 0;
    transform: translateY(calc(-14 * var(--u))) scale(1.5);
  }
  55% {
    opacity: 1;
    transform: translateY(0) scale(0.92);
    filter: drop-shadow(0 0 calc(10 * var(--u)) var(--pin-beam)) brightness(1.5);
  }
}
@keyframes beamUp {
  from {
    transform: scaleY(0);
    opacity: 1;
  }
}

/* The player. */
.player {
  position: absolute;
  left: 0;
  top: 0;
  rotate: var(--heading);
  transition: rotate 700ms cubic-bezier(0.3, 1.25, 0.5, 1);
}
.cone {
  position: absolute;
  left: calc(-80 * var(--u));
  top: calc(-80 * var(--u));
  width: calc(160 * var(--u));
  height: calc(160 * var(--u));
  animation: coneBreathe 3.2s ease-in-out infinite;
}
.cone svg,
.arrow svg {
  display: block;
  width: 100%;
  height: 100%;
}
.arrow {
  position: absolute;
  left: calc(-8.5 * var(--u));
  top: calc(-8.5 * var(--u));
  width: calc(17 * var(--u));
  height: calc(17 * var(--u));
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--gold-glow));
}
@keyframes coneBreathe {
  50% {
    opacity: 0.72;
  }
}

/* The destination: a gold disc, and while it is set a dashed ring that turns and pulses. */
.destination {
  position: absolute;
  left: 0;
  top: 0;
  animation: destIn 520ms cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
@keyframes destIn {
  from {
    scale: 0;
  }
}
.disc {
  position: absolute;
  left: calc(-8 * var(--u));
  top: calc(-8 * var(--u));
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  border-radius: 50%;
  background: radial-gradient(
    circle,
    #fff8b8 0 20%,
    #f7d64c 22% 34%,
    #b98412 37% 50%,
    #f9d548 53% 100%
  );
  box-shadow:
    0 0 calc(7 * var(--u)) calc(1 * var(--u)) var(--gold-glow),
    0 0 calc(16 * var(--u)) rgb(255 170 20 / 0.45);
}
.destination:not([data-tracked]) .disc {
  filter: saturate(0.7) brightness(0.85);
}
.ring,
.echo {
  position: absolute;
  left: calc(-21 * var(--u));
  top: calc(-21 * var(--u));
  width: calc(42 * var(--u));
  height: calc(42 * var(--u));
  border-radius: 50%;
  opacity: 0;
  scale: 0.4;
  transition:
    opacity 260ms ease-out,
    scale 420ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.ring {
  background: repeating-conic-gradient(from 8deg, #f2d98a 0 31deg, transparent 0 45deg);
  -webkit-mask: radial-gradient(
    circle closest-side,
    transparent calc(100% - 1.8 * var(--u)),
    #000 calc(100% - 1.6 * var(--u)) calc(100% - 0.2 * var(--u)),
    transparent 100%
  );
  mask: radial-gradient(
    circle closest-side,
    transparent calc(100% - 1.8 * var(--u)),
    #000 calc(100% - 1.6 * var(--u)) calc(100% - 0.2 * var(--u)),
    transparent 100%
  );
}
.echo {
  box-shadow: inset 0 0 0 calc(1.2 * var(--u)) rgb(255 215 110 / 0.7);
}
.destination[data-tracked] .ring {
  opacity: 0.9;
  scale: 1;
  animation: ringTurn 14s linear infinite;
}
.destination[data-tracked] .echo {
  animation: echo 1.8s cubic-bezier(0.2, 0.6, 0.3, 1) 400ms infinite;
}
@keyframes ringTurn {
  to {
    rotate: 1turn;
  }
}
@keyframes echo {
  0% {
    opacity: 0.8;
    scale: 0.5;
  }
  100% {
    opacity: 0;
    scale: 1.5;
  }
}

/* The cursor reticle: four corner triangles, tight at rest, open around a marker. */
.reticle {
  --s: calc(12 * var(--u));
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  transform: translate(calc(var(--rx) * var(--u)), calc(var(--ry) * var(--u)));
  pointer-events: none;
  z-index: 1;
}
.reticle[data-glide] {
  transition: transform 220ms cubic-bezier(0.25, 0.9, 0.3, 1);
}
.map[data-glide='slow'] .reticle[data-glide] {
  transition-duration: 560ms;
}
.reticle[data-open] {
  --s: calc(35 * var(--u));
}
.corners {
  position: absolute;
  left: 0;
  top: 0;
  filter: drop-shadow(0 0 calc(2.5 * var(--u)) rgb(40 180 255 / 0.95));
}
.corners i {
  position: absolute;
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
  background: var(--reticle);
  transition:
    left 260ms cubic-bezier(0.3, 1.6, 0.5, 1),
    top 260ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.corners i:nth-child(1) {
  left: calc(-1 * var(--s));
  top: calc(-1 * var(--s));
  clip-path: polygon(0 0, 100% 0, 0 100%);
}
.corners i:nth-child(2) {
  left: calc(var(--s) - 9 * var(--u));
  top: calc(-1 * var(--s));
  clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.corners i:nth-child(3) {
  left: calc(-1 * var(--s));
  top: calc(var(--s) - 9 * var(--u));
  clip-path: polygon(0 0, 0 100%, 100% 100%);
}
.corners i:nth-child(4) {
  left: calc(var(--s) - 9 * var(--u));
  top: calc(var(--s) - 9 * var(--u));
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
.corners[data-refused] {
  animation: shake 380ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
@keyframes shake {
  20%,
  60% {
    translate: calc(-4 * var(--u)) 0;
  }
  40%,
  80% {
    translate: calc(4 * var(--u)) 0;
  }
}

.callout {
  position: absolute;
  left: calc(40 * var(--u));
  top: calc(-28 * var(--u));
  display: grid;
  justify-items: start;
  gap: calc(5 * var(--u));
  width: max-content;
  max-width: calc(var(--room) * var(--u));
  color: var(--pale);
  font-style: italic;
  font-weight: 600;
  animation: calloutIn 260ms cubic-bezier(0.2, 0.8, 0.3, 1) 60ms both;
}
.reticle[data-flip] .callout {
  left: auto;
  right: calc(40 * var(--u));
  justify-items: end;
  text-align: right;
}
@keyframes calloutIn {
  from {
    opacity: 0;
    translate: calc(-10 * var(--u)) 0;
  }
}
.reticle[data-flip] .callout {
  animation-name: calloutInFlip;
}
@keyframes calloutInFlip {
  from {
    opacity: 0;
    translate: calc(10 * var(--u)) 0;
  }
}
.calloutName {
  font-size: calc(24 * var(--u));
  font-weight: 560;
  line-height: 1.15;
  text-wrap: balance;
  letter-spacing: -0.003em;
  text-shadow:
    0 0 calc(4 * var(--u)) var(--pale-glow),
    0 0 calc(10 * var(--u)) rgb(20 140 255 / 0.75);
}
.calloutAction {
  display: inline-flex;
  align-items: center;
  gap: calc(5 * var(--u));
  margin-left: calc(1 * var(--u));
  font-size: calc(14 * var(--u));
  text-shadow: 0 0 calc(5 * var(--u)) var(--pale-glow);
}
.calloutAction svg {
  width: calc(19 * var(--u));
  height: calc(19 * var(--u));
  color: var(--pale);
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--pale-glow));
}

/* The light of the bezel spilling over the map's edges. */
.glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 2;
  box-shadow:
    inset 0 calc(16 * var(--u)) calc(18 * var(--u)) calc(-8 * var(--u)) var(--edge-light),
    inset 0 calc(-20 * var(--u)) calc(22 * var(--u)) calc(-8 * var(--u)) var(--edge-light),
    inset calc(12 * var(--u)) 0 calc(26 * var(--u)) calc(-12 * var(--u)) var(--edge-side),
    inset calc(-12 * var(--u)) 0 calc(26 * var(--u)) calc(-12 * var(--u)) var(--edge-side);
}

/* The corners of the map: clock and thermometer, pin palette and sensor. */
.clock,
.status {
  position: absolute;
  bottom: calc(76 * var(--u));
  z-index: 2;
  display: grid;
  font-size: calc(14 * var(--u));
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.02em;
  color: var(--cyan);
  text-shadow: 0 0 calc(5 * var(--u)) var(--cyan-glow);
}
.clock {
  left: calc(43 * var(--u));
  gap: calc(8 * var(--u));
  font-size: calc(13 * var(--u));
  font-weight: 600;
  letter-spacing: 0.01em;
  pointer-events: none;
}
.clockLine {
  display: grid;
  justify-items: start;
  gap: calc(6 * var(--u));
}
.clockLine svg,
.sensorScript svg {
  display: block;
  height: calc(5.5 * var(--u));
  width: auto;
}
.status {
  right: calc(78 * var(--u));
  justify-items: end;
}
.palette {
  display: flex;
  gap: calc(6 * var(--u));
  margin: 0 0 calc(2 * var(--u)) calc(-3 * var(--u));
  pointer-events: auto;
}
.swatch {
  --pin-c: #f88b68;
  --pin-mark: #c94116;
  --pin-beam: #e0401c;
  --pin-edge: rgb(255 255 255 / 0.35);
  position: relative;
  display: grid;
  place-items: center;
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--pin-c);
  cursor: pointer;
}
.swatch svg {
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--pin-beam));
  transition:
    filter 200ms,
    scale 200ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.swatch[data-placed] {
  color: var(--pin-spent);
  --pin-mark: var(--pin-spent-mark);
  --pin-edge: rgb(255 255 255 / 0.08);
}
.swatch[data-placed] svg {
  filter: none;
}
.swatch[aria-checked='true'] svg {
  scale: 1.12;
}
.swatch[aria-checked='true']::after {
  content: '';
  position: absolute;
  inset: calc(-1 * var(--u));
  border-radius: calc(5 * var(--u));
  box-shadow:
    0 0 0 calc(1.4 * var(--u)) var(--reticle),
    0 0 calc(6 * var(--u)) var(--pale-glow);
}
.swatch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(3 * var(--u));
}
.sensorLine {
  display: inline-flex;
  align-items: center;
  gap: calc(8 * var(--u));
  font-size: calc(14 * var(--u));
  font-weight: 400;
}
.sensorIcon {
  display: block;
  width: calc(17 * var(--u));
  height: calc(17 * var(--u));
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--cyan-glow));
  animation: sensorFlip 360ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.sensorIcon svg {
  display: block;
  width: 100%;
  height: 100%;
}
.sensorIcon[data-on] {
  color: var(--pale);
}
@keyframes sensorFlip {
  from {
    scale: 0.4;
    opacity: 0;
    filter: drop-shadow(0 0 calc(10 * var(--u)) var(--pale-glow)) brightness(1.8);
  }
}
.sensorScript {
  margin-top: calc(-1 * var(--u));
  margin-right: calc(25 * var(--u));
}

/* The control hints along the bottom band. */
.hints {
  position: absolute;
  right: calc(93 * var(--u));
  top: calc(2 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(30 * var(--u));
  height: calc(22 * var(--u));
  white-space: nowrap;
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(5 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: calc(15 * var(--u));
  font-weight: 500;
  color: var(--cyan);
  text-shadow: 0 0 calc(5 * var(--u)) var(--cyan-glow);
  cursor: pointer;
  border-radius: calc(4 * var(--u));
}
span.hint {
  cursor: default;
}
.hint svg {
  width: calc(19 * var(--u));
  height: calc(19 * var(--u));
  filter: drop-shadow(0 0 calc(3 * var(--u)) var(--cyan-glow));
  transition: scale 160ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.hint[data-stick] svg {
  width: calc(23 * var(--u));
  height: calc(23 * var(--u));
}
.hint:is(button):hover svg {
  scale: 1.12;
}
.hint:is(button):active svg {
  scale: 0.9;
}
.hint[aria-pressed='true'] {
  color: var(--pale);
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(3 * var(--u));
}

/* The bezel's corners, over everything. */
.corner {
  position: absolute;
  z-index: 3;
  pointer-events: none;
}
.corner svg {
  display: block;
  width: 100%;
  height: 100%;
}
.corner[data-corner='tl'] {
  left: 0;
  top: 0;
  width: calc(164 * var(--o));
  height: calc(132 * var(--o));
}
.corner[data-corner='tr'] {
  right: 0;
  top: 0;
  width: calc(120 * var(--o));
  height: calc(228 * var(--o));
}
.corner[data-corner='bl'] {
  left: 0;
  bottom: 0;
  width: calc(110 * var(--o));
  height: calc(78 * var(--o));
}
.corner[data-corner='br'] {
  right: 0;
  bottom: 0;
  width: calc(74 * var(--o));
  height: calc(136 * var(--o));
}

.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* A narrow stage keeps the screen's height and drops what it can spare. */
@container (max-width: 440px) {
  .hint[data-short] span {
    display: none;
  }
}
@container (max-width: 640px) {
  .screen {
    --o: calc(var(--u) * 0.72);
  }
  .hint[data-optional] {
    display: none;
  }
  .hints {
    gap: calc(11 * var(--u));
    right: calc(48 * var(--u));
  }
  .tabs {
    gap: calc(40 * var(--u));
    transform: translateX(-50%);
  }
  .tab span {
    display: none;
  }
  .clock {
    left: calc(24 * var(--u));
  }
  .status {
    right: calc(52 * var(--u));
  }
}

/* Off screen or in a hidden tab, nothing loops. */
.root[data-paused] .map,
.root[data-paused] .map * {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .map,
  .map[data-glide],
  .reticle[data-glide],
  .corners i,
  .player,
  .placeInner,
  .ring,
  .echo,
  .swatch svg,
  .hint svg {
    transition: none;
  }
  .map,
  .cone,
  .destination,
  .destination[data-tracked] .ring,
  .destination[data-tracked] .echo,
  .pin[data-fresh] svg,
  .pin[data-fresh] .beam,
  .corners[data-refused],
  .callout,
  .sensorIcon {
    animation: none;
  }
}
```