# Minimap radar

> A game HUD minimap: player arrow and view cone, pins that slide along the rim, a turning north tab and sensor pings.

The round map in the corner of a game screen. The arrow at its centre is the player and the soft cone is where the camera looks; yellow pins sit on the map when near and ride the rim in a dashed ring when out of range, sliding along it as the player turns. A grey N tab orbits the edge while the map turns with the player, or stays put in north-up mode. A sensor badge lights up and pings faster as a pin gets closer, and a chip under the disc keeps the game clock and the temperature. Walk it with the arrow keys, on a navy grid or a terrain map.

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

Category: Games · HUD.

## Anatomy

1. **Disc**: A canvas at devicePixelRatio, clipped round: the ground under the player, a navy grid with faint contours or a terrain map, with a soft shadow off the page.
2. **North tab**: A grey tab outside the rim with an upright N. Heading-up, it orbits the rim as the map turns; north-up, it stays on top.
3. **Camera cone**: A 41° wedge with a radial yellow gradient, softly blurred: where the camera looks. It trails a turn and settles in about 160 ms.
4. **Player arrow**: The yellow arrow at the centre, with a glow. Heading-up, it always points up; north-up, it turns.
5. **Pin on the map**: A glowing yellow disc with a darker ring and a bright dot, placed where the pin is when it is within `range`.
6. **Pin on the rim**: A pin out of range, clamped to the rim in its direction inside a dashed ring; turning slides it along the edge.
7. **Sensor badge**: Grey until the nearest pin is within 2.4 ranges; then ice cyan, sending a ping ring across the map, faster as the pin nears.
8. **Chip**: A dark pill under the disc with the temperature on a cold, mild and hot dial, and the game clock.

## When to use

- A corner map in a game HUD that shows, without opening the big map, which way the player faces and where their pins are.
- Pins that may be anywhere in the world: near ones sit on the map, far ones ride the rim and point the way.
- A heading-up map that turns with the player, with a north tab on the rim; or north-up for players who keep their bearings.
- Telling the player a target is close without a number: a sensor that lights up and pings faster as the pin nears.
- A small status chip that belongs to the map, such as the game clock and the temperature.

## When not to use

- The full world map the player plans a route on. Use a world map with zoom, pan and a legend; this shows a few hundred metres.
- A single objective direction along the top of the screen. Use a compass strip, which takes less room.
- Locations on a real map in an app (stores, deliveries). Use a web map with real tiles and controls.
- A distance the player must read exactly, such as 37 m to the target. Put a label on a waypoint marker in the scene.
- Enemies tracked in real time in a competitive game. Use a radar with its own threat markers and rules; these pins are the player’s own.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/minimap-radar.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 { MinimapRadar } from './minimap-radar';

const pins = [
  { id: 'camp', x: 600, y: 470, label: 'Camp' },
  { id: 'peak', x: 300, y: 720, label: 'Peak' },
];

export function CornerMap() {
  return <MinimapRadar variant="terrain" pins={pins} controls={false} autoplay={false} size={180} />;
}
```

## States

- **Heading-up**: The defaults: the navy map turned so the player faces up, the Ridge pin on the map and two on the rim.
- **Turned**: `heading={120}`: the map has turned under the arrow, so the N tab sits low on the left and the rim pins have slid round.
- **North-up**: `rotate={false}` with the same heading: the map keeps north on top and the arrow and cone turn instead.
- **Terrain**: `variant="terrain"`: ochre land bands, water, woods and roads instead of the navy grid.
- **Pin close**: A pin 30 m ahead: the sensor is lit and pings about every 0.7 s.
- **All far**: Every pin out of range and past the sensor’s reach: they all ride the rim and the badge is grey.
- **No pins**: `pins={[]}`: the bare map; the label says “No pins.”
- **Zoomed out**: `range={320}`: the disc reaches twice as far, so all three pins sit on the map.
- **Night, °C**: `time={1380}` and `unit="C"`: 11:00 PM, colder, the needle in the ice segments.
- **Compact**: `sensor={false}` and `size={150}`: a smaller disc and a chip with only the dial and the clock.
- **With buttons**: `controls` (the default): turn and walk, drop a pin, Terrain and North up under the radar.
- **Light**: `colorScheme="light"`: a dark tab and rim, white buttons. The disc and chip never change.
- **Dark**: `colorScheme="dark"`: a light tab and hairline rim, a deeper shadow, dark buttons.

## How it works

- The ground is a canvas drawn at devicePixelRatio after every React commit, so it moves in the same paint as the SVG on top. It is clipped to the disc, then rotated by the map’s rotation, scaled to the range (160 world units across the radius by default) and translated so the player sits at the centre.
- The world is invented: periodic value noise in five octaves (8 to 128 cells across a 1024-unit square that repeats both ways) gives a height field. From it, one terrain texture (six ochre land bands, darker contour edges, blue-grey water with a shore band, dark wood clumps and two pale roads) and one transparent contour texture for the navy map are painted once, a few rows per frame, so the page never stalls.
- The navy map is #06203a with those contours at low alpha, a grid of lines every 72 units and small crosses at the quarter points, all drawn as one-device-pixel strokes so they stay sharp while turning, and a radial darkening over its outer tenth.
- Everything else is SVG in a 260-unit frame where the disc’s radius is 100: the camera cone (a 41° wedge with a radial yellow gradient, softly blurred), the arrow, the pins, the N tab, the sensor badge and the ping rings.
- Heading-up (the default) rotates the map by the heading, so the arrow points up and the N tab orbits the rim, its letter kept upright; north-up keeps the map still and turns the arrow. Switching swings the map to its new rotation over 420 ms (cubic ease-out) instead of jumping. The camera trails the character with a 160 ms exponential settle, so after a turn the cone catches up a moment later.
- Pins live in world units. Within range they are placed on the map; beyond it they are clamped to the rim in their direction, and a dashed ring (radius 20.8 units, dash 8.6 / 4.47) grows round them. Crossing the range, the ring spins and shrinks into the pin in 260 ms and the pin pops (scale 0.4 to 1.22 to 1); closer than 9 units, a pin is reached: it swells and scales away under a yellow burst ring.
- The sensor tracks the nearest pin within 2.4 ranges: its badge turns from grey to ice cyan and a cyan ring sweeps from the player to the rim in 1.1 s, every 2.2 s at the edge of its reach down to every 0.55 s close by; the badge’s three arcs flash white in turn with each ping.
- The chip runs the game clock at a game minute per second and shows the temperature (warmest mid-afternoon, colder at night and up high), its needle easing to each new reading on a nine-segment cold, mild and hot dial.
- Untouched and on screen, an autopilot steers with the same keys a reader has: it seeks a pin until it is reached, switches to terrain, goes north-up for a turn, drops a pin ahead and reaches it, switches back and restores the pins.

## API reference

- **variant** (`'navy' | 'terrain'`, default `'navy'`): The ground: a navy grid with faint contours, or a terrain map.
- **rotate** (`boolean`, default `true`): Heading-up: the map turns with the player and the N tab orbits. False keeps north up and turns the arrow.
- **pins** (`MinimapPin[]`, default `three pins`): Pins as { id, x, y, label? } in world units (the world repeats every 1024; the player starts at 512, 560).
- **range** (`number`, default `160`): How far the disc’s radius reaches, in world units; pins beyond it ride the rim.
- **time** (`number`, default `550`): The game clock at the start, in minutes since midnight (550 is 09:10 AM).
- **heading** (`number`): Where the player faces at the start, in degrees clockwise from north. Setting it opens the radar turned that way and turns the autopilot off.
- **timeScale** (`number`, default `1`): Game minutes per real second; 0 stops the clock.
- **unit** (`'C' | 'F'`, default `'F'`): The temperature’s unit in the chip.
- **sensor** (`boolean`, default `true`): Shows the sensor badge and its pings.
- **controls** (`boolean`, default `true`): Shows the buttons under the radar: turn, walk, drop a pin, terrain, north up.
- **size** (`number`): The disc’s diameter in px. Without it, 176, then 200 on a stage 400 px wide or more and 232 from 640 px.
- **onPinReached** (`(pin: MinimapPin) => void`): Called when the player reaches a pin, which then clears.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, an autopilot plays a short run; any real input hands it over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark north tab, rim and buttons, or the reader’s (system); without it, the page’s color-scheme.
- **className** (`string`): Added to the root element.

## Accessibility

- The radar is one image (role="img") whose label says the mode, the map, the facing and the nearest pin with its distance and side, such as “Facing north-east. 3 pins; nearest Ridge, 139 metres ahead left”; the drawing is aria-hidden.
- The stage takes focus: then the arrow keys or W A S D walk and turn, and P drops a pin. The buttons under it do the same: the four movement buttons are held with the pointer or with Space or Enter on the focused button, and Terrain and North up are toggle buttons with aria-pressed.
- The time and temperature are plain text with visually hidden labels.
- A pin coming into range, a pin reached, a pin dropped and a mode change are announced through a polite live region, only for the reader’s own play, never for the autopilot.
- Reduced motion: no autopilot, no pings, pops, bursts or boot animation, and mode switches are instant; walking still moves the map, since that is the reader’s own input.
- The clock and the demo pause offscreen and in a hidden tab, and the autopilot lets go of its keys the moment the reader takes over.

## Source

### minimap-radar/minimap-radar.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import { WORLD, paintTextures, temperatureAt, type Textures } from './world';
import styles from './minimap-radar.module.css';

/*
 * Minimap radar: the round map in a corner of a game HUD that tells the player, without opening
 * the big map, which way they face, where their pins are and which way is north.
 *
 * - The disc is a canvas at devicePixelRatio: the ground under the player, either a navy grid
 *   with faint contour lines or a terrain map (world.ts paints both textures from one invented,
 *   endlessly repeating height field). Everything on top of it is SVG in a 200-unit frame (the
 *   disc's radius is 100): the camera cone, the player arrow, the pins, the north tab, the
 *   sensor badge and its pings, so it all stays sharp at any size.
 * - With `rotate` (heading-up, the default) the map turns under a fixed arrow and the N tab
 *   orbits the rim; without it (north-up) the map only pans and the arrow turns. Switching swings
 *   the map to its new orientation in 420 ms. The cone shows where the camera looks: it trails a
 *   turn for a moment and settles.
 * - Pins live in world units. Within `range` they sit on the map; past it they are clamped to the
 *   rim in their direction and wear a dashed ring, so turning slides them along the edge. Coming
 *   into range, the ring shrinks into the pin and fades; reaching one clears it with a burst.
 * - The sensor tracks the nearest pin: in reach it lights up and sends a ping ring across the map,
 *   faster the closer the pin. Under the disc, a chip shows the game clock (a game minute per
 *   second) and the temperature, colder up high and at night, on a small cold/hot dial.
 * - The arrow keys (or WASD) walk and turn, P drops a pin ahead; the buttons under it do the same
 *   by pointer. While on screen and untouched, an autopilot plays a short run (seek a pin, switch
 *   to terrain, go north-up, drop a pin, reach it) and any real input hands it over.
 * - With no game scene behind it, the radar brings its own footing: an opaque disc with a hairline
 *   rim and a soft shadow, and a north tab and chip that are tokens for light and dark pages.
 */

/** The player's walking speed in units (metres) per second, and the turning rate in rad/s. */
const SPEED = 58;
const TURN = (150 * Math.PI) / 180;
/** Closer than this, a pin is reached. */
const REACH = 9;
/** How long the map takes to swing to a new orientation, in ms. */
const SWING = 420;
/** How far the sensor reaches, in ranges, and its ping intervals near and far, in s. */
const SENSOR_REACH = 2.4;
const PING_NEAR = 0.55;
const PING_FAR = 2.2;
/** How long a ping ring and a reach burst last, in ms. */
const PING_MS = 1100;
const BURST_MS = 560;
/** The crossfade between the navy and terrain maps, in ms. */
const FADE = 340;

export type MinimapPin = { id: string; x: number; y: number; label?: string };
type Variant = 'navy' | 'terrain';
type Key = 'forward' | 'back' | 'left' | 'right';

const START = { x: 512, y: 560 };
/** The default pins: one near, two off-range (behind left, far ahead right). */
export const DEFAULT_PINS: MinimapPin[] = [
  { id: 'ridge', x: START.x + 92, y: START.y - 104, label: 'Ridge' },
  { id: 'well', x: START.x - 250, y: START.y + 150, label: 'Old well' },
  { id: 'tower', x: START.x + 300, y: START.y - 250, label: 'Tower' },
];

/** The shortest signed angle from b to a, in -π..π. */
function turn(a: number, b: number) {
  return ((((a - b + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) - Math.PI;
}
/** The shortest offset along one wrapped world axis. */
function wrap(d: number) {
  return d - Math.round(d / WORLD) * WORLD;
}
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);

type World = {
  x: number;
  y: number;
  /** Where the character faces and where the camera looks, clockwise from north, in rad. */
  heading: number;
  cam: number;
  /** The map's rotation, and what is left of a swing between orientations. */
  rot: number;
  swing: { from: number; at: number } | null;
  rotate: boolean;
  keys: Set<Key>;
  pins: MinimapPin[];
  /** Pins inside range, so the ring's change can be announced once. */
  near: Set<string>;
  minutes: number;
  temp: number;
  /** Seconds since the last ping. */
  ping: number;
};

type Frame = {
  x: number;
  y: number;
  heading: number;
  cam: number;
  rot: number;
  pins: MinimapPin[];
  minutes: number;
  temp: number;
  /** Distance to the nearest pin, when the sensor reaches it. */
  sensor: number | null;
};

const snap = (w: World, range: number): Frame => {
  let nearest = Infinity;
  for (const pin of w.pins)
    nearest = Math.min(nearest, Math.hypot(wrap(pin.x - w.x), wrap(pin.y - w.y)));
  return {
    x: w.x,
    y: w.y,
    heading: w.heading,
    cam: w.cam,
    rot: w.rot,
    pins: w.pins,
    minutes: w.minutes,
    temp: w.temp,
    sensor: nearest <= range * SENSOR_REACH ? nearest : null,
  };
};

const COMPASS = [
  'north',
  'north-east',
  'east',
  'south-east',
  'south',
  'south-west',
  'west',
  'north-west',
];
const SIDES = [
  'ahead',
  'ahead right',
  'right',
  'behind right',
  'behind',
  'behind left',
  'left',
  'ahead left',
];
const octant = (angle: number) => ((Math.round(angle / (Math.PI / 4)) % 8) + 8) % 8;

function clock(minutes: number) {
  const m = ((Math.floor(minutes) % 1440) + 1440) % 1440;
  const h = Math.floor(m / 60);
  return `${String(h % 12 || 12).padStart(2, '0')}:${String(m % 60).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`;
}

/* ------------------------------------------------------------------------------------------ */
/* Art                                                                                         */
/* ------------------------------------------------------------------------------------------ */

function PinArt() {
  return (
    <g className={styles.pinArt}>
      <circle r="8" className={styles.pinDisc} />
      <circle r="4.6" className={styles.pinLine} />
      <circle r="2.2" className={styles.pinDot} />
    </g>
  );
}

function SensorArt() {
  return (
    <g className={styles.sensorArt}>
      <circle r="15" className={styles.sensorPlate} />
      <g className={styles.sensorArcs}>
        <path d="M -4.2 -6.4 A 5.2 5.2 0 1 1 4.2 -6.4" />
        <path d="M -6.8 -3.4 A 8.4 8.4 0 1 1 6.8 -3.4" />
        <path d="M -9.3 -0.4 A 11.4 11.4 0 1 1 9.3 -0.4" className={styles.sensorOuter} />
      </g>
      <path
        className={styles.sensorMast}
        d="M -1.6 -6.6 L -3.6 9.5 L 3.6 9.5 L 1.6 -6.6 Z M 0 -12 a 3.1 3.1 0 1 0 0.01 0 Z"
      />
    </g>
  );
}

/** The temperature dial: nine segments from cold (left) to hot (right) and a needle. */
function Dial({ celsius }: { celsius: number }) {
  const t = Math.min(1, Math.max(0, (celsius + 10) / 50));
  const needle = -135 + t * 270;
  const segments = Array.from({ length: 9 }, (_, index) => {
    const a0 = ((-135 + index * 30 + 2) * Math.PI) / 180;
    const a1 = ((-135 + index * 30 + 28) * Math.PI) / 180;
    const r = 8.2;
    const p = (a: number) => `${(Math.sin(a) * r).toFixed(2)} ${(-Math.cos(a) * r).toFixed(2)}`;
    const zone = index < 3 ? styles.cold : index < 6 ? styles.mild : styles.hot;
    return <path key={index} d={`M ${p(a0)} A ${r} ${r} 0 0 1 ${p(a1)}`} className={zone} />;
  });
  return (
    <svg className={styles.dial} viewBox="-12 -12 24 24" aria-hidden="true" focusable="false">
      <circle r="11.6" className={styles.dialPlate} />
      {segments}
      <g style={{ transform: `rotate(${needle}deg)` }} className={styles.needle}>
        <path d="M -0.9 0 L 0 -8 L 0.9 0 Z" />
      </g>
      <circle r="2.2" className={styles.hub} />
    </svg>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The component                                                                               */
/* ------------------------------------------------------------------------------------------ */

const KEYS: { key: Key; glyph: string; label: string; shortcut: string }[] = [
  { key: 'left', glyph: '←', label: 'Turn left', shortcut: 'ArrowLeft' },
  { key: 'forward', glyph: '↑', label: 'Walk', shortcut: 'ArrowUp' },
  { key: 'back', glyph: '↓', label: 'Back', shortcut: 'ArrowDown' },
  { key: 'right', glyph: '→', label: 'Turn right', shortcut: 'ArrowRight' },
];

/** The autopilot's run: steps it plays in order, then from the top. */
type Step =
  | { do: 'wait'; s: number }
  | { do: 'seek'; pin: string | 'new' }
  | { do: 'turn'; by: number }
  | { do: 'walk'; s: number }
  | { do: 'variant'; to: Variant }
  | { do: 'rotate'; to: boolean }
  | { do: 'drop' }
  | { do: 'reset' };
const RUN: Step[] = [
  { do: 'wait', s: 1.6 },
  { do: 'seek', pin: 'ridge' },
  { do: 'wait', s: 0.6 },
  { do: 'variant', to: 'terrain' },
  { do: 'wait', s: 0.8 },
  { do: 'seek', pin: 'well' },
  { do: 'wait', s: 0.5 },
  { do: 'rotate', to: false },
  { do: 'wait', s: 0.6 },
  { do: 'turn', by: 2.2 },
  { do: 'walk', s: 1.4 },
  { do: 'rotate', to: true },
  { do: 'wait', s: 0.8 },
  { do: 'drop' },
  { do: 'wait', s: 0.6 },
  { do: 'seek', pin: 'new' },
  { do: 'wait', s: 0.5 },
  { do: 'variant', to: 'navy' },
  { do: 'wait', s: 0.8 },
  { do: 'reset' },
  { do: 'wait', s: 1.2 },
];

export type MinimapRadarProps = {
  /** The ground: a navy grid with faint contours, or a terrain map. */
  variant?: Variant;
  /** Heading-up: the map turns with the player and the N tab orbits. False: north-up. */
  rotate?: boolean;
  /** Pins, in world units (metres; the world repeats every 1024). The player starts at 512, 560. */
  pins?: MinimapPin[];
  /** How far the disc's radius reaches, in world units. */
  range?: number;
  /** The game clock at the start, in minutes since midnight. */
  time?: number;
  /** Where the player faces at the start, in degrees clockwise from north. Setting it opens the
   * radar turned that way and turns the autopilot off. */
  heading?: number;
  /** Game minutes per real second; 0 stops the clock. */
  timeScale?: number;
  /** The temperature's unit. */
  unit?: 'C' | 'F';
  /** Shows the sensor badge and its pings. */
  sensor?: boolean;
  /** Shows the buttons under the radar. */
  controls?: boolean;
  /** The disc's diameter in px; without it, 176, 200 from a stage 400 px wide, 232 from 640. */
  size?: number;
  /** Called when the player reaches a pin, which then clears. */
  onPinReached?: (pin: MinimapPin) => void;
  /** While on screen and untouched, an autopilot plays a short run by itself. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

export function MinimapRadar({
  variant: variantProp = 'navy',
  rotate: rotateProp = true,
  pins: pinsProp = DEFAULT_PINS,
  range = 160,
  time = 550,
  heading: headingProp,
  timeScale = 1,
  unit = 'F',
  sensor = true,
  controls = true,
  size,
  onPinReached,
  autoplay = true,
  colorScheme,
  className = '',
}: MinimapRadarProps) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(6000);
  const start = ((headingProp ?? 0) * Math.PI) / 180;

  const [variant, setVariant] = useState<Variant>(variantProp);
  const [rotate, setRotate] = useState(rotateProp);
  const [props, setProps] = useState({ variantProp, rotateProp, pinsProp });
  if (
    props.variantProp !== variantProp ||
    props.rotateProp !== rotateProp ||
    props.pinsProp !== pinsProp
  ) {
    setProps({ variantProp, rotateProp, pinsProp });
    setVariant(variantProp);
    setRotate(rotateProp);
  }

  const world = useRef<World>(null as unknown as World);
  if (!world.current)
    world.current = {
      x: START.x,
      y: START.y,
      heading: start,
      cam: start,
      rot: rotateProp ? start : 0,
      swing: null,
      rotate: rotateProp,
      keys: new Set(),
      pins: pinsProp,
      near: new Set(
        pinsProp
          .filter((p) => Math.hypot(wrap(p.x - START.x), wrap(p.y - START.y)) <= range)
          .map((p) => p.id),
      ),
      minutes: time,
      temp: temperatureAt(START.x, START.y, time),
      ping: 0,
    };
  const [frame, setFrame] = useState<Frame>(() => snap(world.current, range));
  const [pings, setPings] = useState<number[]>([]);
  const [bursts, setBursts] = useState<number[]>([]);
  const [leaving, setLeaving] = useState<(MinimapPin & { at: [number, number] })[]>([]);
  const [held, setHeld] = useState<Partial<Record<Key | 'pin', boolean>>>({});
  const [message, setMessage] = useState('');
  const reader = useRef(false);
  const live = useRef({ range, sensor, reduced, onPinReached, timeScale });
  live.current = { range, sensor, reduced, onPinReached, timeScale };

  // New pins from props replace the world's.
  useEffect(() => {
    const w = world.current;
    w.pins = pinsProp;
    w.near = new Set(
      pinsProp
        .filter((p) => Math.hypot(wrap(p.x - w.x), wrap(p.y - w.y)) <= range)
        .map((p) => p.id),
    );
    setFrame(snap(w, range));
  }, [pinsProp, range]);

  // The orientation: a swing from where the map is now to its new rotation.
  useEffect(() => {
    const w = world.current;
    if (w.rotate === rotate) return;
    w.rotate = rotate;
    const target = rotate ? w.heading : 0;
    w.swing = live.current.reduced ? null : { from: turn(w.rot, target), at: performance.now() };
    if (!w.swing) w.rot = target;
    setFrame(snap(w, live.current.range));
  }, [rotate]);

  const quiet = useRef(0);
  const say = (text: string) => {
    if (!reader.current) return;
    setMessage(text);
    window.clearTimeout(quiet.current);
    quiet.current = window.setTimeout(() => setMessage(''), 3000);
  };
  useEffect(() => () => window.clearTimeout(quiet.current), []);
  const sayRef = useRef(say);
  sayRef.current = say;

  // The clock: steps the world every frame while the radar is on screen.
  useEffect(() => {
    if (!visible) return;
    let raf = 0;
    let last = 0;
    let lastPing = performance.now();
    let shown: Frame = snap(world.current, live.current.range);
    shown = { ...shown, pins: [] };
    const step = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
      last = now;
      const w = world.current;
      const { range: r, sensor: sensing, reduced: calm, timeScale: pace } = live.current;
      // Turning and walking.
      const k = w.keys;
      const spin = (k.has('right') ? 1 : 0) - (k.has('left') ? 1 : 0);
      const walk = (k.has('forward') ? 1 : 0) - (k.has('back') ? 0.6 : 0);
      w.heading += spin * TURN * dt;
      w.x += Math.sin(w.heading) * walk * SPEED * dt;
      w.y -= Math.cos(w.heading) * walk * SPEED * dt;
      w.x = ((w.x % WORLD) + WORLD) % WORLD;
      w.y = ((w.y % WORLD) + WORLD) % WORLD;
      // The camera trails the character, then settles.
      w.cam += turn(w.heading, w.cam) * (calm ? 1 : 1 - Math.exp(-dt / 0.16));
      // The map's rotation, with what is left of a swing.
      const target = w.rotate ? w.heading : 0;
      if (w.swing) {
        const t = Math.min(1, (now - w.swing.at) / SWING);
        w.rot = target + w.swing.from * (1 - easeOut(t));
        if (t >= 1) w.swing = null;
      } else w.rot = target;
      // Time and temperature.
      w.minutes = (w.minutes + dt * pace) % 1440;
      w.temp += (temperatureAt(w.x, w.y, w.minutes) - w.temp) * (1 - Math.exp(-dt / 1.6));
      // Pins: reached, and in or out of range.
      for (const pin of w.pins) {
        const d = Math.hypot(wrap(pin.x - w.x), wrap(pin.y - w.y));
        if (d < REACH) {
          w.pins = w.pins.filter((p) => p !== pin);
          w.near.delete(pin.id);
          setLeaving((list) => [...list, { ...pin, at: [0, 0] }]);
          if (!calm) setBursts((list) => [...list, now]);
          sayRef.current(`${pin.label ?? 'Pin'} reached.`);
          live.current.onPinReached?.(pin);
          continue;
        }
        const inside = d <= r;
        if (inside && !w.near.has(pin.id)) {
          w.near.add(pin.id);
          sayRef.current(`${pin.label ?? 'Pin'} in range, ${Math.round(d)} metres.`);
        } else if (!inside && w.near.has(pin.id)) w.near.delete(pin.id);
      }
      const next = snap(w, r);
      // The sensor's pings: faster the nearer the pin.
      if (sensing && next.sensor !== null && !calm) {
        const t = Math.min(1, next.sensor / (r * SENSOR_REACH));
        const interval = PING_NEAR + (PING_FAR - PING_NEAR) * t;
        if ((now - lastPing) / 1000 >= interval) {
          lastPing = now;
          setPings((list) => [...list.filter((at) => now - at < PING_MS), now]);
        }
      }
      // A new frame only when something on screen changed.
      const moved =
        spin !== 0 ||
        walk !== 0 ||
        w.swing !== null ||
        Math.abs(turn(w.heading, w.cam)) > 0.0005 ||
        Math.floor(w.minutes) !== Math.floor(shown.minutes) ||
        Math.abs(w.temp - shown.temp) > 0.05 ||
        w.pins !== shown.pins ||
        w.rot !== shown.rot ||
        now - fade.current.at < FADE + 60;
      if (moved) {
        shown = next;
        setFrame(next);
      }
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [visible]);

  // Old pings, bursts and cleared pins go once their animation is over.
  useEffect(() => {
    if (!pings.length && !bursts.length && !leaving.length) return;
    const timer = window.setTimeout(() => {
      const now = performance.now();
      setPings((list) => list.filter((at) => now - at < PING_MS));
      setBursts((list) => list.filter((at) => now - at < BURST_MS));
      setLeaving([]);
    }, PING_MS);
    return () => window.clearTimeout(timer);
  }, [pings, bursts, leaving]);

  /* ---------------------------------------------------------------------------------------- */
  /* The canvas: the ground, drawn after each commit so it moves with the SVG on top           */
  /* ---------------------------------------------------------------------------------------- */

  const canvas = useRef<HTMLCanvasElement>(null);
  const textures = useRef<Textures | null>(null);
  const [ready, setReady] = useState(false);
  /** The variant crossfade: the one faded from, and since when. */
  const fade = useRef<{ from: Variant; to: Variant; at: number }>({
    from: variant,
    to: variant,
    at: 0,
  });
  if (fade.current.to !== variant)
    fade.current = {
      from: fade.current.to,
      to: variant,
      at: reduced ? 0 : performance.now(),
    };

  // The textures are painted once, in slices of a few rows per frame.
  useEffect(() => {
    const element = canvas.current;
    if (!element) return;
    const diameter = element.clientWidth || 200;
    const scale = Math.min(
      2,
      Math.max(1, (diameter * (window.devicePixelRatio || 1)) / (2 * range)),
    );
    const { textures: made, step } = paintTextures(Math.round(scale * 4) / 4);
    let raf = 0;
    const paint = () => {
      const start = performance.now();
      while (performance.now() - start < 8) {
        if (step()) {
          textures.current = made;
          setReady(true);
          return;
        }
      }
      raf = requestAnimationFrame(paint);
    };
    raf = requestAnimationFrame(paint);
    return () => cancelAnimationFrame(raf);
  }, [range]);

  useLayoutEffect(() => {
    const element = canvas.current;
    if (!element) return;
    const css = element.clientWidth;
    if (!css) return;
    const dpr = window.devicePixelRatio || 1;
    const px = Math.round(css * dpr);
    if (element.width !== px) {
      element.width = px;
      element.height = px;
    }
    const ctx = element.getContext('2d');
    if (!ctx) return;
    const { from, to, at } = fade.current;
    const mix = from === to ? 1 : Math.min(1, (performance.now() - at) / FADE);
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, px, px);
    const draw = (kind: Variant, alpha: number) => {
      ctx.save();
      ctx.globalAlpha = alpha;
      ctx.beginPath();
      ctx.arc(px / 2, px / 2, px / 2, 0, Math.PI * 2);
      ctx.clip();
      ctx.fillStyle = kind === 'navy' ? '#06203a' : '#3a2f10';
      ctx.fillRect(0, 0, px, px);
      const unit = px / 2 / range;
      ctx.translate(px / 2, px / 2);
      ctx.rotate(-frame.rot);
      ctx.scale(unit, unit);
      ctx.translate(-frame.x, -frame.y);
      const reach = range * 1.5;
      const tiles = (paint: (x: number, y: number) => void) => {
        for (
          let tx = Math.floor((frame.x - reach) / WORLD);
          tx <= Math.floor((frame.x + reach) / WORLD);
          tx++
        )
          for (
            let ty = Math.floor((frame.y - reach) / WORLD);
            ty <= Math.floor((frame.y + reach) / WORLD);
            ty++
          )
            paint(tx * WORLD, ty * WORLD);
      };
      const tex = textures.current;
      if (kind === 'terrain' && tex)
        tiles((x, y) => ctx.drawImage(tex.terrain, x, y, WORLD, WORLD));
      if (kind === 'navy') {
        if (tex) tiles((x, y) => ctx.drawImage(tex.contours, x, y, WORLD, WORLD));
        // The grid: lines every 72 units, small crosses between them, all one device pixel.
        const line = 1 / unit;
        const cell = 72;
        const x0 = Math.floor((frame.x - reach) / cell) * cell;
        const y0 = Math.floor((frame.y - reach) / cell) * cell;
        ctx.strokeStyle = '#0a2d4f';
        ctx.lineWidth = line * Math.max(1, Math.round(dpr * 0.6));
        ctx.beginPath();
        for (let x = x0; x <= frame.x + reach; x += cell) {
          ctx.moveTo(x, frame.y - reach);
          ctx.lineTo(x, frame.y + reach);
        }
        for (let y = y0; y <= frame.y + reach; y += cell) {
          ctx.moveTo(frame.x - reach, y);
          ctx.lineTo(frame.x + reach, y);
        }
        ctx.stroke();
        ctx.strokeStyle = '#0f4170';
        ctx.beginPath();
        const arm = 4.2;
        for (let x = x0; x <= frame.x + reach; x += cell / 4)
          for (let y = y0; y <= frame.y + reach; y += cell / 4) {
            const onX = (x - x0) % cell === 0;
            const onY = (y - y0) % cell === 0;
            const quarter =
              ((x - x0) % (cell / 2) !== 0 && (y - y0) % (cell / 2) !== 0) || (onX && onY);
            if (!quarter) continue;
            const a = onX && onY ? arm * 1.5 : arm;
            ctx.moveTo(x - a, y);
            ctx.lineTo(x + a, y);
            ctx.moveTo(x, y - a);
            ctx.lineTo(x, y + a);
          }
        ctx.stroke();
      }
      ctx.restore();
    };
    if (mix < 1) draw(from, 1);
    draw(to, mix);
  });

  /* ---------------------------------------------------------------------------------------- */
  /* Input and the autopilot                                                                   */
  /* ---------------------------------------------------------------------------------------- */

  const press = (key: Key, down: boolean) => {
    const keys = world.current.keys;
    if (down) keys.add(key);
    else keys.delete(key);
    setHeld((h) => ({ ...h, [key]: down }));
  };

  /** The autopilot, while it drives: the keys it holds. */
  const pilot = useRef({ on: false, keys: new Set<Key>() });
  const counter = useRef(0);
  const drop = () => {
    const w = world.current;
    const id = `pin-${++counter.current}`;
    const ahead = range * 1.7;
    const pin = {
      id,
      x: (w.x + Math.sin(w.heading + 0.35) * ahead + WORLD) % WORLD,
      y: (w.y - Math.cos(w.heading + 0.35) * ahead + WORLD) % WORLD,
      label: `Pin ${counter.current}`,
    };
    w.pins = [...w.pins, pin];
    setFrame(snap(w, range));
    setHeld((h) => ({ ...h, pin: true }));
    window.setTimeout(() => setHeld((h) => ({ ...h, pin: false })), 160);
    say(`${pin.label} dropped ahead, ${Math.round(ahead)} metres.`);
    return id;
  };

  // The autopilot: plays RUN on a loop while playing, steering with the same keys a reader has.
  const run = autoplay && headingProp === undefined && playing;
  const dropRef = useRef(drop);
  dropRef.current = drop;
  const pressRef = useRef(press);
  pressRef.current = press;
  useEffect(() => {
    if (!run) return;
    reader.current = false;
    let index = 0;
    let since = performance.now();
    let lastNew = '';
    let turned = 0;
    let raf = 0;
    const auto = pilot.current;
    auto.on = true;
    const set = (want: Partial<Record<Key, boolean>>) => {
      if (!auto.on) return;
      for (const key of ['forward', 'back', 'left', 'right'] as Key[]) {
        const on = Boolean(want[key]);
        if (auto.keys.has(key) === on) continue;
        if (on) auto.keys.add(key);
        else auto.keys.delete(key);
        pressRef.current(key, on);
      }
    };
    const next = () => {
      index = (index + 1) % RUN.length;
      since = performance.now();
      turned = 0;
      set({});
    };
    let lastTick = performance.now();
    const tick = (now: number) => {
      const dt = (now - lastTick) / 1000;
      lastTick = now;
      const w = world.current;
      const stepNow = RUN[index];
      const elapsed = (now - since) / 1000;
      if (stepNow.do === 'wait') {
        if (elapsed >= stepNow.s) next();
      } else if (stepNow.do === 'walk') {
        set({ forward: true });
        if (elapsed >= stepNow.s) next();
      } else if (stepNow.do === 'turn') {
        set(stepNow.by > 0 ? { right: true } : { left: true });
        turned += TURN * dt;
        if (turned >= Math.abs(stepNow.by)) next();
      } else if (stepNow.do === 'seek') {
        const id = stepNow.pin === 'new' ? lastNew : stepNow.pin;
        const pin = w.pins.find((p) => p.id === id);
        if (!pin || elapsed > 14) next();
        else {
          const want = Math.atan2(wrap(pin.x - w.x), -wrap(pin.y - w.y));
          const off = turn(want, w.heading);
          set({
            left: off < -0.06,
            right: off > 0.06,
            forward: Math.abs(off) < 0.5,
          });
        }
      } else {
        if (stepNow.do === 'variant') setVariant(stepNow.to);
        if (stepNow.do === 'rotate') setRotate(stepNow.to);
        if (stepNow.do === 'drop') lastNew = dropRef.current();
        if (stepNow.do === 'reset') {
          w.pins = pinsProp;
          w.near = new Set(
            pinsProp
              .filter((p) => Math.hypot(wrap(p.x - w.x), wrap(p.y - w.y)) <= range)
              .map((p) => p.id),
          );
        }
        next();
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(raf);
      set({});
      auto.on = false;
    };
  }, [run, pinsProp, range]);

  /** Real input: the reader takes over from the autopilot. */
  const real = () => {
    const auto = pilot.current;
    if (auto.on) {
      for (const key of auto.keys) press(key, false);
      auto.keys.clear();
      auto.on = false;
    }
    reader.current = true;
    take();
  };

  const keyOf = (event: ReactKeyboardEvent): Key | null => {
    switch (event.key) {
      case 'ArrowUp':
      case 'w':
      case 'W':
        return 'forward';
      case 'ArrowDown':
      case 's':
      case 'S':
        return 'back';
      case 'ArrowLeft':
      case 'a':
      case 'A':
        return 'left';
      case 'ArrowRight':
      case 'd':
      case 'D':
        return 'right';
    }
    return null;
  };
  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    if (event.target instanceof HTMLButtonElement && (event.key === ' ' || event.key === 'Enter'))
      return;
    const key = keyOf(event);
    if (key) {
      event.preventDefault();
      if (event.repeat) return;
      real();
      press(key, true);
    } else if ((event.key === 'p' || event.key === 'P') && !event.repeat) {
      event.preventDefault();
      real();
      drop();
    }
  };
  const onKeyUp = (event: ReactKeyboardEvent) => {
    const key = keyOf(event);
    if (key) press(key, false);
  };

  const holdProps = (key: Key) => ({
    onPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => {
      if (event.button !== 0) return;
      event.currentTarget.setPointerCapture(event.pointerId);
      real();
      press(key, true);
    },
    onPointerUp: () => press(key, false),
    onPointerCancel: () => press(key, false),
    onLostPointerCapture: () => press(key, false),
    onKeyDown: (event: ReactKeyboardEvent) => {
      if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
        event.preventDefault();
        event.stopPropagation();
        real();
        press(key, true);
      }
    },
    onKeyUp: (event: ReactKeyboardEvent) => {
      if (event.key === ' ' || event.key === 'Enter') {
        event.stopPropagation();
        press(key, false);
      }
    },
    onBlur: () => press(key, false),
    onContextMenu: (event: { preventDefault: () => void }) => event.preventDefault(),
  });

  /* ---------------------------------------------------------------------------------------- */
  /* Drawing                                                                                   */
  /* ---------------------------------------------------------------------------------------- */

  const f = frame;
  const scale = 100 / range;
  const cos = Math.cos(-f.rot);
  const sin = Math.sin(-f.rot);
  const deg = (rad: number) => (rad * 180) / Math.PI;
  const placed = f.pins.map((pin) => {
    const dx = wrap(pin.x - f.x);
    const dy = wrap(pin.y - f.y);
    const rx = (dx * cos - dy * sin) * scale;
    const ry = (dx * sin + dy * cos) * scale;
    const d = Math.hypot(rx, ry);
    const far = d > 100;
    const k = far ? 100 / d : 1;
    return { pin, x: rx * k, y: ry * k, far, metres: Math.round(d / scale) };
  });
  const celsius = f.temp;
  const shownTemp = Math.round(unit === 'F' ? (celsius * 9) / 5 + 32 : celsius);
  const tempText = `${shownTemp > 0 ? '+' : shownTemp < 0 ? '−' : ''}${Math.abs(shownTemp)} °${unit}`;

  const nearest = placed.reduce<(typeof placed)[number] | null>(
    (best, p) => (!best || p.metres < best.metres ? p : best),
    null,
  );
  const facing = COMPASS[octant(((f.heading % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2))];
  const summary =
    `Minimap, ${rotate ? 'turning with you' : 'north up'}, ${variant} map. Facing ${facing}. ` +
    (placed.length
      ? `${placed.length} ${placed.length === 1 ? 'pin' : 'pins'}${
          nearest
            ? `; nearest ${nearest.pin.label ?? 'pin'}, ${nearest.metres} metres ${
                SIDES[octant(Math.atan2(nearest.x, -nearest.y) + (rotate ? 0 : -f.heading))]
              }`
            : ''
        }.`
      : 'No pins.');

  const sensing = sensor && f.sensor !== null;
  const uid = `mmr${useId().replace(/[^a-zA-Z0-9]/g, '')}`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={size ? ({ '--size-prop': `${size}px` } as CSSProperties) : undefined}
      onKeyDown={onKeyDown}
      onKeyUp={onKeyUp}
    >
      <div
        className={styles.stage}
        data-part="stage"
        role="group"
        aria-label="Minimap: arrow keys or W A S D to walk and turn, P to drop a pin"
        tabIndex={0}
      >
        <div className={styles.hud} data-part="hud">
          <div
            className={styles.radar}
            data-part="radar"
            data-variant={variant}
            role="img"
            aria-label={summary}
          >
            <svg
              className={styles.under}
              viewBox="-130 -130 260 260"
              aria-hidden="true"
              focusable="false"
            >
              <g
                data-part="north"
                style={{ transform: `rotate(${deg(-f.rot)}deg)` }}
                className={styles.north}
              >
                <path
                  d="M -22 -94 L -2.9 -116.6 Q 0 -119.6 2.9 -116.6 L 22 -94 Z"
                  className={styles.tab}
                />
                {/* The N stays upright as the tab orbits. */}
                <text
                  x="0"
                  y="-104.4"
                  className={styles.tabInk}
                  transform={`rotate(${deg(f.rot)} 0 -104.4)`}
                >
                  N
                </text>
              </g>
            </svg>
            <canvas
              ref={canvas}
              className={styles.disc}
              data-part="disc"
              data-ready={ready || undefined}
            />
            <svg
              className={styles.over}
              viewBox="-130 -130 260 260"
              aria-hidden="true"
              focusable="false"
            >
              <defs>
                <radialGradient
                  id={`${uid}-cone`}
                  cx="0"
                  cy="0"
                  r="96"
                  gradientUnits="userSpaceOnUse"
                >
                  <stop offset="0" stopColor="var(--yellow)" stopOpacity="0.36" />
                  <stop offset="0.3" stopColor="var(--yellow)" stopOpacity="0.24" />
                  <stop offset="0.7" stopColor="var(--yellow)" stopOpacity="0.07" />
                  <stop offset="1" stopColor="var(--yellow)" stopOpacity="0" />
                </radialGradient>
                <radialGradient
                  id={`${uid}-edge`}
                  cx="0"
                  cy="0"
                  r="100"
                  gradientUnits="userSpaceOnUse"
                >
                  <stop offset="0.9" stopColor="var(--navy-edge)" stopOpacity="0" />
                  <stop offset="1" stopColor="var(--navy-edge)" stopOpacity="1" />
                </radialGradient>
                <filter id={`${uid}-soft`} x="-20%" y="-20%" width="140%" height="140%">
                  <feGaussianBlur stdDeviation="2.4" />
                </filter>
              </defs>
              <circle r="100" fill={`url(#${uid}-edge)`} className={styles.edge} />
              {pings.map((at) => (
                <circle key={at} r="100" className={styles.ping} data-part="ping" />
              ))}
              <g style={{ transform: `rotate(${deg(f.cam - f.rot)}deg)` }}>
                <path
                  d="M 0 0 L -34.2 -90.2 A 96.5 96.5 0 0 1 34.2 -90.2 Z"
                  fill={`url(#${uid}-cone)`}
                  filter={`url(#${uid}-soft)`}
                  data-part="cone"
                />
              </g>
              {placed
                .filter((p) => !p.far)
                .map(({ pin, x, y }) => (
                  <g
                    key={pin.id}
                    transform={`translate(${x.toFixed(2)} ${y.toFixed(2)})`}
                    data-part="pin"
                  >
                    <g key="near" className={styles.pin}>
                      <circle r="20.8" className={styles.ring} data-far="false" />
                      <PinArt />
                    </g>
                  </g>
                ))}
              {leaving.map((pin) => (
                <g key={`gone-${pin.id}`} className={styles.gone}>
                  <PinArt />
                </g>
              ))}
              {bursts.map((at) => (
                <circle key={at} r="34" className={styles.burst} />
              ))}
              <g style={{ transform: `rotate(${deg(f.heading - f.rot)}deg)` }} data-part="player">
                <path
                  className={styles.player}
                  d="M 0 -10 Q 0.8 -10 1.3 -8.9 L 6.4 5.4 Q 6.9 7 5.4 6.5 L 0 4.6 L -5.4 6.5 Q -6.9 7 -6.4 5.4 L -1.3 -8.9 Q -0.8 -10 0 -10 Z"
                />
              </g>
              <circle r="100" className={styles.rim} />
              {placed
                .filter((p) => p.far)
                .map(({ pin, x, y }) => (
                  <g
                    key={pin.id}
                    transform={`translate(${x.toFixed(2)} ${y.toFixed(2)})`}
                    data-part="pin"
                  >
                    <g key="far" className={styles.pin}>
                      <circle
                        r="20.8"
                        className={styles.ring}
                        data-far="true"
                        data-part="pin-ring"
                      />
                      <PinArt />
                    </g>
                  </g>
                ))}
            </svg>
          </div>
          <div className={styles.chip} data-part="chip">
            {sensor && (
              <svg
                className={styles.sensor}
                viewBox="-16 -16 32 32"
                data-part="sensor"
                data-on={sensing || undefined}
                aria-hidden="true"
                focusable="false"
              >
                {pings.slice(-1).map((at) => (
                  <g key={at} className={styles.sensorFlash}>
                    <SensorArt />
                  </g>
                ))}
                {!pings.length && <SensorArt />}
              </svg>
            )}
            <span className={styles.time} data-part="time">
              <span className={styles.visually}>Time </span>
              {clock(f.minutes)}
            </span>
            <span className={styles.temp} data-part="temperature">
              <Dial celsius={celsius} />
              <span className={styles.visually}>Temperature </span>
              {tempText}
            </span>
          </div>
        </div>
      </div>
      {controls && (
        <div className={styles.controls} data-part="controls">
          <div className={styles.pad} role="group" aria-label="Move">
            {KEYS.map(({ key, glyph, label, shortcut }) => (
              <button
                key={key}
                type="button"
                className={styles.key}
                data-part="key"
                data-active={held[key] || undefined}
                aria-label={label}
                aria-keyshortcuts={shortcut}
                title={`Hold ${label.toLowerCase()}`}
                {...holdProps(key)}
              >
                <span aria-hidden="true">{glyph}</span>
              </button>
            ))}
          </div>
          <button
            type="button"
            className={styles.action}
            data-part="drop"
            data-active={held.pin || undefined}
            aria-keyshortcuts="P"
            onClick={() => {
              real();
              drop();
            }}
          >
            <span className={styles.glyph} aria-hidden="true">
              P
            </span>
            Drop pin
          </button>
          <div className={styles.toggles} role="group" aria-label="Map">
            <button
              type="button"
              className={styles.toggle}
              data-part="variant"
              aria-pressed={variant === 'terrain'}
              onClick={() => {
                real();
                setVariant((v) => (v === 'navy' ? 'terrain' : 'navy'));
                say(variant === 'navy' ? 'Terrain map.' : 'Navy grid.');
              }}
            >
              Terrain
            </button>
            <button
              type="button"
              className={styles.toggle}
              data-part="rotate"
              aria-pressed={!rotate}
              onClick={() => {
                real();
                setRotate((r) => !r);
                say(rotate ? 'North up.' : 'Map turns with you.');
              }}
            >
              North up
            </button>
          </div>
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### minimap-radar/world.ts

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

/*
 * The invented world under the minimap: a square of WORLD units (metres) that repeats in both
 * directions, so the player can walk forever. Its height comes from periodic value noise (each
 * octave's lattice wraps at the world's edge), and from the height come the terrain colours, the
 * contour lines, the water and the temperature. Two textures are painted from it, in slices so
 * the page never stalls: the terrain map (ochre land, blue-grey water, dark woods, pale roads,
 * contour lines) and, for the navy variant, the same contours as faint lines on transparency.
 */

/** The world's side, in units; everything wraps at it. */
export const WORLD = 1024;
/** Below this height is water. */
const WATER = 0.33;

function hash(x: number, y: number, seed: number) {
  let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(seed, 2246822519);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
}

/** Periodic value noise: `cells` lattice cells across the world, smooth, from 0 to 1. */
function noise(x: number, y: number, cells: number, seed: number) {
  const fx = (x / WORLD) * cells;
  const fy = (y / WORLD) * cells;
  const ix = Math.floor(fx);
  const iy = Math.floor(fy);
  const tx = fx - ix;
  const ty = fy - iy;
  const sx = tx * tx * (3 - 2 * tx);
  const sy = ty * ty * (3 - 2 * ty);
  const wrap = (n: number) => ((n % cells) + cells) % cells;
  const x0 = wrap(ix);
  const x1 = wrap(ix + 1);
  const y0 = wrap(iy);
  const y1 = wrap(iy + 1);
  const a = hash(x0, y0, seed);
  const b = hash(x1, y0, seed);
  const c = hash(x0, y1, seed);
  const d = hash(x1, y1, seed);
  return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
}

/** Height at a world point, from 0 (deep water) to 1 (peaks). */
export function heightAt(x: number, y: number) {
  let sum = 0;
  let weight = 0;
  let amp = 1;
  for (const cells of [8, 16, 32, 64, 128]) {
    sum += noise(x, y, cells, cells) * amp;
    weight += amp;
    amp *= 0.56;
  }
  const h = sum / weight;
  // Stretch the middle so there are real lowlands and highlands.
  return Math.min(1, Math.max(0, (h - 0.5) * 2.5 + 0.5));
}

function woodsAt(x: number, y: number) {
  return noise(x, y, 32, 101) * 0.6 + noise(x, y, 128, 102) * 0.4;
}

/** Temperature in °C at a world point and time of day (minutes since midnight). */
export function temperatureAt(x: number, y: number, minutes: number) {
  const h = heightAt(x, y);
  // Warmest mid-afternoon, coldest before dawn.
  const day = Math.cos(((minutes / 60 - 15) / 24) * Math.PI * 2);
  return 15 + day * 5 - Math.max(0, h - WATER) * 15;
}

/** The roads: two wavy lines that wrap with the world. */
function roads() {
  const lines: [number, number][][] = [];
  const across: [number, number][] = [];
  const down: [number, number][] = [];
  for (let t = 0; t <= WORLD; t += 8) {
    const a = (t / WORLD) * Math.PI * 2;
    across.push([t, 300 + Math.sin(a) * 70 + Math.sin(a * 3 + 1.2) * 26]);
    down.push([700 + Math.sin(a * 2 + 0.4) * 60 + Math.sin(a + 2) * 40, t]);
  }
  lines.push(across, down);
  return lines;
}

type Rgb = [number, number, number];
const LAND: Rgb[] = [
  [40, 31, 8],
  [49, 39, 11],
  [58, 47, 16],
  [68, 57, 25],
  [79, 68, 35],
  [92, 80, 47],
];
const WATER_RGB: Rgb = [42, 57, 71];
const SHORE_RGB: Rgb = [54, 70, 80];
const WOODS_RGB: Rgb = [38, 28, 9];

export type Textures = { terrain: HTMLCanvasElement; contours: HTMLCanvasElement };

/**
 * Paints both textures at `scale` texels per unit, a few rows per call of `step` so it can be
 * spread over frames. Returns a function that paints the next slice and returns true when done.
 */
export function paintTextures(scale: number): { textures: Textures; step: () => boolean } {
  const size = Math.round(WORLD * scale);
  const make = () => {
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;
    return canvas;
  };
  const terrain = make();
  const contours = make();
  const tctx = terrain.getContext('2d')!;
  const cctx = contours.getContext('2d')!;
  // The height field a row at a time, plus the next row, for the contour test below each texel.
  const heights = (y: number) => {
    const out = new Float32Array(size + 1);
    for (let x = 0; x < size; x++) out[x] = heightAt(x / scale, (y % size) / scale);
    out[size] = out[0];
    return out;
  };
  const BANDS = 22;
  let row = 0;
  const rows = Math.max(8, Math.round(48 / scale));
  let below = heights(0);

  const step = () => {
    const end = Math.min(size, row + rows);
    const tImage = tctx.createImageData(size, end - row);
    const cImage = cctx.createImageData(size, end - row);
    const t = tImage.data;
    const c = cImage.data;
    for (let y = row; y < end; y++) {
      const here = below;
      const next = heights(y + 1);
      for (let x = 0; x < size; x++) {
        const wx = x / scale;
        const wy = y / scale;
        const h = here[x];
        const i = ((y - row) * size + x) * 4;
        let rgb: Rgb;
        if (h < WATER) rgb = h > WATER - 0.025 ? SHORE_RGB : WATER_RGB;
        else {
          const level = Math.min(
            LAND.length - 1,
            Math.floor(Math.pow((h - WATER) / (1 - WATER), 1.3) * 6.4),
          );
          rgb = LAND[level];
          // Woods: dark patches on the lower land, broken up into clumps.
          if (h < 0.74) {
            const w = woodsAt(wx, wy);
            if (w > 0.5 && hash(x >> 1, y >> 1, 7) < (w - 0.5) * 6) rgb = WOODS_RGB;
          }
        }
        const b = Math.floor(h * BANDS);
        const edge = b !== Math.floor(here[x + 1] * BANDS) || b !== Math.floor(next[x] * BANDS);
        const shade = edge && h >= WATER ? 0.8 : 1;
        t[i] = rgb[0] * shade;
        t[i + 1] = rgb[1] * shade;
        t[i + 2] = rgb[2] * shade;
        t[i + 3] = 255;
        if (edge) {
          c[i] = 40;
          c[i + 1] = 92;
          c[i + 2] = 140;
          c[i + 3] = 34;
        }
      }
      below = next;
    }
    tctx.putImageData(tImage, 0, row);
    cctx.putImageData(cImage, 0, row);
    row = end;
    if (row < size) return false;
    // Roads last, on top, drawn three times across each wrapped edge.
    tctx.save();
    tctx.scale(scale, scale);
    tctx.strokeStyle = 'rgb(214 200 140)';
    tctx.lineWidth = 2.2;
    tctx.lineJoin = 'round';
    for (const line of roads())
      for (const dx of [-WORLD, 0, WORLD])
        for (const dy of [-WORLD, 0, WORLD]) {
          tctx.beginPath();
          line.forEach(([x, y], index) =>
            index ? tctx.lineTo(x + dx, y + dy) : tctx.moveTo(x + dx, y + dy),
          );
          tctx.stroke();
        }
    tctx.restore();
    return true;
  };
  return { textures: { terrain, contours }, step };
}
```

### minimap-radar/minimap-radar.module.css

```css
/* Minimap radar (minimap-radar.tsx): the radar disc and its time and temperature chip, alone in the
   middle of a plain stage, with a row of buttons under it.

   The disc is --d wide (176px; 200px on a stage 400px wide or more, 232px from 640px, or the size
   prop). The SVG layers are 1.3 × --d, centred on it, in a 260-unit frame where the disc's radius
   is 100, so the north tab, the rim pins and the sensor badge can stand out past the edge.

   The disc, its pins and the chip are dark and glowing on any page, as a HUD draws them over a
   world; what touches the page (the north tab, the rim's hairline, the shadow, the buttons)
   follows the color scheme through light-dark() tokens. No filter blurs the art itself: glows are
   drop shadows behind sharp SVG shapes. */

.root {
  /* The radar's own colours, the same on any scheme. */
  --navy: #06203a;
  --navy-edge: #081724;
  --ground: #3a2f10;
  --yellow: #ffe460;
  --pin: #fff07a;
  --pin-line: #e8a80f;
  --pin-dash: rgb(255 240 178 / 0.88);
  --glow: rgb(255 212 50 / 0.85);
  --cyan: #3cd3fc;
  --cyan-glow: rgb(60 211 252 / 0.55);
  --ice: #88e8f8;
  --mild: #123a58;
  --hot: #f88810;
  --plate: rgb(5 17 30 / 0.94);
  --sensor-off: #5c5d58;
  /* What touches the page. */
  --tab: rgb(24 24 20 / 0.42);
  --tab-ink: rgb(255 255 255 / 0.78);
  --rim: rgb(0 0 0 / 0.5);
  --shade: rgb(20 18 12 / 0.28);
  /* The buttons, as quiet lab chips. */
  --chip: #fff;
  --chip-active: #ececec;
  --chip-line: rgb(0 0 0 / 0.1);
  --chip-ink: #1b1d1c;
  --glyph: #1d1f1e;
  --glyph-ink: #fff;
  --accent: #1d7fb0;
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  width: 100%;
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --tab: light-dark(rgb(24 24 20 / 0.42), rgb(255 255 255 / 0.17));
    --tab-ink: light-dark(rgb(255 255 255 / 0.82), rgb(255 255 255 / 0.66));
    --rim: light-dark(rgb(0 0 0 / 0.5), rgb(255 255 255 / 0.16));
    --shade: light-dark(rgb(20 18 12 / 0.28), rgb(0 0 0 / 0.6));
    --chip: light-dark(#fff, #1f2120);
    --chip-active: light-dark(#ececec, #2e3130);
    --chip-line: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --chip-ink: light-dark(#1b1d1c, #eceeed);
    --glyph: light-dark(#1d1f1e, #eceeed);
    --glyph-ink: light-dark(#fff, #151716);
    --accent: light-dark(#1d7fb0, #3cd3fc);
    --focus: light-dark(#387bff, #7aa7ff);
  }
}
.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 stage: transparent, the radar and its chip in its middle. */
.stage {
  --d: var(--size-prop, 176px);
  display: grid;
  place-items: center;
  min-height: calc(var(--d) * 1.62 + 24px);
  border-radius: 14px;
  outline: none;
}
@container (min-width: 400px) {
  .stage {
    --d: var(--size-prop, 200px);
  }
}
@container (min-width: 640px) {
  .stage {
    --d: var(--size-prop, 232px);
  }
}
.stage:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}

.hud {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(var(--d) * 0.13);
  padding-top: calc(var(--d) * 0.1);
  animation: boot 520ms cubic-bezier(0.2, 0.9, 0.25, 1.15) both;
}
@keyframes boot {
  from {
    opacity: 0;
    scale: 0.92;
  }
}

/* The radar: the canvas disc between two SVG layers. */
.radar {
  position: relative;
  width: var(--d);
  height: var(--d);
}
.under,
.over {
  position: absolute;
  top: -15%;
  left: -15%;
  width: 130%;
  height: 130%;
  overflow: visible;
  pointer-events: none;
}
.disc {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--navy);
  box-shadow: 0 calc(var(--d) * 0.02) calc(var(--d) * 0.08) var(--shade);
}
.radar[data-variant='terrain'] .disc {
  background: var(--ground);
}

/* The north tab, outside the rim, turned with the map. */
.tab {
  fill: var(--tab);
}
.tabInk {
  fill: var(--tab-ink);
  font-size: 9.6px;
  font-weight: 800;
  text-anchor: middle;
  dominant-baseline: central;
}

/* The navy map darkens to its rim; the terrain one does not. */
.edge {
  transition: opacity 340ms ease;
}
.radar[data-variant='terrain'] .edge {
  opacity: 0;
}
.rim {
  fill: none;
  stroke: var(--rim);
  stroke-width: 1px;
  vector-effect: non-scaling-stroke;
}

/* The player and the camera cone. */
.player {
  fill: var(--yellow);
  filter: drop-shadow(0 0 2.4px var(--glow));
}

/* Pins: a glowing yellow disc with a darker ring and a bright dot. Off-range, on the rim, a dashed
   ring grows around it; coming into range, the ring shrinks into the pin and fades. */
.pinArt {
  filter: drop-shadow(0 0 3px var(--glow));
  animation: pop 300ms cubic-bezier(0.34, 1.7, 0.6, 1) both;
  transform-box: fill-box;
  transform-origin: center;
}
.pinDisc {
  fill: var(--pin);
}
.pinLine {
  fill: none;
  stroke: var(--pin-line);
  stroke-width: 1.5;
}
.pinDot {
  fill: #fffbe0;
}
.ring {
  fill: none;
  stroke: var(--pin-dash);
  stroke-width: 1.7;
  stroke-dasharray: 8.6 4.47;
  transform-box: fill-box;
  transform-origin: center;
}
.ring[data-far='true'] {
  animation: ring-in 300ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.ring[data-far='false'] {
  animation: ring-out 260ms ease-in both;
}
@keyframes pop {
  0% {
    scale: 0.4;
  }
  60% {
    scale: 1.22;
  }
  100% {
    scale: 1;
  }
}
@keyframes ring-in {
  from {
    opacity: 0;
    scale: 0.4;
    rotate: -60deg;
  }
}
@keyframes ring-out {
  to {
    opacity: 0;
    scale: 0.38;
    rotate: 50deg;
  }
}
/* A reached pin scales away under a bright burst. */
.gone .pinArt {
  animation: gone 320ms ease-in both;
}
@keyframes gone {
  40% {
    scale: 1.5;
    opacity: 1;
  }
  100% {
    scale: 0.2;
    opacity: 0;
  }
}
.burst {
  fill: none;
  stroke: var(--yellow);
  stroke-width: 3;
  filter: drop-shadow(0 0 3px var(--glow));
  animation: burst 560ms cubic-bezier(0.1, 0.7, 0.3, 1) both;
}
@keyframes burst {
  from {
    scale: 0.15;
    opacity: 1;
    stroke-width: 6;
  }
  to {
    scale: 1;
    opacity: 0;
    stroke-width: 1;
  }
}

/* The sensor's ping: a cyan ring sweeping from the player to the rim. */
.ping {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.6;
  animation: ping 1100ms cubic-bezier(0.25, 0.6, 0.35, 1) both;
}
@keyframes ping {
  from {
    scale: 0.05;
    opacity: 0.95;
  }
  to {
    scale: 1;
    opacity: 0;
  }
}

/* The sensor badge: a dark plate with a mast and three arcs; lit cyan when the pin is in reach,
   its arcs flashing with each ping; grey out of reach. */
.sensor {
  display: block;
  flex: none;
  width: max(22px, calc(var(--d) * 0.135));
  height: max(22px, calc(var(--d) * 0.135));
  overflow: visible;
}
.sensorPlate {
  fill: #0c0d0d;
  stroke: rgb(255 255 255 / 0.12);
  stroke-width: 1;
}
.sensorArcs path {
  fill: none;
  stroke: var(--sensor-off);
  stroke-width: 1.5;
  stroke-linecap: round;
}
.sensorMast {
  fill: var(--sensor-off);
  fill-rule: nonzero;
}
.sensor[data-on] .sensorArcs path {
  stroke: var(--ice);
}
.sensor[data-on] .sensorMast {
  fill: var(--ice);
}
.sensor[data-on] .sensorArt {
  filter: drop-shadow(0 0 2px var(--cyan-glow));
}
.sensorFlash .sensorArcs path {
  animation: arc 640ms ease-out both;
}
.sensorFlash .sensorArcs path:nth-child(2) {
  animation-delay: 70ms;
}
.sensorFlash .sensorArcs path:nth-child(3) {
  animation-delay: 140ms;
}
.sensor:not([data-on]) .sensorFlash .sensorArcs path {
  animation: none;
}
@keyframes arc {
  0% {
    stroke: #fff;
  }
  100% {
    stroke: var(--ice);
  }
}

/* The chip: the game clock and the temperature, cyan on a dark plate. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--d) * 0.06);
  padding: calc(var(--d) * 0.022) calc(var(--d) * 0.07) calc(var(--d) * 0.022) calc(var(--d) * 0.03);
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px rgb(60 211 252 / 0.24),
    0 3px 10px var(--shade);
  color: var(--cyan);
  font-size: max(12px, calc(var(--d) * 0.066));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
  line-height: 1;
  white-space: nowrap;
  text-shadow: 0 0 6px var(--cyan-glow);
}
.time {
  order: 2;
  padding-left: calc(var(--d) * 0.06);
  border-left: 1px solid rgb(60 211 252 / 0.28);
}
.temp {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--d) * 0.03);
}
.dial {
  display: block;
  width: max(20px, calc(var(--d) * 0.12));
  height: max(20px, calc(var(--d) * 0.12));
  overflow: visible;
}
.dialPlate {
  fill: #0e0f0f;
}
.dial path {
  fill: none;
  stroke-width: 3.2;
}
.cold {
  stroke: var(--ice);
}
.mild {
  stroke: var(--mild);
}
.hot {
  stroke: var(--hot);
}
.needle path {
  fill: var(--ice);
  stroke: none;
}
.needle {
  transition: transform 500ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
.hub {
  fill: var(--ice);
}

/* The buttons, under the stage. */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 8px 10px;
  padding: 4px 8px 8px;
}
.pad,
.toggles {
  display: flex;
  gap: 6px;
}
.key,
.action,
.toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  min-height: 36px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-line);
  color: var(--chip-ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  transition:
    background-color 120ms ease,
    box-shadow 120ms ease,
    transform 120ms ease;
}
.key {
  width: 36px;
  padding: 0;
  font-size: 16px;
}
.action {
  padding: 0 12px 0 5px;
}
.toggle {
  padding: 0 13px;
}
.key[data-active],
.action[data-active] {
  background: var(--chip-active);
  transform: scale(0.94);
}
.toggle[aria-pressed='true'] {
  background: var(--chip-active);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.key:focus-visible,
.action:focus-visible,
.toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.glyph {
  display: grid;
  place-items: center;
  min-width: 26px;
  height: 26px;
  border-radius: 13px;
  background: var(--glyph);
  color: var(--glyph-ink);
  font-size: 13px;
  font-weight: 800;
}

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

@media (prefers-reduced-motion: reduce) {
  .hud,
  .pinArt,
  .ring,
  .gone .pinArt,
  .burst,
  .ping,
  .sensorFlash .sensorArcs path {
    animation: none;
  }
  .ring[data-far='false'],
  .gone {
    opacity: 0;
  }
  .needle,
  .edge {
    transition: none;
  }
}
```

### minimap-radar/use-autoplay.ts

```tsx
'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` and `reduced` are also returned, so
 * the component can pause its own clock offscreen and calm its effects.
 */
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 };
}
```