# Timezone globe

> A time zone picker beside a halftone globe: hover, search or arrow through the zones and the globe turns to light up each one’s slice of the Earth.

A dialog for choosing a time zone: a searchable list of 27 zones with their live local times, beside a globe drawn as a halftone of dots. Point at a zone, find a city or arrow down the list, and the globe turns on a spring until that zone’s band of longitude faces you, its main cities marked. Drag the globe to spin it, or click it to pick the zone under the pointer.

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

Category: Inputs.

## Anatomy

1. **Title**: The dialog’s heading, set by `title`. The dialog is a group labelled by it.
2. **Search field**: A combobox over the list: typing filters it by zone, city, country, abbreviation or offset, the arrows move through it, Enter chooses and Escape clears, then cancels.
3. **Chosen row**: A listbox option with the zone’s name, tinted and ticked when chosen. Pointing at a row previews it on the globe without choosing it.
4. **Local time**: The zone’s abbreviation and its time now, read from its IANA name in the browser and updated on the minute, so daylight saving time is always right.
5. **Globe**: A dotted globe on a canvas that springs round to the zone shown and lights its band and cities. Drag to spin it; click a zone on it to choose it.
6. **Cancel**: Goes back to the last zone confirmed and calls `onCancel`.
7. **Select**: Confirms the choice and calls `onSelect` with the zone. It names the zone’s abbreviation, and stays grey and inert until a zone is chosen.

## When to use

- Setting the time zone of an account, a calendar or a team member, where seeing the zone on the map settles the doubt between two similar names.
- People who know a city but not its zone’s name: the search finds `Tokyo`, `Brazil`, `+5:30` or `UTC-6`.
- Scheduling across places, where each row’s live local time answers “what time is it there?” before choosing.
- A deliberate, occasional choice that deserves a dialog with Cancel and an explicit `Select` button.

## When not to use

- A field in a long form where the zone can be guessed. Preselect the reader’s zone and offer a plain select to change it.
- Choosing among every IANA zone (over 400). This lists about 30 broad zones, each with its main cities; use a searchable combobox for the full list.
- A narrow sidebar or a popover: the dialog needs about 560 px for the globe beside the list (it stacks below that, at about 600 px tall).
- Showing several zones at once, as in a meeting planner. Use a timeline of the zones side by side.
- Changing a zone often, as a filter. A compact menu is faster than a dialog that has to be confirmed.

## Installation

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

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

// Opens on the account's zone; the reader confirms another one, or cancels.
export function TimezoneDialog({ onClose }: { onClose: (timeZone: string | null) => void }) {
  return (
    <TimezoneGlobe
      autoplay={false}
      defaultZone="central"
      onSelect={(zone) => onClose(zone.timeZone)}
      onCancel={() => onClose(null)}
    />
  );
}
```

## States

- **Zone chosen**: The globe faces the chosen zone, its row is ticked, and Select names it.
- **Nothing chosen**: With `defaultZone={null}` the globe rests over the Atlantic and Select is grey until a row is picked.
- **Previewing**: A row under the pointer turns the globe to it and tints it (`defaultActive`); the choice is unchanged, so Select still names it.
- **Searching**: The list keeps the matches, names the city that matched, and rings the best one for Enter (`defaultQuery`).
- **No match**: A search that finds nothing says so in the list; screen readers hear it once typing stops.
- **Dark**: Every colour is a `light-dark()` pair: the dialog follows the page’s scheme, or `colorScheme`.

## How it works

- Every time is live. Each zone’s clock is read with Intl.DateTimeFormat from its IANA name, once a minute, with the abbreviation the browser knows (MDT) or the zone’s own where it only gives an offset. The reader’s zone, from resolvedOptions().timeZone, is matched to the nearest listed zone by offset and tagged.
- The land is a halftone. 12,000 points are spread evenly over the sphere on a Fibonacci spiral and kept where they fall inside coastlines written by hand in longitude and latitude: about 3,500 dots, worked out once, the same on the server and in the browser.
- The globe is a 2D canvas in orthographic projection. A zone’s band is the lune between two meridians 15° apart, centred on its offset × 15° and clipped to the near side. A spring that keeps its speed when the target changes turns the band into view; a drag spins the globe with inertia, and a click picks the zone under the pointer by longitude.
- The server draws the same view as an SVG still, its dots drawn as zero-length strokes with round caps, so the first frame is complete; the canvas takes over once it has drawn. It draws only while something moves, caps its pixel ratio at 2 and gives its pixels back off screen.
- In Safari a scroll container gets a GPU layer, and in a scaled-down thumbnail full-size layers form around it. So the list is an overflow: hidden box that the wheel, a finger (with a flick that slows down), the keyboard and the demo scroll by script, which keeps an iPhone under 75 MB where a native scrolling list took a thumbnail to 234 MB.
- Every colour is a light-dark() token on the root, so the dialog follows the page’s color-scheme, or the colorScheme prop: white with a pale globe of dark dots by day, dark with a dark globe of light dots at night, on the same light stage. The canvas reads the globe’s colours from the computed style through a hidden probe, and an unseen 1 ms colour transition tells it to read them again and redraw when the scheme changes; the server’s SVG takes the same tokens by class.

## API reference

- **zones** (`readonly Zone[]`, default `ZONES`): The time zones to list, west to east: an id, a name, an IANA time zone, a standard offset in hours, abbreviations, two or three cities with coordinates, and more places the search knows.
- **defaultZone** (`string | null`, default `'mountain'`): The id of the zone chosen at first, or null for none.
- **defaultQuery** (`string`): The search’s text at first, as if typed: the list is filtered and its best match pointed at. Setting it turns the demo off.
- **defaultActive** (`string`): The id of a zone previewed at first, as if under the pointer; the chosen zone stays chosen. Setting it turns the demo off.
- **title** (`string`, default `'Select your timezone'`): The dialog’s heading.
- **locale** (`string`, default `'en-US'`): The language the times are written in, as for Intl.DateTimeFormat.
- **onSelect** (`(zone: Zone) => void`): Called with the zone when the reader confirms it with the primary button.
- **onCancel** (`() => void`): Called when the reader cancels (Cancel, or Escape with an empty search); the choice goes back to the last confirmed zone.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer browses the list, searches for a city and clicks the globe.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a light stage of its own.

## Accessibility

- The search is a combobox over a listbox, linked by aria-activedescendant: the arrow keys and Page Up and Down move through the zones, Enter chooses, and Escape clears the search, then cancels. Each zone reads plainly: “Mountain Time, 5:01 PM, MDT, your time zone”.
- Choices are announced politely (“Mountain Time selected, 5:01 PM.”, “Time zone set to Mountain Time.”); the demo’s own actions stay silent.
- The globe is decorative: the list carries everything, and dragging or clicking the globe is only a pointer shortcut. It turns east and west only, so a vertical swipe over it still scrolls the page.
- Under reduced motion the globe jumps to each zone, the list scrolls at once and the demo never plays.

## Source

### timezone-globe/timezone-globe.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { homeZone, offsetLabel, readClock, type Reading } from './clock';
import { GhostPointer, runDemo } from './ghost-pointer';
import { createGlobe, type Globe } from './globe';
import { GlobeStill } from './globe-still';
import { createListScroll } from './list-scroll';
import { bestOf, search } from './search';
import type { View } from './sphere';
import { useAutoplay } from './use-autoplay';
import { ZONES, viewOf, zoneAt, type Zone } from './zones';
import styles from './timezone-globe.module.css';

/*
 * Timezone globe: a dialog to choose a time zone, with a list on one side and a globe on the
 * other.
 *
 * - The list shows each zone's name, abbreviation and local time, read live from its IANA name
 *   and updated on the minute; the reader's own zone carries a tag. The search filters it by zone,
 *   city, country or offset (a combobox over a listbox: the arrow keys move through the list,
 *   Enter chooses, Escape clears the search, then cancels).
 * - The globe (globe.ts) turns on a spring to the zone chosen, or to the one under the pointer or
 *   the keyboard as a preview: its band, the lune between two meridians, lights up with its
 *   cities. It can be dragged round, and clicking it picks the zone there.
 * - On the server the globe is an SVG still of the same view (globe-still.tsx), so the first
 *   frame is complete; the canvas replaces it once it has drawn.
 * - The list scrolls by hand (list-scroll.ts), and nothing animates with CSS transforms or
 *   opacity: in Safari a scroll container or an animated element costs a GPU layer, and in a
 *   scaled-down copy of the page (a gallery's thumbnail) full-size layers round it too.
 * - While on screen and untouched, a ghost pointer browses the list, searches for a city and
 *   clicks on the globe. Under reduced motion the globe jumps to each zone and there is no demo.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the dialog follows the
 *   page's color-scheme, or the one the colorScheme prop sets: white with a pale globe of dark
 *   dots, or dark with a dark globe of light dots. The canvas reads the globe's colours from the
 *   computed style, and draws again when the scheme changes.
 */

/** The view when no zone is chosen: the Atlantic, between the Americas and Europe. */
const OPEN_SEA: View = { lon: -30, lat: 20 };

/** The demo's tour: browse a few rows and choose the last, search for a city and choose its
 * zone, click a city on the globe, and so on round. */
type Scene =
  | { type: 'browse'; ids: string[] }
  | { type: 'search'; text: string }
  | { type: 'globe'; lat: number; lon: number };

const TOUR: Scene[] = [
  { type: 'browse', ids: ['central', 'eastern'] },
  { type: 'search', text: 'Tok' },
  { type: 'globe', lat: -33.87, lon: 151.21 },
  { type: 'browse', ids: ['gmt', 'cet', 'eet', 'cet'] },
  { type: 'search', text: 'Mum' },
  { type: 'globe', lat: 13.76, lon: 100.5 },
  { type: 'browse', ids: ['pacific', 'mountain'] },
];

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function TimezoneGlobe({
  zones = ZONES,
  defaultZone = 'mountain',
  defaultQuery,
  defaultActive,
  title = 'Select your timezone',
  locale = 'en-US',
  onSelect,
  onCancel,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The time zones to list, west to east. */
  zones?: readonly Zone[];
  /** The id of the zone chosen at first, or null for none. */
  defaultZone?: string | null;
  /** The search's text at first, as if typed: the list is filtered and its best match is
   * pointed at, as by the keyboard. Turns the demo off. */
  defaultQuery?: string;
  /** The id of a zone previewed at first, as if under the pointer: the globe turns to it while
   * the chosen zone stays chosen. Turns the demo off. */
  defaultActive?: string;
  /** The dialog's heading. */
  title?: string;
  /** The language the times are written in, as in Intl.DateTimeFormat. */
  locale?: string;
  /** Called with the zone when the reader confirms it with the primary button. */
  onSelect?: (zone: Zone) => void;
  /** Called when the reader cancels: the choice goes back to the last confirmed zone. */
  onCancel?: () => void;
  /** While on screen and untouched, a ghost pointer browses the list, searches and picks zones. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  /** A dialog opened in a given state stays in it: no demo. */
  const demo = autoplay && defaultQuery === undefined && defaultActive === undefined;
  const zoneOf = (id: string | null) => zones.find((zone) => zone.id === id) ?? null;
  /** The zone chosen at first and its view, as the server drew them. */
  const [first] = useState(() => {
    const zone = zones.find((one) => one.id === defaultZone) ?? null;
    return { zone, view: zone ? viewOf(zone) : OPEN_SEA };
  });
  const [chosen, setChosen] = useState<string | null>(first.zone?.id ?? null);
  /** The last zone confirmed: Cancel goes back to it. */
  const [committed, setCommitted] = useState<string | null>(first.zone?.id ?? null);
  /** The row the search points at first, when it opens with a query. */
  const [firstPick] = useState(() =>
    defaultQuery
      ? (bestOf(search(zones, defaultQuery, null), defaultQuery)?.zone.id ?? null)
      : null,
  );
  /** The row under the pointer or the keyboard: the globe shows it as a preview. */
  const [active, setActive] = useState<string | null>(
    () => firstPick ?? zones.find((zone) => zone.id === defaultActive)?.id ?? null,
  );
  /** The active row came from the keyboard (or the search): it keeps it when the pointer leaves. */
  const [keyed, setKeyed] = useState(firstPick !== null);
  const [query, setQuery] = useState(defaultQuery ?? '');
  /** The demo is typing in the search field. */
  const [demoTyping, setDemoTyping] = useState(false);
  /** The current minute, once in the browser (the server cannot know the reader's clock). */
  const [now, setNow] = useState<number | null>(null);
  const [home, setHome] = useState<string | null>(null);
  /** The canvas has drawn: the still steps aside. */
  const [drawn, setDrawn] = useState(false);
  const [message, setMessage] = useState('');
  const titleId = useId();
  const listId = useId();
  const optionId = (id: string) => `${listId}-${id}`;

  const input = useRef<HTMLInputElement>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const scrollbar = useRef<HTMLSpanElement>(null);
  const rows = useRef(new Map<string, HTMLDivElement>());
  const canvas = useRef<HTMLCanvasElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  const globe = useRef<Globe | null>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  /** A row to scroll into view once it is on the page. */
  const revealNext = useRef<string | null>(active);
  /** What the demo typed: cleared when the reader takes over. */
  const demoQuery = useRef('');
  /** "No time zone matches": said once the reader stops typing, not at every letter. */
  const noMatchTimer = useRef(0);
  /** Where the pointer last moved over the list: a row that comes under a still pointer as the
   * list scrolls does not take the keyboard's place. */
  const pointerAt = useRef({ x: -1, y: -1 });

  const readings = useMemo(
    () =>
      now === null
        ? null
        : new Map<string, Reading>(zones.map((zone) => [zone.id, readClock(zone, now, locale)])),
    [zones, now, locale],
  );
  const matches = useMemo(() => search(zones, query, readings), [zones, query, readings]);
  const chosenZone = zoneOf(chosen);
  const activeZone = zoneOf(active);
  const shownZone = activeZone ?? chosenZone;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) =>
    setMessage((current) => (current === text ? `${text}\u00a0` : text));

  /** "MDT · 5:01 PM"; before the clock is read, the standard abbreviation and offset. */
  const metaOf = (zone: Zone) => {
    const reading = readings?.get(zone.id);
    return reading
      ? `${reading.abbr} · ${reading.time}`
      : `${zone.abbr} · ${offsetLabel(zone.offset * 60)}`;
  };
  const abbrOf = (zone: Zone) => readings?.get(zone.id)?.abbr ?? zone.abbr;

  /** The row under the pointer (or none): the globe previews it. */
  const hover = (id: string | null) => {
    setActive(id);
    setKeyed(false);
  };

  /** Scrolls the list just enough to show a row; whether it had to. */
  const reveal = (id: string, smooth: boolean) => {
    const list = scroller.current;
    const row = rows.current.get(id);
    if (!list || !row) return false;
    const top = row.offsetTop - 6;
    const bottom = row.offsetTop + row.offsetHeight + 6;
    let next = list.scrollTop;
    if (top < list.scrollTop) next = top;
    else if (bottom > list.scrollTop + list.clientHeight) next = bottom - list.clientHeight;
    if (Math.abs(next - list.scrollTop) < 1) return false;
    list.scrollTo({ top: next, behavior: smooth && !reducedMotion() ? 'smooth' : 'auto' });
    return true;
  };

  const choose = (zone: Zone, byReader: boolean) => {
    setChosen(zone.id);
    // Even if it was chosen already: the globe may have been dragged away from it.
    globe.current?.show(zone);
    if (byReader) {
      const { time } = readClock(zone, Date.now(), locale);
      announce(`${zone.name} selected, ${time}.`);
    }
  };

  const changeQuery = (text: string, byReader: boolean) => {
    setQuery(text);
    const found = search(zones, text, readings);
    const best = bestOf(found, text)?.zone ?? null;
    setActive(best?.id ?? null);
    setKeyed(!!best);
    scroller.current?.scrollTo({ top: 0 });
    if (best) revealNext.current = best.id;
    window.clearTimeout(noMatchTimer.current);
    if (byReader && text.trim() && !found.length) {
      noMatchTimer.current = window.setTimeout(
        () => announce(`No time zone matches “${text.trim()}”.`),
        700,
      );
    }
  };

  const cancel = () => {
    const back = zoneOf(committed);
    setChosen(committed);
    setQuery('');
    setActive(null);
    setKeyed(false);
    if (back) revealNext.current = back.id;
    onCancel?.();
    announce(back ? `Cancelled. Back to ${back.name}.` : 'Cancelled.');
  };

  const confirm = () => {
    if (!chosenZone) return;
    setCommitted(chosenZone.id);
    onSelect?.(chosenZone);
    announce(`Time zone set to ${chosenZone.name}.`);
  };

  /** A zone picked on the globe: chosen, and shown in the list. */
  const pick = (zone: Zone, byReader: boolean) => {
    choose(zone, byReader);
    hover(null);
    if (!matches.some((match) => match.zone.id === zone.id)) setQuery('');
    revealNext.current = zone.id;
  };

  // The handlers as they are in the latest render, for the globe's callbacks and the demo.
  const actions = useRef({ hover, choose, pick, changeQuery, reveal, zoneOf, zones });
  useLayoutEffect(() => {
    actions.current = { hover, choose, pick, changeQuery, reveal, zoneOf, zones };
  });

  // The globe: made once, on the view the server drew.
  useEffect(() => {
    const surface = canvas.current;
    if (!surface) return;
    const made = createGlobe(surface, {
      view: first.view,
      zone: first.zone,
      zoneAt: (lat, lon) => zoneAt(actions.current.zones, lat, lon),
      onPick: (zone) => actions.current.pick(zone, true),
      onReady: () => setDrawn(true),
    });
    globe.current = made;
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => made.setReducedMotion(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      motion.removeEventListener('change', readMotion);
      made.destroy();
      globe.current = null;
    };
  }, [first]);
  useEffect(() => () => window.clearTimeout(noMatchTimer.current), []);

  // The list scrolls by hand (list-scroll.ts): it is not a scroll container, to save memory.
  useEffect(() => {
    const list = scroller.current;
    const bar = scrollbar.current;
    if (!list || !bar) return;
    return createListScroll(list, bar);
  }, []);
  // A list that fits needs no dragging: a swipe on it scrolls the page.
  useLayoutEffect(() => {
    const list = scroller.current;
    if (list) list.toggleAttribute('data-scrolls', list.scrollHeight > list.clientHeight + 1);
  }, [matches]);

  // Near the screen with the tab visible, the globe draws and the clocks tick, on the minute.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    setHome(homeZone(zones, Date.now())?.id ?? null);
    let onScreen = false;
    let timer = 0;
    const tick = () => {
      setNow(Date.now());
      timer = window.setTimeout(tick, 60000 - (Date.now() % 60000) + 20);
    };
    const update = () => {
      const near = onScreen && !document.hidden;
      globe.current?.setOnScreen(near);
      window.clearTimeout(timer);
      if (near) tick();
    };
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { rootMargin: '120px 0px' },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      window.clearTimeout(timer);
    };
  }, [root, zones]);

  // The globe follows the active row, else the chosen zone. Both are listed, so that pointing at
  // the chosen row after a drag turns the globe back to it.
  useEffect(() => {
    globe.current?.show(shownZone);
    // `shownZone` follows from these two.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active, chosen]);

  // A row picked on the globe or brought back by Cancel scrolls into view once it is listed.
  useLayoutEffect(() => {
    const id = revealNext.current;
    if (!id || !rows.current.has(id)) return;
    revealNext.current = null;
    reveal(id, true);
  });

  // The demo: browse a few rows and choose one, search for a city and choose its zone, click a
  // city on the globe; again and again, while on screen and untouched.
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !playing || !stage || !ghost) return;
    const act = () => actions.current;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const type = async (text: string) => {
        for (let length = 1; length <= text.length; length++) {
          demoQuery.current = text.slice(0, length);
          act().changeQuery(demoQuery.current, false);
          await wait(140 + (length % 2) * 60);
        }
      };
      const clear = async () => {
        if (!demoQuery.current) return;
        await moveTo(stage.querySelector(`.${styles.clear}`));
        await press(() => act().changeQuery('', false));
        demoQuery.current = '';
        setDemoTyping(false);
        await wait(500);
      };
      await wait(1000);
      for (let round = 0; ; round++) {
        const scene = TOUR[round % TOUR.length];
        if (scene.type !== 'search') await clear();
        if (scene.type === 'browse') {
          for (const [index, id] of scene.ids.entries()) {
            // The list scrolls first, if the row is out of sight.
            if (act().reveal(id, true)) await wait(450);
            await moveTo(rows.current.get(id), { x: 0.3, y: 0.5 });
            act().hover(id);
            await wait(index === scene.ids.length - 1 ? 520 : 640);
          }
          const last = act().zoneOf(scene.ids[scene.ids.length - 1]);
          if (last) await press(() => act().choose(last, false));
          await wait(1500);
          act().hover(null);
        } else if (scene.type === 'search') {
          await moveTo(input.current, { x: 0.35, y: 0.5 });
          await press(() => setDemoTyping(true));
          await type(scene.text);
          await wait(900);
          const match = bestOf(search(zones, scene.text, null), scene.text)?.zone;
          if (match) {
            await moveTo(rows.current.get(match.id), { x: 0.3, y: 0.5 });
            await press(() => act().choose(match, false));
          }
          await wait(1700);
        } else {
          const surface = canvas.current;
          const spot = globe.current?.locate(scene.lat, scene.lon);
          if (!surface || !spot) continue;
          await moveTo(surface, {
            x: spot.x / surface.offsetWidth,
            y: spot.y / surface.offsetHeight,
          });
          await press(() => {
            const zone = globe.current?.zoneAtPoint(spot.x, spot.y);
            if (zone) act().pick(zone, false);
          });
          await wait(2000);
        }
      }
    });
    stopDemo.current = () => {
      stop();
      act().hover(null);
      setDemoTyping(false);
      // What the demo typed goes when the reader takes over.
      if (demoQuery.current) act().changeQuery('', false);
      demoQuery.current = '';
    };
    return () => stopDemo.current?.();
  }, [demo, playing, root, zones]);

  /** The reader takes over: the demo stops at once, and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    stopDemo.current = null;
    take();
  };

  const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    const steps: Record<string, number> = { ArrowDown: 1, ArrowUp: -1, PageDown: 5, PageUp: -5 };
    if (event.key in steps) {
      event.preventDefault();
      if (!matches.length) return;
      const step = steps[event.key];
      const at = matches.findIndex((match) => match.zone.id === active);
      // The first press lands on the chosen row if it is listed, else on the first (or last) row.
      const from = matches.findIndex((match) => match.zone.id === chosen);
      const index =
        at >= 0
          ? Math.max(0, Math.min(matches.length - 1, at + step))
          : from >= 0
            ? from
            : step > 0
              ? 0
              : matches.length - 1;
      const id = matches[index].zone.id;
      setActive(id);
      setKeyed(true);
      reveal(id, false);
    } else if (event.key === 'Enter' && activeZone && keyed) {
      event.preventDefault();
      choose(activeZone, true);
    }
  };

  const hoverable = (event: PointerEvent) => event.pointerType !== 'touch';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onPointerMove={(event) => {
        if (event.pointerType === 'mouse') readerActs();
      }}
    >
      <div
        className={styles.dialog}
        role="group"
        aria-labelledby={titleId}
        onKeyDown={(event) => {
          if (event.key !== 'Escape') return;
          event.preventDefault();
          if (query) changeQuery('', true);
          else cancel();
        }}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget)) hover(null);
        }}
      >
        <p id={titleId} className={styles.title} data-part="title">
          {title}
        </p>

        <div className={styles.search} data-part="search" data-demo={demoTyping || undefined}>
          <svg className={styles.searchIcon} viewBox="0 0 20 20" aria-hidden="true">
            <circle cx="8.75" cy="8.75" r="5.25" />
            <path d="m12.75 12.75 3.5 3.5" />
          </svg>
          <input
            ref={input}
            className={styles.input}
            type="text"
            role="combobox"
            aria-label="Search for a city or time zone"
            aria-expanded="true"
            aria-controls={listId}
            aria-autocomplete="list"
            aria-activedescendant={activeZone && keyed ? optionId(activeZone.id) : undefined}
            placeholder="Any city or time zone"
            autoComplete="off"
            autoCorrect="off"
            spellCheck={false}
            enterKeyHint="done"
            value={query}
            onChange={(event) => changeQuery(event.target.value, true)}
            onKeyDown={onSearchKeyDown}
          />
          {query && (
            <button
              type="button"
              className={styles.clear}
              data-part="clear"
              tabIndex={-1}
              aria-label="Clear the search"
              onClick={() => {
                changeQuery('', true);
                input.current?.focus();
              }}
            >
              <svg viewBox="0 0 20 20" aria-hidden="true">
                <path d="m7 7 6 6m0-6-6 6" />
              </svg>
            </button>
          )}
        </div>

        <div className={styles.listArea}>
          <div
            ref={scroller}
            className={styles.list}
            onPointerMove={(event) => {
              pointerAt.current = { x: event.clientX, y: event.clientY };
            }}
            onPointerLeave={(event) => {
              if (hoverable(event) && !keyed) hover(null);
            }}
            onMouseDown={(event) => {
              // A click on a row keeps the focus where it was (in the search field, often).
              event.preventDefault();
            }}
          >
            <div id={listId} role="listbox" aria-label="Time zones">
              {matches.map(({ zone, place }) => {
                const selected = zone.id === chosen;
                const reading = readings?.get(zone.id);
                const label = [
                  zone.name,
                  place,
                  reading ? `${reading.time}, ${reading.abbr}` : zone.abbr,
                  zone.id === home && 'your time zone',
                ]
                  .filter(Boolean)
                  .join(', ');
                return (
                  <div
                    key={zone.id}
                    ref={(element) => {
                      if (element) rows.current.set(zone.id, element);
                      else rows.current.delete(zone.id);
                    }}
                    id={optionId(zone.id)}
                    role="option"
                    aria-selected={selected}
                    aria-label={label}
                    className={styles.option}
                    data-part="option"
                    data-active={zone.id === active || undefined}
                    data-keyed={(zone.id === active && keyed) || undefined}
                    onPointerEnter={(event) => {
                      const still =
                        event.clientX === pointerAt.current.x &&
                        event.clientY === pointerAt.current.y;
                      if (hoverable(event) && !(keyed && still)) hover(zone.id);
                    }}
                    onClick={() => {
                      choose(zone, true);
                      // The globe shows the choice, whatever was active before (the search's pick).
                      hover(null);
                    }}
                  >
                    <span className={styles.name}>
                      {zone.name}
                      {place && <span className={styles.place}> · {place}</span>}
                    </span>
                    <span className={styles.meta}>
                      <span className={styles.time} data-part="time">
                        {metaOf(zone)}
                      </span>
                      {zone.id === home && (
                        <span className={styles.tag} data-part="tag">
                          Your time zone
                        </span>
                      )}
                    </span>
                    <svg
                      className={styles.check}
                      data-part="check"
                      viewBox="0 0 20 20"
                      aria-hidden="true"
                    >
                      <path d="m5 10.5 3.25 3.25L15 7" />
                    </svg>
                  </div>
                );
              })}
            </div>
            {!matches.length && (
              <p className={styles.empty} data-part="empty">
                No time zone matches “{query.trim()}”.
              </p>
            )}
          </div>
          <span ref={scrollbar} className={styles.scrollbar} aria-hidden="true" />
        </div>

        <div className={styles.footer} data-part="footer">
          <button type="button" className={styles.cancel} data-part="cancel" onClick={cancel}>
            Cancel
          </button>
          <button
            type="button"
            className={styles.confirm}
            data-part="confirm"
            aria-disabled={!chosenZone || undefined}
            data-ready={chosenZone ? '' : undefined}
            onClick={confirm}
          >
            {chosenZone ? `Select ${abbrOf(chosenZone)}` : 'Select'}
            {chosenZone && <span className={styles.hidden}>, {chosenZone.name}</span>}
          </button>
        </div>

        <div className={styles.pane} data-part="globe" aria-hidden="true">
          <canvas ref={canvas} className={styles.canvas} />
          {!drawn && <GlobeStill view={first.view} zone={first.zone} />}
          <p className={styles.hint} data-part="hint">
            Drag to spin<span className={styles.pointerHint}> · Click a zone to pick it</span>
            <span className={styles.touchHint}> · Tap a zone to pick it</span>
          </p>
        </div>
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### timezone-globe/timezone-globe.module.css

```css
/* Timezone globe (timezone-globe.tsx): a dialog on a light stage, the list on the left and the
   globe on the right; on a narrow stage, the globe sits above the list.

   Colours are the tokens below, light-dark(light, dark): the dialog takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). By day the dialog is white with a pale globe of dark dots; at night it
   is dark with a dark globe of light dots. The stage is the same light grey on both. The globe's
   tokens are read by the canvas too (globe.ts), and by the still before it (globe-still.tsx). */

.root {
  /* The dialog's words: names, then times and places, then the placeholder, the hint and the idle
     button. */
  --ink: #1c1c1e;
  --muted: #5f5f66;
  --faint: #6a6a71;
  /* The dialog, its hairlines, and its shadow on the stage (the sheen is a lit top edge, at night). */
  --card: #fff;
  --line: rgb(0 0 0 / 0.08);
  --card-edge: rgb(0 0 0 / 0.08);
  --card-sheen: rgb(255 255 255 / 0);
  --shadow-near: rgb(0 0 0 / 0.06);
  --shadow: rgb(0 0 0 / 0.28);
  /* Fills over the dialog: the search field and Cancel, and Cancel under the pointer; the tag and
     the idle Select button; a row under the pointer, and the chosen row. */
  --wash: rgb(0 0 0 / 0.045);
  --wash-strong: rgb(0 0 0 / 0.08);
  --chip: rgb(0 0 0 / 0.055);
  --row-active: rgb(0 0 0 / 0.035);
  --row-selected: rgb(0 0 0 / 0.06);
  /* The tag's words, and Cancel's. */
  --chip-ink: #55555c;
  --cancel-ink: #3a3a40;
  /* The search field's edge, and its fill while it has the focus. */
  --field-edge: rgb(0 0 0 / 0.07);
  --field-focus: #fff;
  /* The list's scroll bar, shown and hidden: its colour fades, not its opacity. */
  --scrollbar: rgb(0 0 0 / 0.28);
  --scrollbar-off: rgb(0 0 0 / 0);
  /* Select: amber, with dark words, on both; a little deeper under the pointer by day. */
  --accent: #f7c948;
  --accent-hover: #f2bd2c;
  --accent-ink: #261d03;
  /* The amber of the marks: the tick, the caret, the focus rings and the keyboard's row. By day it
     is darker, to stand out on white. */
  --accent-strong: #a86b00;
  --focus-ring: rgb(168 107 0 / 0.9);
  --focus-glow: rgb(247 201 72 / 0.3);
  --keyed: rgb(168 107 0 / 0.9);
  /* The globe's pane: lit in its middle, and its edge. */
  --pane-lit: #fdfdfd;
  --pane: #f6f6f8;
  --pane-shade: #efeff2;
  --pane-edge: rgb(0 0 0 / 0.05);
  /* The globe: the glow round it (a soft shade by day), its body lit from the top left, the
     graticule and the rim, the zone's band and its outline, and the land's dots, plain and in the
     band (their alpha is the dots' own, times the token's). */
  --globe-glow: rgb(0 0 0 / 0.05);
  --globe-lit: #fff;
  --globe-body: #f4f4f7;
  --globe-shade: #e2e2e8;
  --globe-grid: rgb(0 0 0 / 0.07);
  --globe-rim: rgb(0 0 0 / 0.1);
  --globe-band: rgb(247 201 72 / 0.14);
  --globe-band-line: rgb(150 96 0 / 0.55);
  --globe-land: rgb(52 52 60);
  --globe-land-lit: rgb(92 60 6);
  /* A city on the globe: its dot and the ring round it, its name, and the halo that keeps both
     clear of the land. */
  --city: #b97a00;
  --city-ring: rgb(214 150 0 / 0.24);
  --city-label: #1c1c1e;
  --halo: #fff;
  position: relative;
  container: timezone-globe / inline-size;
  display: grid;
  place-items: center;
  /* Percentages of the width: 40px and 32px round the dialog on a wide stage, 14px on a phone. */
  padding: clamp(14px, 4.8%, 40px) clamp(14px, 3.9%, 32px);
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(rgb(0 0 0 / 0.055) 1px, transparent 1px) 0 0 / 14px 14px,
    #e6e6e9;
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.06);
  font:
    400 14px/1.3 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --ink: light-dark(#1c1c1e, #f3f3f5);
    --muted: light-dark(#5f5f66, #8f8f98);
    --faint: light-dark(#6a6a71, #686871);
    --card: light-dark(#fff, #141417);
    --line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));
    --card-edge: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.12));
    --card-sheen: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.06));
    --shadow-near: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.08));
    --shadow: light-dark(rgb(0 0 0 / 0.28), rgb(0 0 0 / 0.5));
    --wash: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.055));
    --wash-strong: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.09));
    --chip: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.07));
    --row-active: light-dark(rgb(0 0 0 / 0.035), rgb(255 255 255 / 0.045));
    --row-selected: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.085));
    --chip-ink: light-dark(#55555c, #babac2);
    --cancel-ink: light-dark(#3a3a40, #dcdce2);
    --field-edge: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.06));
    --field-focus: light-dark(#fff, rgb(255 255 255 / 0.07));
    --scrollbar: light-dark(rgb(0 0 0 / 0.28), rgb(255 255 255 / 0.3));
    --scrollbar-off: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0));
    --accent-hover: light-dark(#f2bd2c, #ffd560);
    --accent-strong: light-dark(#a86b00, #f7c948);
    --focus-ring: light-dark(rgb(168 107 0 / 0.9), rgb(247 201 72 / 0.6));
    --focus-glow: light-dark(rgb(247 201 72 / 0.3), rgb(247 201 72 / 0.16));
    --keyed: light-dark(rgb(168 107 0 / 0.9), rgb(247 201 72 / 0.7));
    --pane-lit: light-dark(#fdfdfd, #1b1b20);
    --pane: light-dark(#f6f6f8, #111114);
    --pane-shade: light-dark(#efeff2, #0c0c0e);
    --pane-edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --globe-glow: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --globe-lit: light-dark(#fff, #2c2c32);
    --globe-body: light-dark(#f4f4f7, #19191d);
    --globe-shade: light-dark(#e2e2e8, #0e0e11);
    --globe-grid: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.075));
    --globe-rim: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.12));
    --globe-band: light-dark(rgb(247 201 72 / 0.14), rgb(255 255 255 / 0.075));
    --globe-band-line: light-dark(rgb(150 96 0 / 0.55), rgb(255 255 255 / 0.55));
    --globe-land: light-dark(rgb(52 52 60), rgb(236 236 240));
    --globe-land-lit: light-dark(rgb(92 60 6), rgb(255 246 222));
    --city: light-dark(#b97a00, #f7c948);
    --city-ring: light-dark(rgb(214 150 0 / 0.24), rgb(247 201 72 / 0.22));
    --city-label: light-dark(#1c1c1e, #f2f2f4);
    --halo: light-dark(#fff, #101013);
  }
}
/* 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 buttons start from the dialog's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

.dialog {
  position: relative;
  display: grid;
  grid-template-columns: 296px minmax(0, 1fr);
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  grid-template-areas:
    'title pane'
    'search pane'
    'list pane'
    'footer pane';
  width: min(100%, 768px);
  height: 452px;
  border-radius: 22px;
  background: var(--card);
  color: var(--ink);
  box-shadow:
    0 0 0 1px var(--card-edge),
    inset 0 1px 0 var(--card-sheen),
    0 2px 6px var(--shadow-near),
    0 30px 60px -28px var(--shadow);
}

.title {
  grid-area: title;
  margin: 0;
  padding: 22px 22px 14px;
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.012em;
}

/* The search field. */
.search {
  grid-area: search;
  position: relative;
  margin: 0 14px 8px;
}
.input {
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 40px;
  margin: 0;
  padding: 0 38px 0 38px;
  border: 0;
  border-radius: 12px;
  background: var(--wash);
  box-shadow: inset 0 0 0 1px var(--field-edge);
  color: var(--ink);
  font: inherit;
  outline: none;
  appearance: none;
  caret-color: var(--accent-strong);
  transition:
    box-shadow 0.15s ease,
    background-color 0.15s ease;
}
.input::placeholder {
  color: var(--faint);
}
/* Empty, the field has no clear button: the placeholder takes its room. */
.input:placeholder-shown {
  padding-right: 12px;
}
.input:focus-visible,
.search[data-demo] .input {
  background: var(--field-focus);
  box-shadow:
    inset 0 0 0 1px var(--focus-ring),
    0 0 0 3px var(--focus-glow);
}
.searchIcon {
  position: absolute;
  top: 50%;
  left: 12px;
  width: 17px;
  height: 17px;
  margin-top: -8.5px;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.7;
  stroke-linecap: round;
  pointer-events: none;
}
.clear {
  position: absolute;
  top: 50%;
  right: 6px;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin-top: -14px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}
.clear svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}
@media (hover: hover) {
  .clear:hover {
    background: var(--wash);
    color: var(--ink);
  }
}

/* The list: a box that scrolls by hand (list-scroll.ts), and the bar that shows where it is. */
.listArea {
  grid-area: list;
  position: relative;
  min-height: 0;
}
.list {
  position: relative;
  box-sizing: border-box;
  height: 100%;
  overflow: hidden;
  padding: 2px 8px 8px;
}
/* Vertical drags are the list's own, while it has more rows than room. */
.list[data-scrolls] {
  touch-action: pan-x pinch-zoom;
}
.scrollbar {
  position: absolute;
  top: 0;
  right: 2px;
  width: 3px;
  border-radius: 2px;
  background: var(--scrollbar-off);
  pointer-events: none;
  /* Its colour fades rather than its opacity, which would give it a layer of its own. */
  transition: background-color 0.3s ease;
}
.scrollbar[data-shown] {
  background: var(--scrollbar);
  transition-duration: 0.1s;
}
.option {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    'name check'
    'meta check';
  align-items: center;
  column-gap: 10px;
  row-gap: 3px;
  padding: 9px 12px;
  border-radius: 12px;
  cursor: pointer;
  user-select: none;
  transition:
    background-color 0.12s ease,
    box-shadow 0.12s ease;
}
.option[data-active] {
  background: var(--row-active);
}
.option[aria-selected='true'] {
  background: var(--row-selected);
}
/* The keyboard's (and the search's) row: a ring, as focus. */
.option[data-keyed] {
  box-shadow: inset 0 0 0 1.5px var(--keyed);
}
.name {
  grid-area: name;
  overflow: hidden;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.place {
  color: var(--muted);
  font-weight: 400;
}
.meta {
  grid-area: meta;
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  font-size: 12.5px;
  color: var(--muted);
}
.time {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.tag {
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-ink);
  font-size: 11px;
  font-weight: 500;
  white-space: nowrap;
}
.check {
  grid-area: check;
  width: 18px;
  height: 18px;
  fill: none;
  stroke: var(--accent-strong);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* The tick draws itself (a paint, not a layer: see ghost-pointer.tsx). */
  stroke-dasharray: 16;
  stroke-dashoffset: 16;
  transition: stroke-dashoffset 0.3s ease;
}
.option[aria-selected='true'] .check {
  stroke-dashoffset: 0;
}
.empty {
  margin: 0;
  padding: 16px 12px;
  color: var(--muted);
  font-size: 13px;
}

/* The buttons. */
.footer {
  grid-area: footer;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 12px 14px 14px;
  border-top: 1px solid var(--line);
}
.cancel,
.confirm {
  height: 38px;
  padding: 0 16px;
  border: 0;
  border-radius: 11px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.cancel {
  background: var(--wash);
  color: var(--cancel-ink);
}
.confirm {
  min-width: 112px;
  background: var(--chip);
  color: var(--faint);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.confirm[data-ready] {
  background: var(--accent);
  color: var(--accent-ink);
}
.confirm[aria-disabled] {
  cursor: default;
}
@media (hover: hover) {
  .cancel:hover {
    background: var(--wash-strong);
  }
  .confirm[data-ready]:hover {
    background: var(--accent-hover);
  }
}
.cancel:active,
.confirm[data-ready]:active {
  transform: scale(0.97);
}
.cancel:focus-visible,
.confirm:focus-visible,
.clear:focus-visible {
  outline: 2px solid var(--accent-strong);
  outline-offset: 2px;
}

/* The globe's pane: the canvas, and the still before it. */
.pane {
  grid-area: pane;
  position: relative;
  margin: 8px 8px 8px 0;
  overflow: hidden;
  container-type: size;
  border-radius: 16px;
  background: radial-gradient(
    120% 90% at 50% 45%,
    var(--pane-lit) 0%,
    var(--pane) 55%,
    var(--pane-shade) 100%
  );
  box-shadow: inset 0 0 0 1px var(--pane-edge);
}
.canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  cursor: grab;
  touch-action: pan-y;
  /* A change of colour scheme runs this unseen transition, which tells the globe to read its
     colours again and draw. Nothing moves, so it stays under reduced motion, even where a page
     turns every transition off (hence !important). */
  color: var(--globe-land);
  transition: color 1ms !important;
}
.canvas[data-dragging] {
  cursor: grabbing;
}
.still {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.stillDrawing {
  position: absolute;
  top: 50%;
  left: 50%;
  overflow: visible;
  transform: translate(-50%, -50%);
}
/* The still's colours: the canvas's tokens. */
.stillGlow {
  stop-color: var(--globe-glow);
}
.stillGlowEnd {
  stop-color: var(--globe-glow);
  stop-opacity: 0;
}
.stillLit {
  stop-color: var(--globe-lit);
}
.stillBody {
  stop-color: var(--globe-body);
}
.stillShade {
  stop-color: var(--globe-shade);
}
.stillGrid {
  stroke: var(--globe-grid);
}
.stillBand {
  fill: var(--globe-band);
  stroke: var(--globe-band-line);
}
.stillLand {
  stroke: var(--globe-land);
}
.stillLandLit {
  stroke: var(--globe-land-lit);
}
.stillRim {
  stroke: var(--globe-rim);
}
/* A city: a point at its place, with the dot on it and the label beside it. */
.stillCity {
  position: absolute;
  width: 0;
  height: 0;
}
.stillCity::before {
  content: '';
  position: absolute;
  top: -3.2px;
  left: -3.2px;
  width: 6.4px;
  height: 6.4px;
  border-radius: 50%;
  background: var(--city);
  box-shadow:
    0 0 0 0.75px var(--halo),
    0 0 0 3.3px var(--city-ring);
}
.stillCity span {
  position: absolute;
  top: 0;
  left: 0;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  color: var(--city-label);
  text-shadow:
    0 0 1.5px var(--halo),
    0 0 1.5px var(--halo),
    0 0 1.5px var(--halo);
  transform: translate(9px, -50%);
}
.stillCity[data-label='left'] span {
  transform: translate(calc(-100% - 9px), -50%);
}
.stillCity[data-label='above'] span {
  transform: translate(-50%, calc(-100% - 8px));
}
.stillCity[data-label='below'] span {
  transform: translate(-50%, 8px);
}
.hint {
  position: absolute;
  right: 0;
  bottom: 12px;
  left: 0;
  margin: 0;
  color: var(--faint);
  font-size: 11.5px;
  text-align: center;
  pointer-events: none;
}
.touchHint {
  display: none;
}
@media (hover: none) {
  .pointerHint {
    display: none;
  }
  .touchHint {
    display: inline;
  }
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* A narrow stage: the globe on top, the list under it. (The globe's pane is a container too:
   the query names the stage's, whose width is measured inside its padding.) */
@container timezone-globe (max-width: 559px) {
  .dialog {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto;
    grid-template-areas:
      'pane'
      'title'
      'search'
      'list'
      'footer';
    height: auto;
    border-radius: 20px;
  }
  .pane {
    height: 204px;
    margin: 8px 8px 0;
  }
  .hint {
    display: none;
  }
  .title {
    padding: 16px 18px 12px;
  }
  .list {
    height: 226px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .input,
  .option,
  .check,
  .scrollbar,
  .cancel,
  .confirm {
    transition: none;
  }
}
```

### timezone-globe/globe.ts

```tsx
import { landDots } from './land';
import {
  bandRing,
  clipRing,
  frameOf,
  graticule,
  project,
  rimArc,
  unproject,
  wrap,
  type Frame,
  type Outline,
  type View,
} from './sphere';
import { viewOf, type Zone } from './zones';

/*
 * The globe, drawn on a 2D canvas: a shaded sphere, a faint graticule, the land as a halftone of
 * dots, and the time zone shown as a band with its cities.
 *
 * - Choosing a zone turns the globe on a spring until its band faces the reader. The spring keeps
 *   its speed when the target changes mid-turn, so hovering down the list makes one smooth move.
 * - The reader can drag the globe round (it keeps spinning a moment when let go) and click it: the
 *   zone under the pointer is picked. A drag only turns it east or west, so a vertical swipe still
 *   scrolls the page.
 * - A zone's band and cities fade in as the next one comes, and out as it goes.
 * - It draws only while something moves, and only near the screen; away from it the canvas gives
 *   its pixels back. The pixel ratio is capped at 2. Under reduced motion it jumps to each zone.
 * - Its colours are the CSS tokens (light-dark() pairs), read from the computed style: once, and
 *   again when the colour scheme changes, which runs the canvas's unseen colour transition.
 */

/** The globe's radius, as a share of the smaller side of its box. */
export const GLOBE_SIZE = 0.42;
/** A land dot's radius facing the reader, as a share of the globe's radius. */
export const DOT_SIZE = 0.0085;

/** The colours, and the CSS tokens they come from (the still drawn on the server uses the same). */
const TOKENS = {
  glow: '--globe-glow',
  lit: '--globe-lit',
  body: '--globe-body',
  shade: '--globe-shade',
  grid: '--globe-grid',
  rim: '--globe-rim',
  band: '--globe-band',
  bandLine: '--globe-band-line',
  land: '--globe-land',
  landLit: '--globe-land-lit',
  city: '--city',
  cityRing: '--city-ring',
  label: '--city-label',
  halo: '--halo',
};
type Palette = Record<keyof typeof TOKENS, string>;

/**
 * The colours as the CSS resolves them here: each token is read through the probe's colour, so
 * light-dark() follows the canvas's color-scheme. They come as rgb() or rgba().
 */
function readPalette(probe: HTMLElement) {
  const style = getComputedStyle(probe);
  const palette = {} as Palette;
  for (const [key, token] of Object.entries(TOKENS)) {
    probe.style.color = `var(${token})`;
    palette[key as keyof Palette] = style.color;
  }
  return palette;
}

/** A colour with its alpha multiplied by `alpha`; a colour that is not rgb() or rgba() is kept. */
function fade(colour: string, alpha: number) {
  const values = colour.startsWith('rgb') ? colour.match(/[\d.]+/g) : null;
  if (!values || values.length < 3) return colour;
  const [red, green, blue, opacity = '1'] = values;
  return `rgba(${red}, ${green}, ${blue}, ${Number(opacity) * alpha})`;
}

/** How bright a land dot is, and how big, from how squarely it faces the reader (0 to 1); in the
 * band, `lit` is 1. */
export const dotAlpha = (facing: number, lit: number) =>
  (0.24 + 0.58 * facing) * (1 - lit) + (0.6 + 0.4 * facing) * lit;
export const dotScale = (facing: number) => 0.45 + 0.55 * facing;

/** The spring's period (s) and damping ratio: a turn with the slightest overshoot. */
const RESPONSE = 0.9;
const DAMPING = 0.86;
/** A drag's spin dies away with this time constant (s). */
const FRICTION = 0.42;
/** How long a band and its cities take to fade in or out (s). */
const FADE = 0.24;
/** A press that moves less than this (px) and lasts less than this (ms) is a click. */
const CLICK_SLOP = 6;
const CLICK_TIME = 500;

const STIFFNESS = ((2 * Math.PI) / RESPONSE) ** 2;
const DAMPER = 2 * DAMPING * Math.sqrt(STIFFNESS);

export type GlobeOptions = {
  /** The view of the first frame, the same as the still's. */
  view: View;
  /** The zone shown at first, if any. */
  zone: Zone | null;
  /** The zone a place belongs to, for picking with the pointer. */
  zoneAt: (lat: number, lon: number) => Zone | null;
  /** The reader clicked or tapped a zone on the globe. */
  onPick: (zone: Zone) => void;
  /** The canvas shows its first frame. */
  onReady: () => void;
};

/** A zone on the globe: its band and its cities, and how far they have faded in. */
type Layer = { zone: Zone; alpha: number; ring: number[] };

type Drag = {
  id: number;
  /** Where it started and where it is (screen px), and when it started (ms). */
  startX: number;
  startY: number;
  lastX: number;
  start: number;
  lastTime: number;
  /** Screen px per canvas px, should the globe sit in a scaled container. */
  scale: number;
  moved: boolean;
  /** The spin, in degrees per second. */
  speed: number;
};

export function createGlobe(canvas: HTMLCanvasElement, options: GlobeOptions) {
  const context = canvas.getContext('2d');
  // The colours' probe: hidden, inside the canvas (fallback content, never drawn).
  const probe = document.createElement('span');
  probe.hidden = true;
  canvas.append(probe);
  let palette = readPalette(probe);
  const dots = landDots();
  const n = dots.count;
  // Each frame's dots, on screen: position, size and brightness level, then sorted by level so
  // each level is filled in one go.
  const dotX = new Float32Array(n);
  const dotY = new Float32Array(n);
  const dotR = new Float32Array(n);
  const dotLevel = new Uint8Array(n);
  const order = new Uint32Array(n);
  /** Brightness levels, for the land's colour and then the band's. */
  const LEVELS = 24;
  const BUCKETS = (LEVELS + 1) * 2;
  const counts = new Uint32Array(BUCKETS);
  const starts = new Uint32Array(BUCKETS + 1);
  const fill = new Uint32Array(BUCKETS);

  let width = 0;
  let height = 0;
  let ratio = 1;
  /** The canvas holds pixels at this size, or '' once they were given back. */
  let pixels = '';
  let onScreen = false;
  let reduced = false;
  let ready = false;

  const view: View = { ...options.view };
  const speed = { lon: 0, lat: 0 };
  let target: View | null = null;
  let spin = 0;
  let drag: Drag | null = null;
  let current: Layer | null = options.zone ? layerOf(options.zone, 1) : null;
  let leaving: Layer[] = [];
  let hover: Layer | null = null;
  let frame = 0;
  let last = 0;

  function layerOf(zone: Zone, alpha: number): Layer {
    return { zone, alpha, ring: bandRing(zone.offset * 15) };
  }

  const radius = () => Math.min(width, height) * GLOBE_SIZE;

  /** A canvas point (CSS px) on the disc, in radii (y up). */
  const toDisc = (x: number, y: number) => {
    const r = radius() || 1;
    return { x: (x - width / 2) / r, y: (height / 2 - y) / r };
  };

  /** The outline of a band, as a path on the canvas. */
  function trace(outline: Outline, cx: number, cy: number, r: number) {
    if (!context) return;
    const { x, y, rim } = outline;
    context.beginPath();
    context.moveTo(cx + x[0] * r, cy - y[0] * r);
    for (let i = 0; i < x.length; i++) {
      const next = (i + 1) % x.length;
      if (rim[i]) {
        const arc = rimArc(x[i], y[i], x[next], y[next]);
        context.arc(cx, cy, r, arc.from, arc.to, !arc.clockwise);
      } else if (next) {
        context.lineTo(cx + x[next] * r, cy - y[next] * r);
      }
    }
    context.closePath();
  }

  function drawBand(layer: Layer, f: Frame, cx: number, cy: number, r: number, strength: number) {
    if (!context) return;
    const outline = clipRing(f, layer.ring);
    if (!outline) return;
    trace(outline, cx, cy, r);
    context.fillStyle = fade(palette.band, strength);
    context.fill();
    context.lineWidth = 1;
    context.strokeStyle = fade(palette.bandLine, strength);
    context.stroke();
  }

  function drawCities(layer: Layer, f: Frame, cx: number, cy: number) {
    if (!context) return;
    context.font =
      '500 11.5px -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", sans-serif';
    context.lineJoin = 'round';
    for (const city of layer.zone.cities) {
      const spot = project(f, city.lat, city.lon);
      // Fades out as it nears the rim.
      const alpha = layer.alpha * Math.min(1, Math.max(0, (spot.z - 0.05) / 0.25));
      if (alpha <= 0) continue;
      const r = radius();
      const x = cx + spot.x * r;
      const y = cy - spot.y * r;
      context.globalAlpha = alpha;
      context.fillStyle = palette.cityRing;
      context.beginPath();
      context.arc(x, y, 6.5, 0, 2 * Math.PI);
      context.fill();
      context.fillStyle = palette.city;
      context.strokeStyle = palette.halo;
      context.lineWidth = 1.5;
      context.beginPath();
      context.arc(x, y, 3.2, 0, 2 * Math.PI);
      context.fill();
      context.stroke();
      const side = city.label ?? 'right';
      context.textAlign = side === 'left' ? 'right' : side === 'right' ? 'left' : 'center';
      context.textBaseline = side === 'above' ? 'bottom' : side === 'below' ? 'top' : 'middle';
      const lx = side === 'left' ? x - 9 : side === 'right' ? x + 9 : x;
      const ly = side === 'above' ? y - 8 : side === 'below' ? y + 8 : y;
      context.lineWidth = 3;
      context.strokeText(city.name, lx, ly);
      context.fillStyle = palette.label;
      context.fillText(city.name, lx, ly);
    }
    context.globalAlpha = 1;
  }

  function draw() {
    if (!context || !pixels) return;
    const r = radius();
    const cx = width / 2;
    const cy = height / 2;
    const f = frameOf(view);
    context.setTransform(ratio, 0, 0, ratio, 0, 0);
    context.clearRect(0, 0, width, height);

    // A soft glow round the globe, its body lit from the top left, and its rim.
    const glow = context.createRadialGradient(cx, cy, r * 0.96, cx, cy, r * 1.24);
    glow.addColorStop(0, palette.glow);
    glow.addColorStop(1, fade(palette.glow, 0));
    context.fillStyle = glow;
    context.fillRect(cx - r * 1.3, cy - r * 1.3, r * 2.6, r * 2.6);
    const body = context.createRadialGradient(cx - r * 0.4, cy - r * 0.5, 0, cx, cy, r * 1.15);
    body.addColorStop(0, palette.lit);
    body.addColorStop(0.55, palette.body);
    body.addColorStop(1, palette.shade);
    context.fillStyle = body;
    context.beginPath();
    context.arc(cx, cy, r, 0, 2 * Math.PI);
    context.fill();

    // The graticule: the meridians between time zones, and a few parallels.
    context.beginPath();
    for (const run of graticule(f)) {
      context.moveTo(cx + run.x[0] * r, cy - run.y[0] * r);
      for (let i = 1; i < run.x.length; i++) context.lineTo(cx + run.x[i] * r, cy - run.y[i] * r);
    }
    context.lineWidth = 0.75;
    context.strokeStyle = palette.grid;
    context.stroke();

    // The band under the pointer, faint; the bands fading out; the band shown.
    if (hover && hover.zone !== current?.zone) drawBand(hover, f, cx, cy, r, 0.45);
    for (const layer of leaving) drawBand(layer, f, cx, cy, r, layer.alpha);
    if (current) drawBand(current, f, cx, cy, r, current.alpha);

    // The land: each dot's place, size and brightness; dots in a band shown are brighter.
    const lit = [current, ...leaving].filter((layer): layer is Layer => !!layer);
    let shown = 0;
    counts.fill(0);
    const base = r * DOT_SIZE;
    for (let k = 0; k < n; k++) {
      const px = dots.x[k];
      const py = dots.y[k];
      const a = px * f.cosLon + py * f.sinLon;
      const z = f.sinLat * dots.z[k] + f.cosLat * a;
      if (z < 0) continue;
      let light = 0;
      for (const layer of lit) {
        if (Math.abs(wrap(dots.lon[k] - layer.zone.offset * 15)) <= 7.5) {
          light = Math.max(light, layer.alpha);
        }
      }
      dotX[shown] = cx + (py * f.cosLon - px * f.sinLon) * r;
      dotY[shown] = cy - (f.cosLat * dots.z[k] - f.sinLat * a) * r;
      dotR[shown] = Math.max(0.55, base * dotScale(z));
      const bucket = Math.round(dotAlpha(z, light) * LEVELS) + (light > 0.5 ? LEVELS + 1 : 0);
      dotLevel[shown] = bucket;
      counts[bucket]++;
      shown++;
    }
    // Sorted by colour and brightness (a counting sort), then one path for each.
    for (let bucket = 0; bucket < BUCKETS; bucket++) {
      starts[bucket + 1] = starts[bucket] + counts[bucket];
      fill[bucket] = starts[bucket];
    }
    for (let k = 0; k < shown; k++) order[fill[dotLevel[k]]++] = k;
    for (let bucket = 0; bucket < BUCKETS; bucket++) {
      if (!counts[bucket]) continue;
      context.beginPath();
      for (let i = starts[bucket]; i < starts[bucket + 1]; i++) {
        const k = order[i];
        context.moveTo(dotX[k] + dotR[k], dotY[k]);
        context.arc(dotX[k], dotY[k], dotR[k], 0, 2 * Math.PI);
      }
      const band = bucket > LEVELS;
      const level = band ? bucket - LEVELS - 1 : bucket;
      context.fillStyle = fade(band ? palette.landLit : palette.land, level / LEVELS);
      context.fill();
    }

    context.beginPath();
    context.arc(cx, cy, r - 0.5, 0, 2 * Math.PI);
    context.lineWidth = 1;
    context.strokeStyle = palette.rim;
    context.stroke();

    for (const layer of leaving) drawCities(layer, f, cx, cy);
    if (current) drawCities(current, f, cx, cy);

    if (!ready) {
      ready = true;
      options.onReady();
    }
  }

  /** Moves everything on by `dt` seconds; whether anything still moves. */
  function advance(dt: number) {
    let moving = false;
    if (drag?.moved) {
      // The pointer moves the globe.
    } else if (spin) {
      view.lon = wrap(view.lon + spin * dt);
      spin *= Math.exp(-dt / FRICTION);
      if (Math.abs(spin) < 2) spin = 0;
      moving = true;
    } else if (target) {
      // A spring toward the target, in small steps so it stays stable on a slow frame.
      const steps = Math.ceil(dt / (1 / 240));
      const h = dt / steps;
      for (let i = 0; i < steps; i++) {
        const dLon = wrap(view.lon - target.lon);
        const dLat = view.lat - target.lat;
        speed.lon += (-STIFFNESS * dLon - DAMPER * speed.lon) * h;
        speed.lat += (-STIFFNESS * dLat - DAMPER * speed.lat) * h;
        view.lon = wrap(view.lon + speed.lon * h);
        view.lat += speed.lat * h;
      }
      // Settled once the rest of the move is a fraction of a pixel.
      const settled =
        Math.abs(wrap(view.lon - target.lon)) < 0.05 &&
        Math.abs(view.lat - target.lat) < 0.05 &&
        Math.hypot(speed.lon, speed.lat) < 0.5;
      if (settled) {
        view.lon = target.lon;
        view.lat = target.lat;
        speed.lon = 0;
        speed.lat = 0;
        target = null;
      } else moving = true;
    }
    if (current && current.alpha < 1) {
      current.alpha = Math.min(1, current.alpha + dt / FADE);
      moving = true;
    }
    if (leaving.length) {
      for (const layer of leaving) layer.alpha -= dt / FADE;
      leaving = leaving.filter((layer) => layer.alpha > 0);
      moving = true;
    }
    return moving;
  }

  function tick(now: number) {
    frame = 0;
    // A long gap (a busy thread, a hidden tab) counts as one short frame.
    const dt = last ? Math.min(0.064, (now - last) / 1000) : 1 / 60;
    last = now;
    const moving = advance(dt);
    draw();
    if (moving && onScreen) frame = requestAnimationFrame(tick);
    else last = 0;
  }

  /** Draws on the next frame, and goes on while something moves. */
  function wake() {
    if (!frame && onScreen && pixels) frame = requestAnimationFrame(tick);
  }

  /** Gives the canvas pixels for its box, or takes them back. */
  function allocate() {
    const size = onScreen && width && height ? `${width}x${height}@${ratio}` : '';
    if (size === pixels) return;
    pixels = size;
    canvas.width = size ? Math.round(width * ratio) : 0;
    canvas.height = size ? Math.round(height * ratio) : 0;
  }

  // The colour scheme changed: the colours are read again, and the globe drawn in them.
  const onScheme = (event: TransitionEvent) => {
    if (event.target !== canvas || event.propertyName !== 'color') return;
    palette = readPalette(probe);
    draw();
  };
  canvas.addEventListener('transitionend', onScheme);

  const sizes = new ResizeObserver(([entry]) => {
    width = entry.contentRect.width;
    height = entry.contentRect.height;
    ratio = Math.min(2, window.devicePixelRatio || 1);
    allocate();
    draw();
  });
  sizes.observe(canvas);

  // The pointer: a drag spins the globe, a click picks the zone under it.
  const scaleOf = () => canvas.getBoundingClientRect().width / canvas.offsetWidth || 1;

  function pointAt(event: PointerEvent, scale: number) {
    const box = canvas.getBoundingClientRect();
    return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
  }

  function zoneAtPoint(x: number, y: number) {
    const disc = toDisc(x, y);
    const place = unproject(frameOf(view), disc.x, disc.y);
    return place ? options.zoneAt(place.lat, place.lon) : null;
  }

  function setHover(zone: Zone | null) {
    if ((hover?.zone ?? null) === zone) return;
    hover = zone ? layerOf(zone, 1) : null;
    draw();
  }

  const onPointerDown = (event: PointerEvent) => {
    if (event.pointerType === 'mouse' && event.button !== 0) return;
    drag = {
      id: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      lastX: event.clientX,
      start: event.timeStamp,
      lastTime: event.timeStamp,
      scale: scaleOf(),
      moved: false,
      speed: 0,
    };
    spin = 0;
    canvas.setPointerCapture(event.pointerId);
  };

  const onPointerMove = (event: PointerEvent) => {
    if (!drag || event.pointerId !== drag.id) {
      if (event.pointerType === 'mouse' && !drag) {
        const point = pointAt(event, scaleOf());
        setHover(zoneAtPoint(point.x, point.y));
      }
      return;
    }
    const travelled = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
    if (!drag.moved && travelled < CLICK_SLOP) return;
    if (!drag.moved) {
      drag.moved = true;
      target = null;
      speed.lon = 0;
      speed.lat = 0;
      canvas.dataset.dragging = '';
      setHover(null);
    }
    // A pointer moving right turns the globe so the land under it follows.
    const degrees = ((event.clientX - drag.lastX) / drag.scale / (radius() || 1)) * (180 / Math.PI);
    view.lon = wrap(view.lon - degrees);
    const dt = Math.max(1, event.timeStamp - drag.lastTime);
    drag.speed = drag.speed * 0.6 + (-degrees / dt) * 1000 * 0.4;
    drag.lastX = event.clientX;
    drag.lastTime = event.timeStamp;
    wake();
  };

  const onPointerUp = (event: PointerEvent) => {
    if (!drag || event.pointerId !== drag.id) return;
    const ended = drag;
    drag = null;
    delete canvas.dataset.dragging;
    if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
    if (ended.moved) {
      // Let go mid-swipe: it keeps spinning a moment (a pause before letting go stops it).
      const fresh = event.timeStamp - ended.lastTime < 80;
      spin = reduced || !fresh ? 0 : Math.max(-400, Math.min(400, ended.speed));
      wake();
      return;
    }
    if (event.timeStamp - ended.start > CLICK_TIME) return;
    const point = pointAt(event, ended.scale);
    const zone = zoneAtPoint(point.x, point.y);
    if (zone) options.onPick(zone);
  };

  const onPointerCancel = (event: PointerEvent) => {
    if (!drag || event.pointerId !== drag.id) return;
    drag = null;
    delete canvas.dataset.dragging;
  };

  const onPointerLeave = () => setHover(null);

  canvas.addEventListener('pointerdown', onPointerDown);
  canvas.addEventListener('pointermove', onPointerMove);
  canvas.addEventListener('pointerup', onPointerUp);
  canvas.addEventListener('pointercancel', onPointerCancel);
  canvas.addEventListener('pointerleave', onPointerLeave);

  return {
    /** Turns the globe to `zone` and shows its band and cities (none: the band fades). */
    show(zone: Zone | null) {
      if (zone) target = viewOf(zone);
      if (zone !== (current?.zone ?? null)) {
        if (current) leaving.push(current);
        current = zone ? layerOf(zone, 0) : null;
      }
      // A spin still going carries on into the spring.
      if (spin) speed.lon = spin;
      spin = 0;
      if (reduced) {
        if (target) {
          view.lon = target.lon;
          view.lat = target.lat;
        }
        target = null;
        if (current) current.alpha = 1;
        leaving = [];
        draw();
        return;
      }
      wake();
    },
    /** Where a place shows on the canvas (CSS px), or null on the far side. */
    locate(lat: number, lon: number) {
      const spot = project(frameOf(view), lat, lon);
      if (spot.z < 0.1) return null;
      const r = radius();
      return { x: width / 2 + spot.x * r, y: height / 2 - spot.y * r };
    },
    /** The zone under a canvas point (CSS px), as a click there would pick it. */
    zoneAtPoint,
    /** Near the screen it draws; away from it, it stops and gives its pixels back. */
    setOnScreen(value: boolean) {
      onScreen = value;
      allocate();
      if (onScreen) {
        draw();
        wake();
      } else {
        cancelAnimationFrame(frame);
        frame = 0;
        last = 0;
      }
    },
    setReducedMotion(value: boolean) {
      reduced = value;
      if (!reduced) return;
      spin = 0;
      if (target) {
        view.lon = target.lon;
        view.lat = target.lat;
        target = null;
      }
      if (current) current.alpha = 1;
      leaving = [];
      draw();
    },
    destroy() {
      cancelAnimationFrame(frame);
      sizes.disconnect();
      canvas.removeEventListener('pointerdown', onPointerDown);
      canvas.removeEventListener('pointermove', onPointerMove);
      canvas.removeEventListener('pointerup', onPointerUp);
      canvas.removeEventListener('pointercancel', onPointerCancel);
      canvas.removeEventListener('pointerleave', onPointerLeave);
      canvas.removeEventListener('transitionend', onScheme);
      probe.remove();
      canvas.width = 0;
      canvas.height = 0;
    },
  };
}

export type Globe = ReturnType<typeof createGlobe>;
```

### timezone-globe/sphere.ts

```tsx
/*
 * The globe's geometry: an orthographic projection, as if the globe were seen from far away, so
 * only the half facing the reader shows. Shared by the canvas (globe.ts) and the still drawn on
 * the server (globe-still.tsx).
 *
 * - A view is the point of the globe facing the reader, a longitude and a latitude. Points on the
 *   globe are unit vectors; turned into the view they read as x (right), y (up) and z (toward the
 *   reader). x and y are the position on the disc, in radii; z below 0 is the far side.
 * - A time zone's band is the lune between two meridians. Its outline is clipped to the near
 *   side, and where it runs behind the globe, the outline follows the rim instead.
 * - The graticule draws the meridians that bound the time zones (every 15°, offset by 7.5°) and a
 *   few parallels.
 */

/** The point of the globe facing the reader, in degrees. */
export type View = { lon: number; lat: number };

/** A view's rotation, as the sines and cosines it is made of. */
export type Frame = { cosLon: number; sinLon: number; cosLat: number; sinLat: number };

/** A point on the disc: x and y in radii (y up), z toward the reader. */
export type Spot = { x: number; y: number; z: number };

/** An outline clipped to the near side. `rim[i]` marks the edge from point i to the next one as
 * one that runs along the rim of the disc. */
export type Outline = { x: number[]; y: number[]; rim: boolean[] };

/** A line drawn on the near side: runs of points on the disc. */
export type Polyline = { x: number[]; y: number[] }[];

const RADIANS = Math.PI / 180;

export function frameOf({ lon, lat }: View): Frame {
  return {
    cosLon: Math.cos(lon * RADIANS),
    sinLon: Math.sin(lon * RADIANS),
    cosLat: Math.cos(lat * RADIANS),
    sinLat: Math.sin(lat * RADIANS),
  };
}

/** The unit vector of a place: x toward 0° E on the equator, y toward 90° E, z north. */
export function vectorOf(lat: number, lon: number): [number, number, number] {
  const c = Math.cos(lat * RADIANS);
  return [c * Math.cos(lon * RADIANS), c * Math.sin(lon * RADIANS), Math.sin(lat * RADIANS)];
}

/** Where a unit vector shows in the view. */
export function turn(frame: Frame, px: number, py: number, pz: number): Spot {
  const a = px * frame.cosLon + py * frame.sinLon;
  return {
    x: py * frame.cosLon - px * frame.sinLon,
    y: frame.cosLat * pz - frame.sinLat * a,
    z: frame.sinLat * pz + frame.cosLat * a,
  };
}

/** Where a place shows in the view. */
export const project = (frame: Frame, lat: number, lon: number) =>
  turn(frame, ...vectorOf(lat, lon));

/** The place under a point of the disc (x, y in radii, y up), or null off the globe. */
export function unproject(frame: Frame, x: number, y: number) {
  const d = x * x + y * y;
  if (d > 1) return null;
  const z = Math.sqrt(1 - d);
  const a = frame.cosLat * z - frame.sinLat * y;
  const c = frame.sinLat * z + frame.cosLat * y;
  const px = a * frame.cosLon - x * frame.sinLon;
  const py = a * frame.sinLon + x * frame.cosLon;
  return {
    lat: Math.asin(Math.max(-1, Math.min(1, c))) / RADIANS,
    lon: Math.atan2(py, px) / RADIANS,
  };
}

/** Wraps a longitude difference into −180 to 180. */
export const wrap = (degrees: number) => degrees - 360 * Math.floor((degrees + 180) / 360);

/**
 * Clips a closed ring of places (unit vectors, x y z after one another) to the near side. Each
 * edge that crosses the rim ends on it; from where the ring goes behind to where it comes back,
 * the outline follows the rim.
 */
export function clipRing(frame: Frame, ring: readonly number[]): Outline | null {
  const out: Outline = { x: [], y: [], rim: [] };
  const n = ring.length / 3;
  let previous = turn(frame, ring[(n - 1) * 3], ring[(n - 1) * 3 + 1], ring[(n - 1) * 3 + 2]);
  for (let i = 0; i < n; i++) {
    const spot = turn(frame, ring[i * 3], ring[i * 3 + 1], ring[i * 3 + 2]);
    const wasNear = previous.z >= 0;
    const isNear = spot.z >= 0;
    if (wasNear !== isNear) {
      // Where the edge meets the rim, pushed out onto the circle.
      const t = previous.z / (previous.z - spot.z);
      const x = previous.x + (spot.x - previous.x) * t;
      const y = previous.y + (spot.y - previous.y) * t;
      const length = Math.hypot(x, y) || 1;
      out.x.push(x / length);
      out.y.push(y / length);
      // Going behind: the outline follows the rim to where it comes back.
      out.rim.push(wasNear);
    }
    if (isNear) {
      out.x.push(spot.x);
      out.y.push(spot.y);
      out.rim.push(false);
    }
    previous = spot;
  }
  return out.x.length > 2 ? out : null;
}

/** The ring of a time zone's band: up one meridian from pole to pole and down the other. */
export function bandRing(centre: number, halfWidth = 7.5): number[] {
  const ring: number[] = [];
  for (let lat = -90; lat <= 90; lat += 3) ring.push(...vectorOf(lat, centre - halfWidth));
  for (let lat = 90; lat >= -90; lat -= 3) ring.push(...vectorOf(lat, centre + halfWidth));
  return ring;
}

/** The meridians between time zones and a few parallels, as lines of places. */
function graticuleLines() {
  const lines: number[][] = [];
  for (let lon = -172.5; lon < 180; lon += 15) {
    const line: number[] = [];
    for (let lat = -84; lat <= 84; lat += 4) line.push(...vectorOf(lat, lon));
    lines.push(line);
  }
  for (const lat of [-60, -30, 0, 30, 60]) {
    const line: number[] = [];
    for (let lon = -180; lon <= 180; lon += 5) line.push(...vectorOf(lat, lon));
    lines.push(line);
  }
  return lines;
}

const GRATICULE = graticuleLines();

/** The graticule's near side, as runs of points on the disc. */
export function graticule(frame: Frame): Polyline {
  const runs: Polyline = [];
  for (const line of GRATICULE) {
    let run: { x: number[]; y: number[] } | null = null;
    let previous: Spot | null = null;
    for (let i = 0; i < line.length; i += 3) {
      const spot = turn(frame, line[i], line[i + 1], line[i + 2]);
      if (previous && previous.z >= 0 !== spot.z >= 0) {
        // The line crosses the rim: it starts or stops there.
        const t = previous.z / (previous.z - spot.z);
        const x = previous.x + (spot.x - previous.x) * t;
        const y = previous.y + (spot.y - previous.y) * t;
        if (run) {
          run.x.push(x);
          run.y.push(y);
          run = null;
        } else {
          run = { x: [x], y: [y] };
          runs.push(run);
        }
      }
      if (spot.z >= 0) {
        if (!run) {
          run = { x: [], y: [] };
          runs.push(run);
        }
        run.x.push(spot.x);
        run.y.push(spot.y);
      }
      previous = spot;
    }
  }
  return runs;
}

/**
 * The rim arc from one point on the rim to another, the short way round: the angles, on screen
 * (y down), and the direction.
 */
export function rimArc(x0: number, y0: number, x1: number, y1: number) {
  const from = Math.atan2(-y0, x0);
  const to = Math.atan2(-y1, x1);
  const sweep = wrap(((to - from) * 180) / Math.PI);
  return { from, to, clockwise: sweep > 0 };
}
```

### timezone-globe/land.ts

```tsx
/*
 * The land, as a halftone: points spread evenly over the sphere (a Fibonacci spiral), kept where
 * they fall on land.
 *
 * - The coastlines are coarse outlines written by hand, as longitude and latitude pairs: the
 *   continents, the large islands, and two inland seas cut out of Eurasia. The dots are about
 *   two degrees apart, so a point every few degrees of coast is plenty.
 * - A point is on land when it falls inside an outline (a ray cast in longitude and latitude)
 *   and in no sea. The spiral and the test are plain arithmetic, so the server and the browser
 *   find the same dots; they are worked out once, the first time they are needed.
 */

/** An outline: longitude, latitude, longitude, latitude… in degrees. */
type Outline = readonly number[];

/** From the Bering Strait along the Arctic coast, round Hudson Bay, down the Atlantic, round the
 * Gulf of Mexico to Panama, and back up the Pacific coast to Alaska. */
const NORTH_AMERICA: Outline = [
  -168.1, 65.6, -166, 66.3, -161.8, 66.6, -163.7, 67.4, -166.8, 68.3, -163, 69.7, -161.9, 70.3,
  -156.8, 71.3, -152, 70.8, -148.3, 70.3, -143.5, 70.1, -141, 69.7, -137.5, 69, -135, 69.5, -133,
  69.5, -129.5, 70, -128, 70.6, -124.7, 70, -121, 69.4, -117.5, 69, -115, 67.8, -110, 68.1, -106,
  68.2, -101.5, 67.8, -97, 67.7, -95.8, 69.5, -94.5, 72, -92, 70.5, -90.5, 69, -88, 68.5, -85.5,
  69.5, -84.5, 69.9, -81.5, 69.2, -81.3, 67.6, -83, 66.4, -86.2, 66.5, -87.8, 65.3, -90.7, 63.3,
  -94.1, 61.1, -94.2, 58.8, -92.5, 57.1, -87.6, 56, -82.3, 55.1, -82.3, 52.9, -80.6, 51.3, -79,
  51.5, -78.6, 52.3, -78.9, 53.8, -77.7, 55.3, -76.6, 56.6, -78.1, 58.5, -77.3, 60, -78.1, 62.4,
  -75.6, 62.2, -72, 61.6, -69.6, 61, -69.9, 58.7, -68.4, 58.1, -65.9, 58.7, -64.4, 60.4, -62.6,
  58.4, -61.7, 56.5, -60.2, 55.4, -57.5, 54.2, -55.8, 52.3, -57.1, 51.4, -60, 50.2, -63.6, 50.3,
  -66.4, 50.2, -68.2, 49.2, -69.7, 48.1, -71.2, 46.8, -69.5, 47.8, -67.5, 48.8, -64.3, 48.8, -65.3,
  48, -64.8, 46.8, -64, 46.2, -62, 45.7, -61, 46.6, -60.4, 47, -59.9, 46, -61, 45.3, -63.6, 44.6,
  -65.6, 43.5, -66, 44.6, -67, 44.8, -69, 44.1, -70.2, 43.6, -70.6, 42.7, -70, 41.8, -71.9, 41.1,
  -74, 40.6, -74.1, 39.7, -74.9, 38.9, -75.1, 38.3, -75.9, 37.2, -75.9, 36.6, -75.5, 35.2, -76.5,
  34.6, -77.9, 33.9, -79.2, 33.1, -80.9, 32, -81.4, 30.4, -80.6, 28.4, -80, 26.7, -80.2, 25.6,
  -81.1, 25.1, -81.8, 26.1, -82.7, 27.7, -82.8, 29.1, -84.3, 30, -85.4, 29.7, -86.5, 30.4, -88,
  30.4, -89.2, 30.3, -89.4, 29, -90.2, 29.2, -92, 29.5, -93.9, 29.7, -94.8, 29.3, -96.5, 28.3,
  -97.4, 27.8, -97.4, 26.5, -97.2, 25.9, -97.7, 24.3, -97.8, 22.3, -97.4, 21, -96.1, 19.2, -94.4,
  18.2, -92.6, 18.6, -91.8, 18.6, -90.5, 19.8, -90.4, 20.9, -89.6, 21.3, -88.2, 21.6, -87, 21.5,
  -86.8, 21.2, -87.5, 19.8, -87.8, 18.3, -88.2, 17.5, -88.6, 15.9, -87.9, 15.9, -86, 15.9, -84,
  15.8, -83.2, 15, -83.4, 14, -83.7, 12, -83.7, 11, -83, 10, -82.2, 9.3, -80.9, 9, -79.9, 9.4,
  -78.5, 9.4, -77.4, 8.7, -77.9, 7.2, -78.4, 8.3, -79.5, 9, -80.4, 7.3, -81.7, 8, -82.9, 8.1, -83.5,
  8.5, -84.8, 9.9, -85.7, 9.9, -85.8, 11, -87.2, 12.5, -87.6, 13, -89.3, 13.5, -91.4, 13.9, -92.3,
  14.5, -93.8, 16.1, -95.2, 16.2, -96.5, 15.7, -98.5, 16.3, -99.9, 16.8, -101.5, 17.6, -102.2, 18,
  -103.5, 18.3, -104.3, 19.1, -105.7, 20.4, -105.3, 21.5, -106.4, 23.2, -107.9, 24.6, -109, 25.6,
  -109.6, 26.8, -110.9, 27.9, -112, 28.8, -113.5, 31.3, -114.8, 31.8, -114.9, 31, -113.6, 29,
  -112.3, 27.3, -111.4, 26, -110.3, 24.2, -109.9, 22.9, -110.2, 23.5, -112.1, 24.7, -112.2, 26,
  -113.6, 26.7, -115.1, 27.9, -114.1, 28.1, -115.7, 30.1, -116.6, 31.9, -117.2, 32.7, -118.4, 33.8,
  -119.7, 34.4, -120.6, 34.5, -120.9, 35.4, -121.9, 36.6, -122.5, 37.8, -123, 38, -123.8, 39.6,
  -124.4, 40.4, -124.2, 42, -124.1, 43.4, -123.9, 46.2, -124.7, 48.4, -125.6, 48.9, -127.4, 50,
  -128.4, 50.8, -127.8, 51.2, -128.2, 52.2, -129.5, 53.4, -130.3, 54.3, -131.7, 55.4, -133.3, 55.5,
  -135.4, 57.1, -136.5, 58.2, -137.5, 58.7, -139.7, 59.5, -141.5, 60, -144, 60.2, -145.8, 60.5,
  -149.5, 60.1, -151.8, 59.2, -153.5, 59.3, -154.5, 58.5, -156, 57.5, -158.4, 56.3, -160.5, 55.5,
  -162.5, 55, -164.8, 54.6, -162, 55.8, -160, 56.4, -158.6, 57, -157.4, 58.2, -157, 58.7, -158.5,
  59, -160.4, 59.1, -162.2, 58.7, -161.9, 59.8, -163.5, 60, -165.2, 60.6, -166.1, 61.5, -165, 62.6,
  -163.5, 63.1, -162, 63.5, -160.8, 63.9, -161.5, 64.4, -163, 64.6, -165.4, 64.5, -166.5, 64.8,
];

const GREENLAND: Outline = [
  -43.9, 59.8, -42, 61.5, -40, 63.6, -37.6, 65.6, -33, 67.8, -26, 68.6, -22, 70.5, -22, 72.5, -19.5,
  74.5, -18.5, 76.5, -17, 79.5, -12, 81.5, -20, 82.5, -30, 83.5, -40, 83.3, -50, 82.5, -60, 82, -65,
  81, -69, 79.5, -72.5, 78.3, -68.7, 76.5, -66.3, 76, -58.5, 75.5, -56, 73, -54.5, 70.5, -51, 69.2,
  -53.5, 67, -51.7, 64.2, -49.5, 62, -46, 60.7,
];

const BAFFIN: Outline = [
  -80, 73.7, -73.5, 71.5, -68.5, 70.4, -66, 68.5, -61.8, 66.6, -65, 65, -64, 63.5, -65, 62, -68,
  62.5, -71, 63.3, -74.5, 64.5, -77.8, 64.3, -76.5, 65.5, -73.5, 66.5, -72, 68, -77, 69.5, -80,
  70.5, -85, 71.5, -88, 73.4, -85, 73.8,
];

const VICTORIA: Outline = [
  -118.5, 71.2, -117.5, 72.8, -112, 73.2, -105, 73, -101.5, 71.5, -102, 69.5, -105, 69, -110, 68.8,
  -114, 69.2, -117.5, 69.8,
];

const BANKS: Outline = [-125, 71.9, -124, 74.2, -117.5, 74.3, -115.5, 73.5, -120, 71.4];

const ELLESMERE: Outline = [
  -89, 76.5, -79, 76.2, -75, 78.5, -70, 80, -61.5, 82, -70, 83, -85, 82, -92, 80.5, -90, 78,
];

const DEVON: Outline = [-92, 75.5, -88, 76.6, -80.5, 76.2, -80, 74.6, -88, 74.5, -92, 74.8];

const SOUTHAMPTON: Outline = [-87, 64.5, -86, 66, -81.5, 65.5, -80.5, 64, -84, 63, -86.5, 63.5];

const NEWFOUNDLAND: Outline = [
  -59.4, 47.6, -56, 51.6, -55.5, 49.5, -53.5, 49.3, -52.6, 47.6, -53.1, 46.7, -54, 47.3, -55.5, 47,
  -57, 47.6,
];

const CUBA: Outline = [
  -84.9, 21.9, -83, 23, -80.5, 23.1, -78, 22.4, -75.6, 21.1, -74.1, 20.2, -75.8, 19.9, -77.7, 19.9,
  -77.5, 20.7, -79, 21.6, -81, 22.1, -82.5, 22.6, -84, 22.1,
];

const HISPANIOLA: Outline = [
  -74.5, 18.5, -73.4, 19.7, -72.3, 19.8, -70.7, 19.8, -69.5, 19.3, -68.4, 18.6, -69.9, 18.4, -71.4,
  17.6, -72.5, 18.2, -73.9, 18,
];

/** From the Panama border along the Caribbean, down the Atlantic to Cape Horn, and up the
 * Pacific coast. */
const SOUTH_AMERICA: Outline = [
  -77.3, 8.6, -75.5, 10.4, -74.8, 11, -74.2, 11.3, -72.5, 11.8, -71.7, 12.4, -71, 11.3, -70.2, 11.9,
  -69.8, 11.4, -68.4, 10.5, -67, 10.6, -65, 10.1, -63.9, 10.6, -62, 10.7, -61, 9.5, -60, 8.5, -58.9,
  7.3, -58.2, 6.8, -57, 6, -55.2, 5.9, -53.8, 5.7, -52.3, 4.9, -51.1, 3.9, -50, 1.8, -49.9, 0.5,
  -48.4, -0.7, -48.4, -1.3, -47, -0.6, -44.4, -2.5, -41.8, -2.9, -38.5, -3.7, -37, -4.9, -35.2,
  -5.5, -34.9, -7.1, -34.9, -8.1, -35.7, -9.7, -37, -11, -38.5, -13, -39, -14.8, -39.2, -17.7,
  -40.3, -20.3, -41, -21.6, -42, -22.9, -43.2, -23, -44.7, -23.4, -46.3, -24, -47.9, -25, -48.6,
  -26.6, -48.5, -27.6, -48.8, -28.6, -50, -29.8, -51, -31, -52.1, -32.1, -53.4, -33.7, -54.9, -35,
  -56.2, -34.9, -57.8, -34.5, -58.4, -34, -58.4, -34.6, -57.3, -35.4, -56.7, -36.3, -57.5, -38, -59,
  -38.8, -61, -39, -62.3, -38.8, -62.3, -39.9, -65, -40.8, -64, -42, -63.6, -42.6, -65, -42.8,
  -65.7, -44.8, -67.5, -45.9, -65.9, -47.1, -67.7, -49.3, -68.5, -50.1, -69.2, -51.6, -68.4, -52.3,
  -68.3, -53, -67.7, -53.8, -65.3, -54.7, -67.3, -55.9, -70, -55.3, -72.5, -54, -74.7, -52.7, -75.3,
  -51, -75.6, -48.7, -75.6, -46.6, -74.3, -45, -74, -43.4, -74, -41.8, -73.7, -40, -73.5, -37.5,
  -73.1, -36.8, -72.2, -34.5, -71.6, -33, -71.5, -30, -71, -27.4, -70.4, -23.7, -70.2, -20.2, -70.3,
  -18.5, -71.4, -17.6, -72.6, -16.7, -74, -15.6, -76.3, -13.8, -77.1, -12.1, -78.2, -10.5, -78.6,
  -9.1, -79, -8.1, -79.9, -6.8, -81.2, -6, -81.3, -4.7, -80.3, -3.4, -79.9, -2.6, -80.9, -2.2,
  -80.7, -1, -80.1, 0.8, -79.7, 1, -78.8, 1.8, -77.5, 2.6, -77.1, 3.9, -77.4, 5.5, -77.5, 6.6,
  -77.9, 7.2,
];

const FALKLANDS: Outline = [-61.3, -51.3, -58.4, -51.3, -57.7, -51.7, -58.6, -52.3, -60.7, -52.1];

/** From the Strait of Gibraltar up the Atlantic coast, round the Baltic, Scandinavia and the
 * Arctic to the 180th meridian, down the Pacific, round India and Arabia, and back west along the
 * Mediterranean. Chukotka's tip, past the 180th meridian, is an outline of its own. */
const EURASIA: Outline = [
  -5.6, 36, -6.3, 36.5, -7, 37.2, -9, 37, -8.9, 38, -9.5, 38.8, -9.4, 39.4, -8.9, 40.2, -8.7, 41.2,
  -8.9, 42.1, -9.3, 42.9, -8.4, 43.4, -7.9, 43.8, -5.7, 43.6, -3.8, 43.5, -1.8, 43.4, -1.3, 44.7,
  -1.2, 45.7, -1.2, 46.2, -2.2, 47.3, -3.4, 47.7, -4.8, 48, -4.8, 48.4, -4, 48.7, -2, 48.7, -1.6,
  49.7, -1.1, 49.4, 0.1, 49.5, 1.1, 50, 1.9, 51, 2.4, 51.1, 3.6, 51.5, 4.1, 52, 4.8, 53, 5.5, 53.4,
  7, 53.6, 8.1, 53.6, 8.7, 53.9, 8.9, 54.3, 8.3, 55, 8.4, 55.5, 8.2, 56.9, 8.6, 57.1, 9.9, 57.6,
  10.6, 57.8, 10.5, 57.4, 10.3, 57, 11, 56.4, 10.3, 56.1, 9.9, 55.7, 9.5, 54.8, 10.2, 54.4, 11.1,
  54.5, 11.5, 53.9, 12.1, 54.2, 13.6, 54.6, 14.3, 53.9, 15.6, 54.2, 17.6, 54.8, 18.7, 54.4, 19.6,
  54.5, 20, 55, 21.1, 55.7, 21, 56.5, 21.6, 57.4, 22.6, 57.8, 24.1, 57, 24.5, 58.4, 23.5, 59.2,
  24.8, 59.5, 28, 59.5, 30.3, 60, 28.8, 60.7, 26.9, 60.5, 25, 60.2, 23, 59.8, 22.3, 60.5, 21.5,
  61.5, 21.6, 63.1, 23.1, 63.9, 25.5, 65, 24.2, 65.9, 22.2, 65.6, 21.2, 64.8, 20.3, 63.8, 18.7,
  63.3, 17.3, 62.4, 17.1, 61.7, 17.2, 60.7, 18.5, 60.4, 18.9, 59.4, 17, 58.8, 16.7, 57.8, 16.4,
  56.7, 15.6, 56.2, 14.5, 56, 13.8, 55.4, 13, 55.6, 12.7, 56.1, 12.9, 56.7, 12.3, 57.1, 11.9, 57.7,
  11.2, 58.4, 11, 59.1, 10.5, 59.3, 9.8, 59, 9.3, 58.7, 8, 58.2, 7.1, 58, 6, 58.5, 5.6, 59, 5.2,
  59.5, 5, 60.4, 5, 61.2, 5, 62, 6.2, 62.5, 7.7, 63.1, 8.7, 63.6, 9.8, 64.2, 11.3, 64.9, 12.2, 65.5,
  13, 66.3, 14.4, 67.3, 15.5, 68.3, 16.5, 68.8, 19, 69.7, 20.5, 70.1, 23.7, 70.7, 25.8, 71.2, 27.7,
  71, 31.1, 70.4, 30, 69.8, 32, 69.6, 33.5, 69.4, 36.5, 69.1, 39, 68.5, 40, 68, 41.3, 66.9, 38,
  66.1, 32.4, 67.2, 34.5, 66, 34.8, 64.5, 38.1, 63.9, 40.5, 64.6, 42, 66.4, 44, 66, 43.4, 68.7, 46,
  67.7, 50, 68, 54, 68.6, 57, 68.6, 60.5, 69.8, 66.5, 69.2, 68.5, 70.8, 69.5, 72.8, 71.5, 73.4,
  72.8, 72, 72.3, 68.5, 72.5, 66.6, 74.5, 67.5, 75.5, 70, 77, 72.3, 79.5, 72.5, 80.5, 71, 81, 73.6,
  86, 74.2, 90, 75.5, 98, 76.3, 104.3, 77.7, 108, 76.7, 112, 75.6, 111, 73.7, 114, 73.7, 119, 73,
  126.5, 73.4, 129, 72.5, 131, 71.5, 135, 71.6, 137, 71.4, 140, 72.5, 150, 71.4, 160, 69.7, 166,
  69.6, 170.5, 70.1, 176, 69.8, 180, 69, 180, 65.5, 177.5, 64.7, 178.5, 63.4, 179.1, 62.3, 174,
  61.8, 170.5, 60, 166, 60.3, 163.2, 57.8, 162.5, 56.2, 162.1, 54.8, 158.7, 53, 156.7, 50.9, 156.1,
  52.5, 155.7, 55, 156.8, 57.7, 158.5, 58.8, 161.5, 60.5, 163, 62, 160.5, 61.9, 159.2, 61.9, 156,
  61, 154.1, 59.6, 152.3, 59, 150.8, 59.6, 148, 59.3, 145.5, 59.4, 143.3, 59.4, 141, 58.4, 138.2,
  56.5, 137.5, 54.5, 139.8, 54, 141.4, 53, 140.8, 51.5, 140.3, 49, 138.5, 47, 137, 45.5, 135.5, 44,
  133, 42.8, 131.9, 43.1, 130.7, 42.3, 129.8, 41.8, 127.6, 39.8, 127.5, 39.2, 128.6, 38, 129.4, 36,
  129.1, 35.1, 127.7, 34.7, 126.4, 34.8, 126.7, 36, 126.6, 37.5, 125.7, 38, 125.1, 38.6, 125.4,
  38.7, 125, 39.5, 124.4, 39.9, 121.6, 38.9, 121.2, 39.8, 122.2, 40.7, 121.1, 40.9, 119.6, 39.9,
  117.7, 39, 117.8, 38.4, 119.2, 37.8, 119.9, 37.2, 120.8, 37.8, 121.4, 37.6, 122.7, 37.4, 122.4,
  36.9, 120.4, 36.1, 119.5, 35.4, 119.5, 34.8, 120.4, 33.6, 121.9, 32, 122, 31, 121, 30.6, 121.9,
  29.9, 121.6, 28.6, 120.9, 28, 120.2, 26.6, 119.7, 26, 118.7, 24.9, 118.1, 24.5, 116.7, 23.4,
  115.4, 22.8, 114.2, 22.3, 113.6, 22.2, 112, 21.7, 110.4, 21.2, 110.2, 20.3, 109.7, 21.4, 109.1,
  21.5, 108.4, 21.6, 106.8, 20.9, 105.9, 19.8, 105.8, 18.7, 106.6, 17.5, 107.6, 16.5, 108.3, 16.1,
  108.9, 15.1, 109.3, 13.8, 109.2, 12.3, 109.2, 11.9, 108.1, 10.9, 107.1, 10.4, 106.7, 9.6, 104.8,
  8.6, 105.1, 10, 104.5, 10.4, 104.2, 10.6, 103.5, 10.6, 102.9, 11.6, 102.5, 12.2, 102.1, 12.6,
  100.9, 12.9, 100.6, 13.5, 100, 13.4, 100, 12.6, 99.8, 11.8, 99.2, 10.5, 99.3, 9.2, 100, 8.5,
  100.6, 7.2, 101.3, 6.9, 102.3, 6.2, 103.2, 5.4, 103.4, 3.8, 103.9, 2.4, 104.3, 1.4, 103.8, 1.3,
  103.5, 1.4, 102.3, 2.2, 101.8, 2.5, 101.4, 3, 100.7, 3.9, 100.4, 5.4, 100.4, 6.4, 100.1, 6.6,
  99.5, 7.4, 98.9, 8.1, 98.3, 7.9, 98.3, 8.9, 98.6, 10, 98.6, 12.5, 98.2, 14.1, 97.7, 15.5, 97.6,
  16.5, 96.2, 16.8, 95.4, 15.8, 94.7, 16.8, 94.2, 16.1, 94.3, 17.5, 94.6, 17.6, 94.4, 18.5, 93.7,
  19.5, 92.9, 20.2, 92.3, 20.9, 92, 21.5, 91.8, 22.4, 91, 22.6, 90.3, 21.9, 89.5, 21.8, 88.8, 21.6,
  88.1, 21.7, 87.5, 21.6, 87, 21.5, 86.7, 20.3, 85.9, 19.8, 84.9, 19.3, 83.3, 17.7, 82.3, 17, 81.2,
  16.2, 80.6, 15.6, 80.2, 14.5, 80.3, 13.1, 79.9, 12, 79.9, 10.3, 79.3, 9.3, 78.2, 8.8, 77.6, 8.1,
  77, 8.5, 76.3, 10, 75.8, 11.3, 74.9, 12.9, 74.1, 14.8, 73.8, 15.5, 73.3, 17, 72.8, 19, 72.9, 20.4,
  72.7, 21.2, 72.6, 22.3, 72.2, 21.8, 71, 20.7, 70.4, 20.9, 69.6, 21.7, 69, 22.3, 69.5, 22.8, 70.2,
  23, 68.6, 23.4, 67.5, 23.8, 67, 24.8, 66, 25.4, 64.6, 25.2, 62.3, 25.2, 61.8, 25.1, 60.6, 25.3,
  57.8, 25.7, 56.3, 27.2, 54.9, 26.6, 53.5, 26.9, 52.1, 27.9, 50.9, 29, 50.5, 29.6, 50.2, 30.1,
  48.5, 30, 48, 29.4, 49.7, 27, 50.1, 26.5, 50.8, 24.7, 50.9, 25.6, 51.3, 26.2, 51.6, 25.3, 51.6,
  24.6, 54.4, 24.5, 55.3, 25.3, 56, 25.8, 56.4, 26.4, 56.4, 25.1, 56.8, 24.4, 58.6, 23.6, 59.8,
  22.5, 59, 21, 58.6, 20.4, 57.7, 19.7, 56.5, 18, 54.1, 17, 52.2, 15.8, 49.1, 14.5, 47.5, 13.6, 45,
  12.8, 43.5, 12.7, 43.3, 13.3, 43, 14.8, 42.7, 15.7, 42.6, 16.9, 41.1, 19.2, 40, 20.4, 39.2, 21.5,
  39, 22.8, 38.1, 24.1, 36.5, 26.3, 35.7, 27.4, 34.9, 28.1, 35, 29.5, 34.7, 29, 34.3, 27.9, 33.6,
  28.6, 32.6, 30, 32.3, 30.6, 32.3, 31.3, 33.8, 31.1, 34.5, 31.5, 34.8, 32.1, 35, 32.8, 35.2, 33.3,
  35.5, 33.9, 35.9, 34.5, 35.8, 35.5, 36.2, 36.6, 35.6, 36.6, 34.6, 36.8, 33.9, 36.4, 32.8, 36.1,
  32, 36.6, 30.7, 36.9, 30.6, 36.5, 29.7, 36.2, 29.1, 36.7, 28.3, 36.9, 27.4, 37.1, 27.3, 37.9, 27,
  38.5, 26.3, 38.3, 26.7, 39.3, 26.2, 39.5, 26.4, 40.2, 26.2, 40.1, 26.1, 40.7, 25.9, 40.9, 24.4,
  41, 23.8, 40.4, 23.6, 40, 23, 40.6, 22.6, 40.3, 23, 39.4, 22.9, 38.8, 23.6, 38.2, 23.7, 38, 24,
  37.7, 23, 37.9, 22.8, 37.6, 23.1, 36.9, 23.2, 36.5, 22.6, 36.8, 22.5, 36.4, 22.1, 37, 21.7, 36.8,
  21.7, 36.9, 21.5, 37.6, 21.8, 38.3, 21.1, 38.4, 20.8, 39, 20.3, 39.5, 20, 39.9, 19.5, 40.5, 19.5,
  41.3, 19.1, 42.1, 18.1, 42.7, 16.5, 43.5, 15.9, 43.8, 15.2, 44.1, 14.9, 45, 14.5, 45.4, 13.9,
  44.9, 13.8, 45.7, 12.4, 45.5, 12.3, 45.2, 12.6, 45, 12.3, 44.5, 12.6, 44.1, 13.6, 43.6, 14.2,
  42.5, 15, 42, 16.2, 41.9, 15.9, 41.6, 16.3, 41.3, 16.9, 41.1, 18, 40.7, 18.5, 40.2, 18.4, 39.8,
  18, 40.1, 17.2, 40.5, 16.6, 39.8, 17.2, 39.1, 16.6, 38.7, 16.1, 38, 15.7, 38.1, 15.7, 38.3, 15.9,
  38.7, 16.1, 39.4, 15.7, 40, 14.8, 40.7, 14.6, 40.6, 14.3, 40.9, 13.6, 41.2, 12.6, 41.5, 12.3,
  41.8, 11.8, 42.1, 11.2, 42.5, 10.5, 43, 10.3, 43.6, 10.3, 43.9, 9.9, 44.1, 9, 44.4, 8.5, 44.3,
  7.8, 43.8, 7.3, 43.7, 7, 43.6, 6.7, 43.3, 6, 43.1, 5.4, 43.3, 4.6, 43.4, 3.7, 43.4, 3.1, 43.2,
  3.1, 42.7, 3.3, 42.3, 2.2, 41.4, 1.3, 41.1, 0.9, 40.7, 0, 40, -0.4, 39.5, 0.1, 38.9, 0.2, 38.8,
  -0.5, 38.4, -0.7, 38, -1, 37.6, -1.6, 37.4, -2.2, 36.7, -2.5, 36.9, -3.5, 36.8, -4.4, 36.7, -4.9,
  36.5, -5.4, 36.2,
];

const CHUKOTKA: Outline = [
  -180, 69, -179.5, 68.9, -175.5, 67.8, -171.5, 67, -169.7, 66.1, -171, 65.6, -173.2, 64.4, -175,
  64.8, -177.5, 65, -180, 65.5,
];

/** From Port Said west along the Mediterranean, round the Atlantic coast and the Cape, up the
 * Indian Ocean, and back up the Red Sea. */
const AFRICA: Outline = [
  32.3, 31.3, 30, 31.5, 29.9, 31.2, 27.2, 31.4, 25.2, 31.6, 24, 32.1, 22.6, 32.8, 21.5, 32.9, 20,
  32.1, 19.6, 30.6, 18.5, 30.3, 16.6, 31.2, 15.1, 32.4, 13.2, 32.9, 11.5, 33.1, 10.9, 33.7, 10.1,
  33.9, 10.8, 34.8, 11.1, 35.5, 10.6, 35.9, 11, 37, 10.2, 36.8, 9.9, 37.3, 7.8, 36.9, 5.7, 36.8,
  5.1, 36.8, 3.1, 36.8, 1.3, 36.5, -0.6, 35.7, -2, 35.1, -3.9, 35.2, -5.3, 35.9, -5.9, 35.8, -6.2,
  35.2, -6.8, 34, -7.6, 33.6, -8.5, 33.3, -9.3, 32.3, -9.8, 31.5, -9.6, 30.4, -10.2, 29.4, -11.1,
  28.4, -12.9, 27.9, -14.5, 26.1, -16, 23.7, -17.1, 20.8, -16.5, 19.5, -16, 18.1, -16.5, 16, -17.5,
  14.7, -16.8, 13.4, -16.8, 12.4, -15.6, 11.9, -15, 10.9, -13.7, 9.5, -13.2, 8.5, -12.3, 7.3, -10.8,
  6.3, -9, 5, -7.7, 4.4, -6, 4.9, -4, 5.3, -2.1, 4.8, -0.2, 5.6, 1.2, 6.1, 2.4, 6.4, 3.4, 6.4, 4.6,
  6.2, 5.6, 5.3, 6.1, 4.3, 7, 4.4, 8.3, 4.6, 9, 4, 9.7, 4, 9.8, 2.9, 9.8, 1.9, 9.5, 0.4, 8.7, -0.7,
  9.3, -2, 10.5, -3.4, 11.9, -4.8, 12.3, -6, 12.9, -7.5, 13.2, -8.8, 13.4, -10.5, 13.6, -12.3, 12.2,
  -15.2, 11.8, -17.3, 12.4, -18.5, 13.4, -20.5, 14.5, -22.9, 15.2, -26.6, 16.5, -28.6, 17.3, -30.3,
  18.2, -32.4, 18.4, -33.9, 18.5, -34.4, 20, -34.8, 22.1, -34.2, 25.6, -34, 27.9, -33, 29.5, -31.6,
  31, -29.9, 32.1, -28.8, 32.9, -26.8, 32.6, -25.9, 35.4, -23.9, 35.5, -22, 34.8, -19.8, 36.9,
  -17.9, 38.5, -17, 40, -16.2, 40.7, -14.5, 40.5, -13, 40.5, -10.5, 39.7, -8, 39.3, -6.8, 39.1,
  -5.1, 39.7, -4.1, 40.1, -3.2, 40.9, -2.3, 42.5, -0.4, 45.3, 2, 47.5, 4.5, 48.5, 5.4, 49.7, 8,
  50.8, 10, 51.4, 10.4, 51.3, 11.8, 49.2, 11.3, 47.4, 11.2, 45, 10.4, 43.5, 11.4, 43.1, 11.6, 43.3,
  12.4, 42.7, 13, 41.5, 14, 40, 15.3, 39.5, 15.6, 38.6, 17.3, 37.2, 19.6, 37.1, 21, 36.7, 22.2,
  35.5, 24, 34.9, 25.1, 34, 26.7, 33.8, 27.3, 33.1, 28.4, 32.6, 29.5, 32.5, 29.9, 32.3, 30.6,
];

const MADAGASCAR: Outline = [
  49.3, -12, 50.2, -14.4, 50.4, -15.9, 49.7, -16.4, 49.4, -18.2, 48.4, -21.2, 47.8, -22.8, 47, -25,
  45.2, -25.6, 44, -24.9, 43.7, -23.4, 43.3, -21.8, 44.3, -20.3, 44, -18.1, 44.4, -16.2, 46.3,
  -15.7, 47.8, -14.6, 48.3, -13.4,
];

const GREAT_BRITAIN: Outline = [
  -5.7, 50.1, -4.2, 50.4, -3, 50.6, -1.3, 50.8, 0.3, 50.8, 1.4, 51.2, 1.8, 52.5, 1.7, 52.8, 0.3,
  52.9, 0.1, 53.6, -0.4, 54.3, -1.2, 54.6, -1.6, 55.6, -2.1, 56, -2.5, 56.5, -2.1, 57.2, -1.8, 57.6,
  -3, 57.7, -4, 57.9, -3.1, 58.6, -5, 58.6, -5.7, 57.6, -5.5, 56.4, -5.8, 55.3, -5, 54.7, -3.4,
  54.9, -3.4, 54.5, -3, 53.9, -3.1, 53.4, -4.6, 53.3, -4.2, 52.9, -4.1, 52.4, -5.3, 51.8, -4, 51.6,
  -3.1, 51.4, -4.5, 51.1, -5.1, 50.5,
];

const IRELAND: Outline = [
  -6.4, 52.2, -6.1, 53.3, -6, 54.5, -6.2, 55.2, -7.3, 55.3, -8.3, 55.2, -8.6, 54.3, -10, 54.2,
  -10.1, 53.4, -9, 53.2, -9.9, 52.1, -10.4, 51.8, -9.8, 51.5, -8.5, 51.6, -7, 52.1,
];

const ICELAND: Outline = [
  -24.5, 65.5, -22, 66.4, -18, 66.2, -14.5, 66.3, -13.5, 65.2, -15, 64.3, -18.7, 63.4, -22.5, 63.8,
  -24, 64.8,
];

const SVALBARD: Outline = [
  11, 78.6, 16, 76.6, 19, 77.5, 22, 78.2, 27, 79.8, 25, 80.4, 18, 80.3, 11, 79.8,
];

const NOVAYA_ZEMLYA: Outline = [
  52, 71.4, 55, 70.6, 57.5, 70.7, 56.5, 72.5, 59.5, 74.8, 64, 76.2, 69, 76.9, 66, 77, 60, 76, 56,
  74.5, 54.5, 73, 52.5, 71.8,
];

const SEVERNAYA_ZEMLYA: Outline = [96, 79, 100, 78.2, 105, 78.7, 104.5, 79.9, 97, 81, 92, 80.5];

const NEW_SIBERIA: Outline = [136, 75.5, 141, 74, 150, 75, 148, 76, 140, 76];

const SICILY: Outline = [
  12.4, 37.8, 13.3, 38.2, 15.6, 38.3, 15.1, 37.1, 15.1, 36.7, 14.3, 37, 12.6, 37.6,
];

const SARDINIA_CORSICA: Outline = [
  8.4, 39.1, 9.5, 39.1, 9.8, 40.5, 9.6, 41.2, 9.6, 42, 9.5, 43, 9.3, 42.7, 8.6, 42.5, 8.6, 41.9,
  8.9, 41.4, 8.2, 41, 8.4, 40.6, 8.5, 39.9,
];

const CRETE: Outline = [
  23.5, 35.3, 24.2, 35.6, 25.1, 35.4, 26.3, 35.3, 26.1, 35, 24.7, 35, 23.6, 35.2,
];

const CYPRUS: Outline = [32.3, 35, 33, 35.4, 34.6, 35.7, 34, 35, 33, 34.6, 32.3, 34.8];

const SRI_LANKA: Outline = [
  79.9, 9.8, 81.2, 8.6, 81.9, 7.2, 81.6, 6.4, 80.6, 5.9, 80, 6.4, 79.8, 7.5, 79.8, 8.6,
];

/** Honshu, Shikoku and Kyushu in one. */
const JAPAN: Outline = [
  140.9, 41.5, 141.5, 41.4, 141.5, 40.5, 142, 39.5, 141.3, 38.4, 141, 37.8, 140.9, 36.9, 140.8,
  35.7, 139.9, 34.9, 139.1, 35.2, 138.8, 34.6, 138.2, 34.6, 137, 34.6, 136.9, 34.3, 135.8, 33.4,
  135.1, 34.2, 134.6, 34.1, 134.2, 33.2, 133, 32.7, 132, 33.3, 131.9, 33.2, 131.4, 31.4, 130.7, 31,
  130.2, 31.4, 130.2, 32.1, 129.8, 32.7, 129.7, 33.4, 130.4, 33.6, 130.9, 34, 131.4, 34.4, 132.7,
  35.4, 133.5, 35.5, 134.6, 35.7, 136, 35.7, 137.3, 37.5, 137.3, 36.8, 138.2, 37.2, 139, 37.9,
  139.8, 38.9, 139.7, 39.9, 139.9, 40.6, 140.3, 41.2,
];

const HOKKAIDO: Outline = [
  141.7, 45.4, 142.5, 44.6, 144.3, 44, 145.3, 44.3, 145.6, 43.4, 144.4, 43, 143.3, 41.9, 141.6,
  42.6, 141, 42.3, 140.7, 41.8, 140.2, 41.4, 139.9, 42.2, 140.5, 43.4, 141.3, 43.3, 141.7, 44,
];

const SAKHALIN: Outline = [
  142, 46, 143.4, 46.2, 142.9, 47.5, 143.5, 48.5, 144.7, 48.7, 143.2, 50, 143.2, 52, 143.3, 53.5,
  142.7, 54.4, 142.3, 53.5, 141.8, 52, 142.1, 50, 142, 48, 141.9, 47,
];

const TAIWAN: Outline = [
  121.6, 25.3, 121.9, 24.9, 121.6, 24, 121.4, 23, 120.9, 21.9, 120.3, 22.6, 120.1, 23.4, 120.3,
  24.3, 121, 25.1,
];

const HAINAN: Outline = [
  108.7, 19.3, 110.1, 20.1, 111, 19.7, 110.5, 18.7, 109.5, 18.2, 108.6, 18.5,
];

const LUZON: Outline = [
  120.6, 18.5, 121.6, 18.4, 122.2, 18.5, 122.2, 17.1, 121.6, 15.8, 121.6, 14.7, 122.6, 14.2, 124.2,
  13, 124, 12.6, 123.3, 13, 122.5, 13.6, 121.5, 13.8, 120.6, 14.1, 120.5, 14.8, 120, 15.4, 119.8,
  16.3, 120.4, 16.6, 120.4, 17.6,
];

const VISAYAS: Outline = [
  121.9, 11.9, 123.2, 11.5, 124.3, 12.5, 125.6, 11.5, 125.2, 10.1, 124.1, 9.6, 123.2, 9.1, 122.4,
  9.9, 122, 10.7,
];

const MINDANAO: Outline = [
  122.1, 6.9, 122.9, 7.6, 123.4, 8.6, 124.6, 8.5, 125.5, 9.8, 126.3, 8.5, 126.6, 7.3, 126.2, 6.3,
  125.6, 7, 125.4, 5.6, 125.2, 6.1, 124.2, 6.4, 124, 7.2, 123, 7.5,
];

const BORNEO: Outline = [
  109.7, 2.1, 110.4, 1.6, 111.3, 2.3, 113, 3.2, 114, 4.4, 115, 5, 115.9, 5.6, 116.1, 6, 116.9, 7,
  117.7, 6.4, 118.1, 5.8, 119.3, 5.1, 118.6, 4.5, 117.9, 4.2, 117.7, 3.2, 117.5, 2.2, 119, 1, 117.5,
  0, 117.2, -0.5, 116.9, -1.3, 116.5, -2.5, 116.2, -3.8, 114.6, -4.2, 114.5, -3.4, 113, -3.3, 111.7,
  -2.8, 110.3, -2.9, 110, -1.9, 109.3, -0.1, 108.9, 1,
];

const SUMATRA: Outline = [
  95.3, 5.6, 97.5, 5.2, 98.7, 3.8, 99.8, 3, 101.4, 1.7, 102.5, 0.9, 103.4, 0.5, 103.5, -0.8, 104.5,
  -1.8, 104.9, -2.3, 105.8, -3, 106, -4, 105.9, -5.9, 104.6, -5.9, 103, -4.6, 102.3, -3.8, 101.1,
  -2.6, 100.4, -1, 99.4, 0.2, 98.8, 1.7, 97.8, 2.3, 96.1, 4.1, 95.5, 5,
];

const JAVA: Outline = [
  105.2, -6.8, 105.9, -6, 106.8, -6.1, 108.3, -6.3, 108.6, -6.7, 110.4, -7, 110.7, -6.6, 111.3,
  -6.7, 112.7, -7.2, 114, -7.7, 114.4, -8.2, 114.6, -8.8, 113, -8.4, 111, -8.2, 110.3, -8.1, 108.7,
  -7.7, 106.5, -7.4, 105.5, -6.9,
];

const SULAWESI: Outline = [
  119.4, -5.4, 119.6, -4, 119, -3.5, 118.9, -2.7, 119.4, -1, 119.8, 0.4, 120.5, 0.6, 121.5, 1.1,
  122.8, 0.9, 124, 1, 124.8, 1.5, 125.2, 1.4, 124.4, 0.4, 123.1, 0.5, 121.5, 0.4, 120.3, 0.3, 120.8,
  -1.4, 122, -0.8, 123.4, -1, 122.3, -1.5, 121.3, -1.9, 122, -3, 122.5, -4, 122.8, -4.6, 121.6,
  -4.8, 121.6, -4.1, 120.9, -3.4, 120.2, -3, 120.4, -4.5, 120.4, -5.6,
];

const NEW_GUINEA: Outline = [
  131.3, -0.9, 132.4, -0.4, 134.1, -0.9, 135, -3.3, 135.5, -3.4, 136.5, -2.2, 137.8, -1.6, 138.8,
  -1.9, 140.7, -2.6, 141.3, -2.7, 142.4, -3.1, 143.6, -3.6, 144.5, -4, 145.8, -5.2, 146.5, -5.9,
  147.9, -6.6, 147, -6.8, 147.5, -7.5, 148.4, -8.7, 149.6, -9.6, 150.9, -10.2, 150.4, -10.3, 148.5,
  -10.2, 147.2, -9.5, 145.8, -8, 144.2, -7.7, 143.4, -8.6, 143.2, -9.1, 141, -9.1, 140.4, -8.5,
  138.9, -8.2, 138.4, -7, 137.7, -5.4, 136.9, -4.8, 135.2, -4.4, 133.7, -3.7, 132.8, -4.1, 132.3,
  -2.9, 133.4, -2.5, 131.9, -1.5,
];

/** From Cape York down the east coast, west along the south, and back round the north. */
const AUSTRALIA: Outline = [
  142.5, -10.7, 143.5, -12.5, 143.8, -14.5, 145.3, -15.5, 145.8, -16.9, 146.8, -19.3, 149.2, -21.2,
  150.5, -23.4, 152.4, -24.9, 153.2, -25.2, 153.2, -27.5, 153.5, -28, 153.7, -28.7, 153.2, -30.3,
  152.9, -31.5, 151.8, -33, 151.3, -33.9, 150.9, -34.4, 150.1, -36.2, 149.9, -37.1, 150, -37.5, 148,
  -37.9, 146.4, -39.2, 145, -38.2, 143.5, -38.9, 141.6, -38.4, 140, -37.5, 139.3, -35.8, 138.5,
  -34.9, 138, -34, 137.8, -32.5, 136.5, -33.8, 135.9, -34.8, 134.3, -33, 133.7, -32.2, 131, -31.5,
  128.9, -31.7, 126, -32.3, 121.9, -33.9, 117.9, -35, 115.2, -34.4, 115.7, -33.3, 115.8, -32, 114.6,
  -28.8, 113.6, -26, 113.7, -24.9, 114.2, -21.8, 115.1, -21.7, 118.6, -20.3, 122.2, -18, 123.6,
  -17.3, 125, -14.5, 127, -14, 128.1, -15.5, 129.5, -14.9, 130.9, -12.5, 132, -11.3, 137, -12.4,
  135.5, -14, 135.9, -15.5, 136.3, -16.1, 139, -17.5, 140.9, -17.5, 141.5, -15.5, 141.6, -13, 141.9,
  -12.7,
];

const TASMANIA: Outline = [
  144.7, -40.7, 148.3, -40.9, 148.3, -42.2, 147.4, -43.2, 146.8, -43.6, 145.9, -43.4, 145.2, -42.2,
  144.7, -41.2,
];

const NEW_ZEALAND_NORTH: Outline = [
  172.7, -34.4, 173.8, -35, 174.6, -35.8, 174.8, -36.9, 175.5, -36.4, 175.9, -37.5, 177, -37.9,
  178.5, -37.7, 178, -38.7, 177.9, -39.1, 176.9, -39.5, 176.6, -40.3, 176, -41, 175.2, -41.6, 174.8,
  -41.3, 175, -40, 173.8, -39.3, 174.8, -38.1, 174.5, -37, 174.2, -36.4, 173.1, -35.2,
];

const NEW_ZEALAND_SOUTH: Outline = [
  172.7, -40.5, 173.3, -41.3, 174.2, -40.9, 174.3, -41.7, 173.7, -42.4, 173.1, -43.8, 171.3, -44.4,
  170.6, -45.9, 169.8, -46.5, 168.4, -46.6, 166.6, -46.2, 166.5, -45.3, 167.9, -44.6, 169.1, -43.9,
  170.2, -43.3, 171.2, -42.5, 171.6, -41.8, 172.2, -40.8,
];

const HAWAII: Outline = [-155.9, 20.3, -155.1, 19.9, -154.8, 19.5, -155.6, 18.9, -156.1, 19.7];

/** Down to the pole: the coast from the 180th meridian round to it again, then the pole. */
const ANTARCTICA: Outline = [
  -180, -78, -160, -77.5, -150, -76.5, -140, -75, -130, -74, -120, -73.5, -110, -74.5, -100, -73,
  -90, -72.5, -80, -73, -75, -71, -68, -67, -65, -65, -60, -63.5, -57, -63.3, -58, -64.5, -61, -67,
  -63, -70, -60, -74, -50, -78, -40, -78, -30, -76, -20, -73.5, -10, -71, 0, -70, 10, -70, 20, -70,
  30, -69.5, 40, -69, 50, -67, 60, -67.5, 70, -69, 80, -67, 90, -66.5, 100, -66, 110, -66.3, 120,
  -66.8, 130, -66.2, 140, -66.7, 150, -68.5, 160, -70.5, 170, -71.5, 170, -76, 165, -78, 180, -78,
  180, -90, -180, -90,
];

/** Inland seas, cut out of Eurasia. */
const BLACK_SEA: Outline = [
  27.5, 42.5, 28.6, 44.2, 29.7, 45.2, 30.8, 46.5, 31.8, 46.5, 33.6, 46, 32.6, 45.4, 33.5, 44.6,
  34.2, 44.5, 35.4, 45, 36.5, 45.3, 35.3, 46.4, 36.8, 46.8, 38.9, 47.2, 38.2, 46.3, 37.6, 45.5,
  37.3, 44.9, 37.8, 44.7, 39.7, 43.6, 41.6, 41.6, 39.7, 41, 36.3, 41.3, 35.2, 42, 33, 41.9, 31.5,
  41.3, 29, 41.2, 28, 41.6,
];

const CASPIAN: Outline = [
  47.5, 45.6, 49.5, 46.6, 51.9, 47.1, 53, 46.5, 51.5, 44.5, 52.8, 42.5, 53, 40, 53.9, 37.4, 54,
  36.9, 51.5, 36.8, 49.5, 37.5, 48.9, 38.4, 49.3, 39.5, 49.9, 40.4, 49, 41.6, 47.5, 43, 47.3, 44.5,
];

const LAND = [
  NORTH_AMERICA,
  GREENLAND,
  BAFFIN,
  VICTORIA,
  BANKS,
  ELLESMERE,
  DEVON,
  SOUTHAMPTON,
  NEWFOUNDLAND,
  CUBA,
  HISPANIOLA,
  SOUTH_AMERICA,
  FALKLANDS,
  EURASIA,
  CHUKOTKA,
  AFRICA,
  MADAGASCAR,
  GREAT_BRITAIN,
  IRELAND,
  ICELAND,
  SVALBARD,
  NOVAYA_ZEMLYA,
  SEVERNAYA_ZEMLYA,
  NEW_SIBERIA,
  SICILY,
  SARDINIA_CORSICA,
  CRETE,
  CYPRUS,
  SRI_LANKA,
  JAPAN,
  HOKKAIDO,
  SAKHALIN,
  TAIWAN,
  HAINAN,
  LUZON,
  VISAYAS,
  MINDANAO,
  BORNEO,
  SUMATRA,
  JAVA,
  SULAWESI,
  NEW_GUINEA,
  AUSTRALIA,
  TASMANIA,
  NEW_ZEALAND_NORTH,
  NEW_ZEALAND_SOUTH,
  HAWAII,
  ANTARCTICA,
];
const SEAS = [BLACK_SEA, CASPIAN];

/** An outline and the box around it, to skip the ray cast for points far from it. */
type Shape = { points: Outline; west: number; east: number; south: number; north: number };

const shapeOf = (points: Outline): Shape => {
  const box = { points, west: 180, east: -180, south: 90, north: -90 };
  for (let i = 0; i < points.length; i += 2) {
    box.west = Math.min(box.west, points[i]);
    box.east = Math.max(box.east, points[i]);
    box.south = Math.min(box.south, points[i + 1]);
    box.north = Math.max(box.north, points[i + 1]);
  }
  return box;
};

/** Whether (lon, lat) falls inside `shape`: a ray cast east, counting the edges it crosses. */
function inside(shape: Shape, lon: number, lat: number) {
  if (lon < shape.west || lon > shape.east || lat < shape.south || lat > shape.north) return false;
  const { points } = shape;
  let crossings = false;
  for (let i = 0, j = points.length - 2; i < points.length; j = i, i += 2) {
    const yi = points[i + 1];
    const yj = points[j + 1];
    if (yi > lat === yj > lat) continue;
    const x = points[i] + ((lat - yi) / (yj - yi)) * (points[j] - points[i]);
    if (lon < x) crossings = !crossings;
  }
  return crossings;
}

/** The land dots: each one's direction from the centre of the globe, and its longitude. */
export type Dots = {
  count: number;
  /** Unit vectors, x toward 0° E on the equator, y toward 90° E, z toward the North Pole. */
  x: Float32Array;
  y: Float32Array;
  z: Float32Array;
  /** Longitude in degrees, −180 to 180, to tell which time zone a dot is in. */
  lon: Float32Array;
};

/** Points on the whole sphere; about three in ten fall on land. */
const SPIRAL = 12000;
/** The golden angle, as a share of a turn: each point of the spiral turns this much further. */
const GOLDEN = (3 - Math.sqrt(5)) / 2;

let cache: Dots | null = null;

/** The land dots, worked out the first time they are needed. */
export function landDots(): Dots {
  if (cache) return cache;
  const land = LAND.map(shapeOf);
  const seas = SEAS.map(shapeOf);
  const found: number[] = [];
  for (let i = 0; i < SPIRAL; i++) {
    const z = 1 - (2 * i + 1) / SPIRAL;
    // The turn is reduced to one revolution first, so the angle stays small and exact.
    const turn = i * GOLDEN;
    const angle = (turn - Math.floor(turn)) * 2 * Math.PI;
    const lat = (Math.asin(z) * 180) / Math.PI;
    const lon = angle > Math.PI ? ((angle - 2 * Math.PI) * 180) / Math.PI : (angle * 180) / Math.PI;
    if (!land.some((shape) => inside(shape, lon, lat))) continue;
    if (seas.some((shape) => inside(shape, lon, lat))) continue;
    const r = Math.sqrt(1 - z * z);
    found.push(r * Math.cos(angle), r * Math.sin(angle), z, lon);
  }
  const count = found.length / 4;
  const dots: Dots = {
    count,
    x: new Float32Array(count),
    y: new Float32Array(count),
    z: new Float32Array(count),
    lon: new Float32Array(count),
  };
  for (let k = 0; k < count; k++) {
    dots.x[k] = found[k * 4];
    dots.y[k] = found[k * 4 + 1];
    dots.z[k] = found[k * 4 + 2];
    dots.lon[k] = found[k * 4 + 3];
  }
  cache = dots;
  return dots;
}
```

### timezone-globe/globe-still.tsx

```tsx
import { memo, useId, type CSSProperties } from 'react';
import { DOT_SIZE, GLOBE_SIZE, dotAlpha, dotScale } from './globe';
import { landDots } from './land';
import {
  bandRing,
  clipRing,
  frameOf,
  graticule,
  project,
  rimArc,
  turn,
  wrap,
  type View,
} from './sphere';
import type { Zone } from './zones';
import styles from './timezone-globe.module.css';

/*
 * The globe before the canvas takes over: the same sphere, graticule, band and land dots, drawn
 * as SVG for one view, on the server. It makes the first frame complete before any script runs,
 * and gives way as soon as the canvas has drawn the same view.
 *
 * - The drawing is in thousandths of the globe's radius, so its numbers are whole. The land dots
 *   are zero-length strokes with round caps, one path per size and brightness.
 * - The cities are HTML, placed with container units (the globe's radius is a share of its
 *   box's smaller side, cqmin), so their dots and labels keep their size in pixels.
 * - Its colours are the canvas's CSS tokens, set by class, so it follows the colour scheme too.
 */

/** Thousandths of the radius. */
const UNIT = 1000;
/** Steps of facing (0 to 1) the land dots are grouped by. */
const STEPS = 8;

const fixed = (value: number) => Math.round(value * UNIT);

/** Drawn once: its view and zone are the first ones, and stay. */
export const GlobeStill = memo(function GlobeStill({
  view,
  zone,
}: {
  view: View;
  zone: Zone | null;
}) {
  const id = useId();
  const f = frameOf(view);

  let grid = '';
  for (const run of graticule(f)) {
    grid += `M${fixed(run.x[0])} ${fixed(-run.y[0])}`;
    for (let i = 1; i < run.x.length; i++) grid += `L${fixed(run.x[i])} ${fixed(-run.y[i])}`;
  }

  let band = '';
  const outline = zone ? clipRing(f, bandRing(zone.offset * 15)) : null;
  if (outline) {
    const { x, y, rim } = outline;
    band = `M${fixed(x[0])} ${fixed(-y[0])}`;
    for (let i = 0; i < x.length; i++) {
      const next = (i + 1) % x.length;
      if (rim[i]) {
        const arc = rimArc(x[i], y[i], x[next], y[next]);
        band += `A${UNIT} ${UNIT} 0 0 ${arc.clockwise ? 1 : 0} ${fixed(x[next])} ${fixed(-y[next])}`;
      } else if (next) band += `L${fixed(x[next])} ${fixed(-y[next])}`;
    }
    band += 'Z';
  }

  // The land dots, grouped by how squarely they face the reader and whether they are in the band.
  const dots = landDots();
  const groups: string[] = new Array(STEPS * 2).fill('');
  for (let k = 0; k < dots.count; k++) {
    const spot = turn(f, dots.x[k], dots.y[k], dots.z[k]);
    if (spot.z < 0) continue;
    const lit = zone && Math.abs(wrap(dots.lon[k] - zone.offset * 15)) <= 7.5 ? 1 : 0;
    const step = Math.min(STEPS - 1, Math.floor(spot.z * STEPS));
    groups[lit * STEPS + step] += `M${fixed(spot.x)} ${fixed(-spot.y)}h0`;
  }

  const size = GLOBE_SIZE * 100;
  return (
    <div className={styles.still} aria-hidden="true">
      <svg
        className={styles.stillDrawing}
        viewBox={`${-1.3 * UNIT} ${-1.3 * UNIT} ${2.6 * UNIT} ${2.6 * UNIT}`}
        style={{ width: `${size * 2.6}cqmin`, height: `${size * 2.6}cqmin` }}
      >
        <defs>
          <radialGradient id={`${id}glow`} r="0.5">
            <stop offset={0.96 / 1.3} className={styles.stillGlow} />
            <stop offset={1.24 / 1.3} className={styles.stillGlowEnd} />
          </radialGradient>
          <radialGradient
            id={`${id}body`}
            gradientUnits="userSpaceOnUse"
            cx="0"
            cy="0"
            fx={-0.4 * UNIT}
            fy={-0.5 * UNIT}
            r={1.15 * UNIT}
          >
            <stop offset="0" className={styles.stillLit} />
            <stop offset="0.55" className={styles.stillBody} />
            <stop offset="1" className={styles.stillShade} />
          </radialGradient>
        </defs>
        <rect
          x={-1.3 * UNIT}
          y={-1.3 * UNIT}
          width={2.6 * UNIT}
          height={2.6 * UNIT}
          fill={`url(#${id}glow)`}
        />
        <circle r={UNIT} fill={`url(#${id}body)`} />
        <path d={grid} className={styles.stillGrid} fill="none" strokeWidth="4" />
        {band && <path d={band} className={styles.stillBand} strokeWidth="5" />}
        {groups.map((d, index) => {
          if (!d) return null;
          const lit = index >= STEPS ? 1 : 0;
          const facing = ((index % STEPS) + 0.5) / STEPS;
          return (
            <path
              key={index}
              d={d}
              className={lit ? styles.stillLandLit : styles.stillLand}
              strokeOpacity={dotAlpha(facing, lit).toFixed(2)}
              strokeWidth={(2 * UNIT * DOT_SIZE * dotScale(facing)).toFixed(1)}
              strokeLinecap="round"
            />
          );
        })}
        <circle r={UNIT - 3} className={styles.stillRim} fill="none" strokeWidth="5" />
      </svg>
      {zone?.cities.map((city) => {
        const spot = project(f, city.lat, city.lon);
        if (spot.z < 0.3) return null;
        const place = {
          left: `calc(50% + ${spot.x.toFixed(4)} * ${size}cqmin)`,
          top: `calc(50% - ${spot.y.toFixed(4)} * ${size}cqmin)`,
        } as CSSProperties;
        return (
          <span
            key={city.name}
            className={styles.stillCity}
            style={place}
            data-label={city.label ?? 'right'}
          >
            <span>{city.name}</span>
          </span>
        );
      })}
    </div>
  );
});
```

### timezone-globe/zones.ts

```tsx
import { vectorOf, wrap } from './sphere';

/*
 * The time zones on offer, west to east. Each one has the IANA name its clock is read from, its
 * standard offset (which places its band on the globe: fifteen degrees of longitude per hour),
 * its abbreviations, two or three cities drawn on the globe, and more places the search knows.
 * Every city listed keeps the same clock as its zone all year round.
 */

export type City = {
  name: string;
  lat: number;
  lon: number;
  /** Where its label sits, beside the dot: right unless it would run into a neighbour's. */
  label?: 'left' | 'right' | 'above' | 'below';
};

export type Zone = {
  id: string;
  /** "Mountain Time". */
  name: string;
  /** The IANA time zone its clock is read from, "America/Denver". */
  timeZone: string;
  /** Hours from UTC outside daylight saving time: its band is centred on offset × 15°. */
  offset: number;
  /** The abbreviation in standard time, and in daylight saving time if it has one. The clock's
   * own abbreviation is used where the browser knows it. */
  abbr: string;
  dst?: string;
  /** Two or three cities, drawn on the globe. */
  cities: readonly City[];
  /** More cities, regions and countries the search finds it by. */
  places?: readonly string[];
};

export const ZONES: readonly Zone[] = [
  {
    id: 'hawaii',
    name: 'Hawaii Time',
    timeZone: 'Pacific/Honolulu',
    offset: -10,
    abbr: 'HST',
    cities: [
      { name: 'Honolulu', lat: 21.31, lon: -157.86, label: 'left' },
      { name: 'Hilo', lat: 19.72, lon: -155.08 },
    ],
    places: ['Hawaii', 'Maui', 'Kauai'],
  },
  {
    id: 'alaska',
    name: 'Alaska Time',
    timeZone: 'America/Anchorage',
    offset: -9,
    abbr: 'AKST',
    dst: 'AKDT',
    cities: [
      { name: 'Anchorage', lat: 61.22, lon: -149.9, label: 'left' },
      { name: 'Fairbanks', lat: 64.84, lon: -147.72 },
      { name: 'Juneau', lat: 58.3, lon: -134.42 },
    ],
    places: ['Alaska', 'Nome'],
  },
  {
    id: 'pacific',
    name: 'Pacific Time',
    timeZone: 'America/Los_Angeles',
    offset: -8,
    abbr: 'PST',
    dst: 'PDT',
    cities: [
      { name: 'Vancouver', lat: 49.28, lon: -123.12 },
      { name: 'San Francisco', lat: 37.77, lon: -122.42, label: 'left' },
      { name: 'Los Angeles', lat: 34.05, lon: -118.24 },
    ],
    places: ['Seattle', 'Portland', 'San Diego', 'Las Vegas', 'Tijuana', 'California', 'Oregon'],
  },
  {
    id: 'mountain',
    name: 'Mountain Time',
    timeZone: 'America/Denver',
    offset: -7,
    abbr: 'MST',
    dst: 'MDT',
    cities: [
      { name: 'Calgary', lat: 51.05, lon: -114.07 },
      { name: 'Salt Lake City', lat: 40.76, lon: -111.89, label: 'left' },
      { name: 'Denver', lat: 39.74, lon: -104.99 },
    ],
    places: ['Edmonton', 'Boise', 'Albuquerque', 'El Paso', 'Colorado', 'Utah', 'Alberta'],
  },
  {
    id: 'central',
    name: 'Central Time',
    timeZone: 'America/Chicago',
    offset: -6,
    abbr: 'CST',
    dst: 'CDT',
    cities: [
      { name: 'Winnipeg', lat: 49.9, lon: -97.14 },
      { name: 'Chicago', lat: 41.88, lon: -87.63 },
      { name: 'Houston', lat: 29.76, lon: -95.37 },
    ],
    places: ['Dallas', 'Austin', 'Minneapolis', 'New Orleans', 'Kansas City', 'Texas', 'Illinois'],
  },
  {
    id: 'eastern',
    name: 'Eastern Time',
    timeZone: 'America/New_York',
    offset: -5,
    abbr: 'EST',
    dst: 'EDT',
    cities: [
      { name: 'Toronto', lat: 43.65, lon: -79.38, label: 'left' },
      { name: 'New York', lat: 40.71, lon: -74.01 },
      { name: 'Miami', lat: 25.76, lon: -80.19 },
    ],
    places: ['Washington', 'Boston', 'Atlanta', 'Philadelphia', 'Detroit', 'Montreal', 'Ottawa'],
  },
  {
    id: 'atlantic',
    name: 'Atlantic Time',
    timeZone: 'America/Halifax',
    offset: -4,
    abbr: 'AST',
    dst: 'ADT',
    cities: [
      { name: 'Halifax', lat: 44.65, lon: -63.57 },
      { name: 'Bermuda', lat: 32.29, lon: -64.78 },
    ],
    places: ['Hamilton', 'Moncton', 'Charlottetown', 'Nova Scotia', 'New Brunswick'],
  },
  {
    id: 'newfoundland',
    name: 'Newfoundland Time',
    timeZone: 'America/St_Johns',
    offset: -3.5,
    abbr: 'NST',
    dst: 'NDT',
    cities: [
      { name: 'Corner Brook', lat: 48.95, lon: -57.95, label: 'left' },
      { name: 'St. John’s', lat: 47.56, lon: -52.71 },
    ],
    places: ['St Johns', 'Gander', 'Labrador'],
  },
  {
    id: 'argentina',
    name: 'Argentina Time',
    timeZone: 'America/Argentina/Buenos_Aires',
    offset: -3,
    abbr: 'ART',
    cities: [
      { name: 'Córdoba', lat: -31.42, lon: -64.18, label: 'above' },
      { name: 'Mendoza', lat: -32.89, lon: -68.83, label: 'left' },
      { name: 'Buenos Aires', lat: -34.6, lon: -58.38 },
    ],
    places: ['Rosario', 'La Plata', 'Ushuaia'],
  },
  {
    id: 'brasilia',
    name: 'Brasília Time',
    timeZone: 'America/Sao_Paulo',
    offset: -3,
    abbr: 'BRT',
    cities: [
      { name: 'Brasília', lat: -15.79, lon: -47.88 },
      { name: 'São Paulo', lat: -23.55, lon: -46.63, label: 'left' },
      { name: 'Rio de Janeiro', lat: -22.91, lon: -43.17 },
    ],
    places: ['Brazil', 'Salvador', 'Belo Horizonte', 'Fortaleza', 'Recife', 'Porto Alegre'],
  },
  {
    id: 'cape-verde',
    name: 'Cape Verde Time',
    timeZone: 'Atlantic/Cape_Verde',
    offset: -1,
    abbr: 'CVT',
    cities: [
      { name: 'Mindelo', lat: 16.89, lon: -24.98, label: 'left' },
      { name: 'Praia', lat: 14.93, lon: -23.51 },
    ],
    places: ['Cabo Verde'],
  },
  {
    id: 'utc',
    name: 'Coordinated Universal Time',
    timeZone: 'UTC',
    offset: 0,
    abbr: 'UTC',
    cities: [
      { name: 'Reykjavík', lat: 64.15, lon: -21.94 },
      { name: 'Dakar', lat: 14.72, lon: -17.47, label: 'left' },
      { name: 'Accra', lat: 5.6, lon: -0.19 },
    ],
    places: ['Universal', 'Zulu', 'Iceland', 'Senegal', 'Ghana'],
  },
  {
    id: 'gmt',
    name: 'Greenwich Mean Time',
    timeZone: 'Europe/London',
    offset: 0,
    abbr: 'GMT',
    dst: 'BST',
    cities: [
      { name: 'Dublin', lat: 53.35, lon: -6.26, label: 'left' },
      { name: 'London', lat: 51.51, lon: -0.13 },
      { name: 'Lisbon', lat: 38.72, lon: -9.14, label: 'left' },
    ],
    places: ['United Kingdom', 'Ireland', 'Portugal', 'Edinburgh', 'Manchester', 'Porto'],
  },
  {
    id: 'cet',
    name: 'Central European Time',
    timeZone: 'Europe/Paris',
    offset: 1,
    abbr: 'CET',
    dst: 'CEST',
    cities: [
      { name: 'Paris', lat: 48.86, lon: 2.35, label: 'left' },
      { name: 'Berlin', lat: 52.52, lon: 13.4 },
      { name: 'Rome', lat: 41.9, lon: 12.5 },
    ],
    places: [
      'Madrid',
      'Amsterdam',
      'Brussels',
      'Vienna',
      'Zurich',
      'Milan',
      'Barcelona',
      'Stockholm',
      'Oslo',
      'Copenhagen',
      'Warsaw',
      'Prague',
      'Budapest',
      'France',
      'Germany',
      'Italy',
      'Spain',
    ],
  },
  {
    id: 'wat',
    name: 'West Africa Time',
    timeZone: 'Africa/Lagos',
    offset: 1,
    abbr: 'WAT',
    cities: [
      { name: 'Lagos', lat: 6.52, lon: 3.38 },
      { name: 'Kinshasa', lat: -4.44, lon: 15.27 },
      { name: 'Luanda', lat: -8.84, lon: 13.23, label: 'left' },
    ],
    places: ['Abuja', 'Douala', 'Nigeria', 'Cameroon', 'Angola'],
  },
  {
    id: 'eet',
    name: 'Eastern European Time',
    timeZone: 'Europe/Athens',
    offset: 2,
    abbr: 'EET',
    dst: 'EEST',
    cities: [
      { name: 'Helsinki', lat: 60.17, lon: 24.94 },
      { name: 'Kyiv', lat: 50.45, lon: 30.52 },
      { name: 'Athens', lat: 37.98, lon: 23.73 },
    ],
    places: ['Bucharest', 'Sofia', 'Riga', 'Vilnius', 'Tallinn', 'Greece', 'Ukraine', 'Finland'],
  },
  {
    id: 'sast',
    name: 'South Africa Time',
    timeZone: 'Africa/Johannesburg',
    offset: 2,
    abbr: 'SAST',
    cities: [
      { name: 'Johannesburg', lat: -26.2, lon: 28.05, label: 'left' },
      { name: 'Durban', lat: -29.86, lon: 31.03 },
      { name: 'Cape Town', lat: -33.92, lon: 18.42, label: 'left' },
    ],
    places: ['Pretoria', 'South Africa'],
  },
  {
    id: 'eat',
    name: 'East Africa Time',
    timeZone: 'Africa/Nairobi',
    offset: 3,
    abbr: 'EAT',
    cities: [
      { name: 'Addis Ababa', lat: 9.03, lon: 38.74 },
      { name: 'Nairobi', lat: -1.29, lon: 36.82, label: 'left' },
      { name: 'Dar es Salaam', lat: -6.79, lon: 39.21 },
    ],
    places: ['Kampala', 'Mogadishu', 'Kenya', 'Ethiopia', 'Tanzania', 'Uganda'],
  },
  {
    id: 'moscow',
    name: 'Moscow Time',
    timeZone: 'Europe/Moscow',
    offset: 3,
    abbr: 'MSK',
    cities: [
      { name: 'Saint Petersburg', lat: 59.93, lon: 30.36, label: 'left' },
      { name: 'Moscow', lat: 55.76, lon: 37.62 },
      { name: 'Kazan', lat: 55.8, lon: 49.11 },
    ],
    places: ['St Petersburg', 'Nizhny Novgorod', 'Russia'],
  },
  {
    id: 'gulf',
    name: 'Gulf Time',
    timeZone: 'Asia/Dubai',
    offset: 4,
    abbr: 'GST',
    cities: [
      { name: 'Dubai', lat: 25.2, lon: 55.27, label: 'left' },
      { name: 'Muscat', lat: 23.59, lon: 58.38 },
    ],
    places: ['Abu Dhabi', 'United Arab Emirates', 'UAE', 'Oman'],
  },
  {
    id: 'pakistan',
    name: 'Pakistan Time',
    timeZone: 'Asia/Karachi',
    offset: 5,
    abbr: 'PKT',
    cities: [
      { name: 'Lahore', lat: 31.55, lon: 74.34 },
      { name: 'Karachi', lat: 24.86, lon: 67.01 },
    ],
    places: ['Islamabad', 'Faisalabad', 'Pakistan'],
  },
  {
    id: 'india',
    name: 'India Time',
    timeZone: 'Asia/Kolkata',
    offset: 5.5,
    abbr: 'IST',
    cities: [
      { name: 'Delhi', lat: 28.61, lon: 77.21 },
      { name: 'Mumbai', lat: 19.08, lon: 72.88, label: 'left' },
      { name: 'Kolkata', lat: 22.57, lon: 88.36 },
    ],
    places: ['New Delhi', 'Bengaluru', 'Bangalore', 'Chennai', 'Hyderabad', 'Pune', 'India'],
  },
  {
    id: 'indochina',
    name: 'Indochina Time',
    timeZone: 'Asia/Bangkok',
    offset: 7,
    abbr: 'ICT',
    cities: [
      { name: 'Hanoi', lat: 21.03, lon: 105.85 },
      { name: 'Bangkok', lat: 13.76, lon: 100.5, label: 'left' },
      { name: 'Ho Chi Minh City', lat: 10.82, lon: 106.63 },
    ],
    places: ['Saigon', 'Phnom Penh', 'Vientiane', 'Thailand', 'Vietnam', 'Cambodia', 'Laos'],
  },
  {
    id: 'china',
    name: 'China Time',
    timeZone: 'Asia/Shanghai',
    offset: 8,
    abbr: 'CST',
    cities: [
      { name: 'Beijing', lat: 39.9, lon: 116.41 },
      { name: 'Shanghai', lat: 31.23, lon: 121.47 },
      { name: 'Hong Kong', lat: 22.32, lon: 114.17, label: 'left' },
    ],
    places: ['Shenzhen', 'Guangzhou', 'Chengdu', 'Taipei', 'China', 'Taiwan'],
  },
  {
    id: 'japan',
    name: 'Japan Time',
    timeZone: 'Asia/Tokyo',
    offset: 9,
    abbr: 'JST',
    cities: [
      { name: 'Sapporo', lat: 43.06, lon: 141.35 },
      { name: 'Osaka', lat: 34.69, lon: 135.5, label: 'left' },
      { name: 'Tokyo', lat: 35.68, lon: 139.69 },
    ],
    places: ['Kyoto', 'Yokohama', 'Nagoya', 'Fukuoka', 'Japan'],
  },
  {
    id: 'australia',
    name: 'Australian Eastern Time',
    timeZone: 'Australia/Sydney',
    offset: 10,
    abbr: 'AEST',
    dst: 'AEDT',
    cities: [
      { name: 'Sydney', lat: -33.87, lon: 151.21 },
      { name: 'Melbourne', lat: -37.81, lon: 144.96, label: 'left' },
      { name: 'Hobart', lat: -42.88, lon: 147.33 },
    ],
    places: ['Canberra', 'Newcastle', 'Australia'],
  },
  {
    id: 'new-zealand',
    name: 'New Zealand Time',
    timeZone: 'Pacific/Auckland',
    offset: 12,
    abbr: 'NZST',
    dst: 'NZDT',
    cities: [
      { name: 'Auckland', lat: -36.85, lon: 174.76 },
      { name: 'Wellington', lat: -41.29, lon: 174.78 },
      { name: 'Christchurch', lat: -43.53, lon: 172.64, label: 'left' },
    ],
    places: ['New Zealand'],
  },
];

/** The view that faces a zone: its band in the middle, tilted toward its cities. */
export function viewOf(zone: Zone) {
  const mean = zone.cities.reduce((sum, city) => sum + city.lat, 0) / (zone.cities.length || 1);
  return { lon: zone.offset * 15, lat: Math.max(-24, Math.min(30, mean * 0.55)) };
}

/**
 * The zone a place falls in, for a click on the globe: one whose band holds its longitude (else
 * the nearest band), and among bands that overlap or zones that share one, the zone with a city
 * nearest the place.
 */
export function zoneAt(zones: readonly Zone[], lat: number, lon: number): Zone | null {
  const [x, y, z] = vectorOf(lat, lon);
  let best: Zone | null = null;
  let bestScore = Infinity;
  for (const zone of zones) {
    const outside = Math.max(0, Math.abs(wrap(lon - zone.offset * 15)) - 7.5);
    let nearest = Math.PI;
    for (const city of zone.cities) {
      const [cx, cy, cz] = vectorOf(city.lat, city.lon);
      nearest = Math.min(nearest, Math.acos(Math.max(-1, Math.min(1, x * cx + y * cy + z * cz))));
    }
    // Degrees outside the band count far more than the distance to a city (radians).
    const score = outside * 100 + nearest;
    if (score < bestScore) {
      best = zone;
      bestScore = score;
    }
  }
  return best;
}
```

### timezone-globe/clock.ts

```tsx
import type { Zone } from './zones';

/*
 * The zones' clocks, read with Intl.DateTimeFormat from their IANA names: the local time, the
 * offset from UTC right now (daylight saving time included), and the abbreviation. The browser
 * names the zones it knows ("MDT"); for the others it only gives an offset ("GMT+9"), and the
 * zone's own abbreviation stands in.
 */

/** A zone's clock at one moment. */
export type Reading = {
  /** "5:01 PM", in the reader's language. */
  time: string;
  /** "MDT". */
  abbr: string;
  /** Minutes from UTC, now. */
  offset: number;
};

const formats = new Map<string, Intl.DateTimeFormat>();

/** One formatter per zone and use, made once: they are slow to make and quick to use. */
function formatOf(key: string, make: () => Intl.DateTimeFormat) {
  let format = formats.get(key);
  if (!format) {
    format = make();
    formats.set(key, format);
  }
  return format;
}

/** Minutes from UTC in `timeZone` at `now`: its wall clock, read as if it were UTC, minus now. */
function offsetAt(timeZone: string, now: number) {
  const format = formatOf(
    `parts|${timeZone}`,
    () =>
      new Intl.DateTimeFormat('en-US', {
        timeZone,
        hourCycle: 'h23',
        year: 'numeric',
        month: 'numeric',
        day: 'numeric',
        hour: 'numeric',
        minute: 'numeric',
      }),
  );
  const part: Record<string, number> = {};
  for (const { type, value } of format.formatToParts(now)) part[type] = Number(value);
  const wall = Date.UTC(part.year, part.month - 1, part.day, part.hour % 24, part.minute);
  return Math.round((wall - Math.floor(now / 60000) * 60000) / 60000);
}

/** Its clock at `now`; `locale` sets how the time reads. */
export function readClock(zone: Zone, now: number, locale: string): Reading {
  const time = formatOf(
    `time|${locale}|${zone.timeZone}`,
    () =>
      new Intl.DateTimeFormat(locale, {
        timeZone: zone.timeZone,
        hour: 'numeric',
        minute: '2-digit',
      }),
  ).format(now);
  const offset = offsetAt(zone.timeZone, now);
  const named = formatOf(
    `name|${zone.timeZone}`,
    () => new Intl.DateTimeFormat('en-US', { timeZone: zone.timeZone, timeZoneName: 'short' }),
  )
    .formatToParts(now)
    .find((part) => part.type === 'timeZoneName')?.value;
  // "GMT+9" is only an offset: the zone's own abbreviation reads better.
  const daylight = offset > zone.offset * 60;
  const abbr =
    named && !/^(GMT|UTC)[+\-\u2212]/.test(named)
      ? named
      : daylight && zone.dst
        ? zone.dst
        : zone.abbr;
  return { time, abbr, offset };
}

/** "UTC−6", "UTC+5:30", "UTC". */
export function offsetLabel(minutes: number) {
  if (!minutes) return 'UTC';
  const hours = Math.floor(Math.abs(minutes) / 60);
  const rest = Math.abs(minutes) % 60;
  return `UTC${minutes < 0 ? '\u2212' : '+'}${hours}${rest ? `:${String(rest).padStart(2, '0')}` : ''}`;
}

/**
 * The listed zone nearest the reader's: the same IANA name if listed, else the same offset now
 * (one in the same region first, "Europe/…"), else the nearest offset.
 */
export function homeZone(zones: readonly Zone[], now: number): Zone | null {
  let timeZone: string;
  try {
    timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  } catch {
    return null;
  }
  const exact = zones.find((zone) => zone.timeZone === timeZone);
  if (exact) return exact;
  const mine = -new Date(now).getTimezoneOffset();
  const region = timeZone.split('/')[0];
  let best: Zone | null = null;
  let bestScore = Infinity;
  for (const zone of zones) {
    const score =
      Math.abs(offsetAt(zone.timeZone, now) - mine) * 2 +
      (zone.timeZone.split('/')[0] === region ? 0 : 1);
    if (score < bestScore) {
      best = zone;
      bestScore = score;
    }
  }
  return best;
}
```

### timezone-globe/search.ts

```tsx
import type { Reading } from './clock';
import type { Zone } from './zones';

/*
 * The search: a zone matches when the words typed start a word of its name, of one of its
 * abbreviations, cities, places or IANA name, or when they read as its offset ("+5:30",
 * "UTC-6"). Accents and case are ignored. When a city or place matched rather than the zone's
 * own name, the match says which, so the list can show it ("Japan Time · Tokyo"). The list keeps
 * its order, west to east; `bestOf` picks the match to point at as the reader types.
 */

export type Match = {
  zone: Zone;
  /** The city or place that matched, when the zone's name did not. */
  place?: string;
};

/** Lower case, without accents or punctuation: "São Paulo" and "sao paulo" match. */
const fold = (text: string) =>
  text
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9+:\u2212-]+/g, ' ')
    .trim();

/** Whether every word of `query` starts a word of `text` (both folded), in any order. */
function startsWords(text: string, query: string) {
  const words = text.split(' ');
  return query.split(' ').every((part) => words.some((word) => word.startsWith(part)));
}

/** An offset typed so far: its sign, its hours, and the digits of its minutes. */
type Typed = { sign: number; hours: number; minutes: string };

/** An offset typed so far, as "+5:30", "-6", "utc+1", "gmt −3", or null. */
function offsetOf(query: string): Typed | null {
  const found = /^(?:utc|gmt)?\s*([+\-\u2212])\s*(\d{1,2})(?::(\d{0,2}))?$/.exec(query);
  if (!found) return null;
  return { sign: found[1] === '+' ? 1 : -1, hours: Number(found[2]), minutes: found[3] ?? '' };
}

/** Whether `minutes` from UTC read as the offset typed so far: "+5" finds +5:00 and +5:30. */
function reads(typed: Typed, minutes: number) {
  const hours = Math.floor(Math.abs(minutes) / 60);
  const rest = String(Math.abs(minutes) % 60).padStart(2, '0');
  const sign = minutes < 0 ? -1 : 1;
  return (
    (sign === typed.sign || !minutes) && hours === typed.hours && rest.startsWith(typed.minutes)
  );
}

export function search(
  zones: readonly Zone[],
  query: string,
  readings: Map<string, Reading> | null,
): Match[] {
  const folded = fold(query);
  if (!folded) return zones.map((zone) => ({ zone }));
  const offset = offsetOf(folded);
  const matches: Match[] = [];
  for (const zone of zones) {
    const reading = readings?.get(zone.id);
    if (offset) {
      if (reads(offset, zone.offset * 60) || (reading && reads(offset, reading.offset))) {
        matches.push({ zone });
      }
      continue;
    }
    const own = [zone.name, zone.abbr, zone.dst ?? '', reading?.abbr ?? ''];
    if (own.some((text) => startsWords(fold(text), folded))) {
      matches.push({ zone });
      continue;
    }
    // Its cities and places, then its IANA name ("Pacific/Honolulu"), shown as what matched.
    const place = [
      ...zone.cities.map((city) => city.name),
      ...(zone.places ?? []),
      zone.timeZone,
    ].find((name) => startsWords(fold(name.replace(/[/_]/g, ' ')), folded));
    if (place) matches.push({ zone, place });
  }
  return matches;
}

/**
 * The match to point at as the reader types: the first whose name or place starts with what was
 * typed, else the first match. None for a single letter, which matches too much to mean one.
 */
export function bestOf(matches: readonly Match[], query: string): Match | null {
  const folded = fold(query);
  if (folded.length < 2) return null;
  return (
    matches.find(({ zone, place }) => fold(place ?? zone.name).startsWith(folded)) ??
    matches[0] ??
    null
  );
}
```

### timezone-globe/list-scroll.ts

```tsx
/*
 * The list's scrolling, by hand. The list is not a scroll container but a box with overflow:
 * hidden. Safari keeps a scroll container's content in GPU memory as one large texture (and,
 * inside a scaled-down copy of the page, such as a gallery's thumbnail, it also gives layers to
 * everything round it, at full size): here that was most of the component's memory. A box with
 * overflow: hidden costs nothing more than the page, and still scrolls from script:
 *
 * - the wheel and the trackpad move it, and at an end leave the page to scroll;
 * - a finger drags it, and a flick carries on and slows down, as a native list does;
 * - the keyboard, the search and the demo scroll it to the rows they show (scrollTo);
 * - a thin bar shows where it is while it moves.
 */

/** A flick slows down with this time constant (ms), about as a native list does. */
const GLIDE = 325;
/** A finger that moves less than this (px) taps rather than drags. */
const TAP_SLOP = 6;
/** The bar fades this long (ms) after the list stops. */
const BAR_LINGER = 700;

export function createListScroll(list: HTMLElement, bar: HTMLElement) {
  let drag: { id: number; y: number; scroll: number; time: number; speed: number } | null = null;
  /** The last gesture dragged the list: the click it ends with is not a tap on a row. */
  let dragged = false;
  let speed = 0;
  let frame = 0;
  let last = 0;
  let linger = 0;

  const max = () => list.scrollHeight - list.clientHeight;

  /** The bar: as tall as the share of the list in view, where the view is. */
  const placeBar = () => {
    const room = max();
    if (room <= 0) return;
    const height = Math.max(24, (list.clientHeight * list.clientHeight) / list.scrollHeight);
    const top = (list.scrollTop / room) * (list.clientHeight - height);
    bar.style.height = `${height}px`;
    bar.style.transform = `translateY(${top}px)`;
    bar.dataset.shown = '';
    window.clearTimeout(linger);
    linger = window.setTimeout(() => delete bar.dataset.shown, BAR_LINGER);
  };

  const stopGlide = () => {
    cancelAnimationFrame(frame);
    frame = 0;
    last = 0;
    speed = 0;
  };

  const glide = (now: number) => {
    const dt = last ? Math.min(64, now - last) : 16;
    last = now;
    const before = list.scrollTop;
    list.scrollTop = before + speed * dt;
    speed *= Math.exp(-dt / GLIDE);
    // Stopped by an end, or slow enough to stop.
    if (Math.abs(list.scrollTop - before) < 0.1 || Math.abs(speed) < 0.02) {
      frame = 0;
      last = 0;
      return;
    }
    frame = requestAnimationFrame(glide);
  };

  const onWheel = (event: WheelEvent) => {
    const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? list.clientHeight : 1;
    const next = Math.max(0, Math.min(max(), list.scrollTop + event.deltaY * unit));
    // At an end, the page scrolls instead.
    if (Math.abs(next - list.scrollTop) < 0.5) return;
    event.preventDefault();
    stopGlide();
    list.scrollTop = next;
  };

  const onPointerDown = (event: PointerEvent) => {
    dragged = false;
    if (event.pointerType !== 'touch') return;
    stopGlide();
    drag = {
      id: event.pointerId,
      y: event.clientY,
      scroll: list.scrollTop,
      time: event.timeStamp,
      speed: 0,
    };
  };

  const onPointerMove = (event: PointerEvent) => {
    if (!drag || event.pointerId !== drag.id) return;
    // Screen pixels to the list's own, should it sit in a scaled container.
    const scale = list.getBoundingClientRect().height / list.offsetHeight || 1;
    const moved = (event.clientY - drag.y) / scale;
    if (!dragged && Math.abs(moved) < TAP_SLOP) return;
    dragged = true;
    const before = list.scrollTop;
    list.scrollTop = drag.scroll - moved;
    const dt = Math.max(1, event.timeStamp - drag.time);
    drag.speed = drag.speed * 0.4 + ((list.scrollTop - before) / dt) * 0.6;
    drag.time = event.timeStamp;
  };

  const onPointerUp = (event: PointerEvent) => {
    if (!drag || event.pointerId !== drag.id) return;
    const ended = drag;
    drag = null;
    // A finger that paused before letting go does not flick.
    if (!dragged || event.timeStamp - ended.time > 80) return;
    speed = ended.speed;
    if (Math.abs(speed) > 0.05) frame = requestAnimationFrame(glide);
  };

  const onPointerCancel = () => {
    drag = null;
  };

  /** A drag ends with a click on the row under the finger: it is not a choice. */
  const onClick = (event: MouseEvent) => {
    if (!dragged) return;
    dragged = false;
    event.preventDefault();
    event.stopPropagation();
  };

  list.addEventListener('wheel', onWheel, { passive: false });
  list.addEventListener('pointerdown', onPointerDown);
  list.addEventListener('pointermove', onPointerMove);
  list.addEventListener('pointerup', onPointerUp);
  list.addEventListener('pointercancel', onPointerCancel);
  list.addEventListener('click', onClick, true);
  list.addEventListener('scroll', placeBar);

  return () => {
    stopGlide();
    window.clearTimeout(linger);
    list.removeEventListener('wheel', onWheel);
    list.removeEventListener('pointerdown', onPointerDown);
    list.removeEventListener('pointermove', onPointerMove);
    list.removeEventListener('pointerup', onPointerUp);
    list.removeEventListener('pointercancel', onPointerCancel);
    list.removeEventListener('click', onClick, true);
    list.removeEventListener('scroll', placeBar);
  };
}
```

### timezone-globe/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in
 * progress at once: its promise rejects, which ends the script right there, and the pointer
 * fades out.
 *
 * The pointer moves on animation frames, by script, rather than with CSS transitions or the Web
 * Animations API: an animated element gets a GPU layer of its own, and in a scaled-down copy of
 * the page (a gallery's thumbnail) Safari then gives full-size layers to everything round it.
 */

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

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

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

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

/** Slow, fast, slow, as a hand moves. */
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

/** Runs `frame` with the share of `ms` gone (0 to 1) on each animation frame; resolves at 1.
 * The function it returns cancels it. */
function tween(ms: number, frame: (t: number) => void) {
  let id = 0;
  let cancel = () => cancelAnimationFrame(id);
  const done = new Promise<void>((resolve, reject) => {
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, Math.max(0, (now - start) / ms));
      frame(t);
      if (t < 1) id = requestAnimationFrame(tick);
      else resolve();
    };
    id = requestAnimationFrame(tick);
    cancel = () => {
      cancelAnimationFrame(id);
      reject(new Stopped());
    };
  });
  return { done, cancel: () => cancel() };
}

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  const tweens = new Set<() => void>();
  const arrow = pointer.querySelector('path');
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  const place = ({ x, y }: Point) => {
    pointer.style.transform = `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
  };

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

  /** A tween the demo waits on, cancelled if it stops. */
  const play = (ms: number, frame: (t: number) => void) => {
    const run = tween(ms, frame);
    tweens.add(run.cancel);
    return step(run.done.finally(() => tweens.delete(run.cancel)));
  };

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

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    return play(Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)), (t) => {
      const e = easeInOut(t);
      const u = 1 - e;
      place({
        x: u * u * from.x + 2 * u * e * bend.x + e * e * to.x,
        y: u * u * from.y + 2 * u * e * bend.y + e * e * to.y,
      });
    });
  };

  const press: Demo['press'] = async (action) => {
    // Down to 80% in a third of the tap, and back.
    const tap = play(280, (t) => {
      const depth = t < 0.35 ? t / 0.35 : (1 - t) / 0.65;
      if (arrow) arrow.style.transform = `scale(${1 - 0.2 * easeInOut(depth)})`;
    });
    await wait(95);
    action();
    await tap;
  };

  /** Fades the pointer in or out (the fade out outlives the demo). */
  const fade = (to: number) => {
    const from = Number(pointer.style.opacity || 0);
    return tween(250, (t) => {
      pointer.style.opacity = String(from + (to - from) * t);
    });
  };

  place(at);
  const fadeIn = fade(1);
  fadeIn.done.catch(() => {});
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    tweens.forEach((cancel) => cancel());
    pending.forEach((cancel) => cancel());
    fadeIn.cancel();
    if (arrow) arrow.style.transform = '';
    fade(0).done.catch(() => {});
  };
}

const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  fill: '#1c1c1e',
  stroke: '#fff',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25))',
};

/** The arrow the demo moves around. It is decorative. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### timezone-globe/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.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

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

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

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