# Dot Map

> A hero over a world map in fine dots, framed on the places you list. Each one shows its local time, and a ring tours them, lighting the land it crosses. A plain list without the script.

Backgrounds & textures · background, canvas, map, world, locations, time zones, hero · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-26
Demo: https://kodza.dev/components/dot-map/
Source on GitHub: https://github.com/kodza-dev/kodza/tree/main/components/dot-map

## Files

The code below, served as files: byte for byte what the Kodza site copies (the CSS includes its theme guard).

- [`kodza-dot-map.css`](https://kodza.dev/registry/dot-map/kodza-dot-map.css) · 17.1 KB
- [`kodza-dot-map.js`](https://kodza.dev/registry/dot-map/kodza-dot-map.js) · 36.2 KB
- [`kodza-dot-map.html`](https://kodza.dev/registry/dot-map/kodza-dot-map.html) · 1.2 KB · the markup, to adapt and paste into your page

**Can you run shell commands? Download the CSS and the JS, don't retype them.** Retyping costs thousands of tokens and can drop or change lines of the CSS. Put the files where your stack expects them (Use it says where: change the `-o` paths):

```bash
curl -fsSL --create-dirs -o kodza/kodza-dot-map.css https://kodza.dev/registry/dot-map/kodza-dot-map.css
curl -fsSL --create-dirs -o kodza/kodza-dot-map.js https://kodza.dev/registry/dot-map/kodza-dot-map.js
```

Then load the files from your page as Use it shows, instead of pasting the content. The HTML is the part you adapt (text, links, modifiers): paste it from the Code section. Copy the CSS and the JS from the Code section only if you can't run commands, exactly as they are. (Windows PowerShell: `curl.exe`.)

## API

- Root: `[data-kodza-dot-map]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-dot-map-bg: #f2f2f0` — the paper
  - `--kz-dot-map-ink: #0a0a0a` — the dots, the text, the button
  - `--kz-dot-map-muted: rgba(10, 10, 10, 0.62)`
  - `--kz-dot-map-gap: 9px` — from one dot of land to the next
  - `--kz-dot-map-size: 1.5px` — dot radius
  - `--kz-dot-map-rest: 0.26` — land at rest, 0 to 1
  - `--kz-dot-map-calm: 0.3` — how much the dots light up under the text, 0 to 1 (at rest they fade half as far)
  - `--kz-dot-map-zone: 260px` — the least height left to the map above the text
  - `--kz-dot-map-height: 720px`
  - `--kz-dot-map-width: 1152px` — the text column
  - `--kz-dot-map-side: clamp(20px, 5vw, 64px)`
- Attributes:
  - `data-kodza-dot-map`
  - `data-lat` (e.g. `34.69`, `49.49`, `45.50`)
  - `data-lon` (e.g. `135.50`, `0.11`, `-73.57`)
  - `data-tz` (e.g. `Asia/Tokyo`, `Europe/Paris`, `America/Toronto`)
- Lifecycle: `kodza:init` / `kodza:destroy` on `document`, with `{ detail: { root } }` to target a container.

## Dark mode

The values Kodza uses on a dark background (`#0a0a0a`). For a page that follows the system theme, add them after the component's CSS:

```css
@media (prefers-color-scheme: dark) {
  .kodza-dot-map {
    --kz-dot-map-bg: #0a0a0a;
    --kz-dot-map-ink: #f2f2f0;
    --kz-dot-map-muted: rgba(242, 242, 240, 0.62);
    --kz-dot-map-rest: 0.26;
  }
}
```

A site with its own theme switch puts the same rules under its dark selector instead (`[data-theme="dark"] .kodza-dot-map { … }`); a single dark section, under that section's class.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-dot-map.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-dot-map.css">`. Without a shell, paste it in a `<style>` in `<head>` instead. It only touches `kodza-` classes.
- HTML: where the component goes. Paste it as many times as you need: instances never interfere.
- JS: `kodza/kodza-dot-map.js`, loaded with `<script src="kodza/kodza-dot-map.js" defer></script>` in `<head>`. Without a shell, paste it in a `<script>` before `</body>` (or in `<head>`: it waits for the page). One copy per page, whatever the number of instances.
- Restyle with the `--kz-…` variables, set on the component's root element itself (the root declares its own defaults, so a value on an ancestor is ignored), and with the modifiers / attributes listed under API.
- Font: it takes the page's own. The Kodza look is Geist: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Content added later (AJAX navigation, a SPA route, a builder preview): start it with `document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: container } }))`; before removing it, the same call with `'kodza:destroy'` stops it and puts the original markup back.

### Claude artifact (claude.ai)

- Paste the CSS in a `<style>` and the JS in a `<script>` at the end, in the artifact itself: it loads stylesheets only from Google Fonts and scripts only from a few CDNs, so no `<link>` or `<script src>` to Kodza.
- Writing the artifact file with a shell? Insert the downloaded files (Files) with a command instead of retyping them: a line `KODZA-CSS` alone inside the `<style>`, `KODZA-JS` inside the `<script>`, then `sed -e '/^KODZA-CSS$/{r kodza/kodza-dot-map.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-dot-map.js' -e 'd' -e '}' page.html > artifact.html`.
- Font: load Geist from Google Fonts (allowed): `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Dark mode: the viewer sets `data-theme="dark"` or `"light"` on `<html>`, or nothing when it follows the system. Write the Dark mode rules twice: inside `@media (prefers-color-scheme: dark)` with `:root:not([data-theme="light"])` in front of each selector, and with `:root[data-theme="dark"]` in front of each selector.

### WordPress

- HTML: a **Custom HTML** block (or a pattern / template part). WordPress keeps this markup as it is only for users allowed to post unfiltered HTML (Administrators; Super Admins on multisite): for anyone else it strips tags such as `<svg>`, `<input>` or `<dialog>`, so add it from the theme (a pattern or template part) instead.
- This one spans the page: a block theme keeps a Custom HTML block to the width of the text column, so put it inside a **Group** block set to **Full width**, with **Inner blocks use content width** turned off.
- CSS and JS: the downloaded files go in the theme, under `wp-content/themes/<your-theme>/kodza/` (the child theme if there is one), enqueued from its functions.php (or a code-snippets plugin):
  ```php
  add_action('wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory() . '/kodza/';
    $uri = get_stylesheet_directory_uri() . '/kodza/';
    wp_enqueue_style('kodza-dot-map', $uri . 'kodza-dot-map.css', [], filemtime($dir . 'kodza-dot-map.css'));
    wp_enqueue_script('kodza-dot-map', $uri . 'kodza-dot-map.js', [], filemtime($dir . 'kodza-dot-map.js'), true);
  });
  ```
  Scoped to `kodza-` classes, the CSS never touches the theme. One copy of the JS, whatever the number of blocks.
- No access to the theme files? The CSS can also go, as it is, in **Appearance → Customize → Additional CSS** (classic theme) or **Styles → Additional CSS** in the Site Editor (block theme).
- Theme with AJAX page transitions: fire `kodza:init` on the new content after each swap (and `kodza:destroy` on the old one).
- Performance plugin (WP Rocket, LiteSpeed Cache, Perfmatters…) with **Delay JavaScript execution** turned on: add `kodza` to the scripts it must not delay. Otherwise the component waits for the visitor's first scroll, tap or mouse move, and that first click can be lost.
- Colours: it takes the colour of the text around it (or keeps its own monochrome): nothing to point at the theme.
- WordPress turns straight quotes and `--` into typographic ones in the text of a Custom HTML block (not inside `<code>` or `<pre>`).
- Put it in a full-width Group. Your own text goes inside __content; list your places with data-lat, data-lon and data-tz (an IANA time zone), and set --kz-dot-map-bg and --kz-dot-map-ink to your theme's colours.

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- This one spans the page: give the widget its own container, **Content Width: Full Width**, padding 0.
- CSS and JS: as above. Elementor's Site Settings → Theme Style styles every button, field, link and image of the page, components included; the theme guard at the end of the CSS keeps the component's buttons, fields and link colours as they are.
- The Elementor editor redraws each widget after the page has loaded: without the script below, the component stays still in the editor preview (the published page works). Add it once per site, in the footer like the component's script (`kodza/elementor-editor.js`, or a code-snippets plugin):

```js
// Kodza in the Elementor editor: the editor redraws each HTML widget after the page has loaded.
// Start Kodza in what it draws, stop it in what it replaces or removes. Once per site.
window.addEventListener('elementor/frontend/init', () => {
  if (!elementorFrontend.isEditMode()) return;
  class KodzaWidget extends elementorModules.frontend.handlers.Base {
    onInit() {
      super.onInit();
      this.kodza = [...this.$element[0].children];
      document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: this.$element[0] } }));
    }
    onDestroy() {
      super.onDestroy();
      this.kodza.forEach((root) => document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } })));
    }
  }
  elementorFrontend.elementsHandler.attachHandler('html', KodzaWidget);
});
```

- No child theme: everything can go in the HTML widget instead, `<style>` with the CSS, the HTML, then `<script>` with the JS. It works on the page and in the editor without the script above.

### Shopify

- Files: `assets/kodza-dot-map.css` and `assets/kodza-dot-map.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-dot-map.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-dot-map.js' | asset_url }}" defer></script>`, then the HTML.
- The theme editor re-renders sections: restart them with
  `document.addEventListener('shopify:section:load', (e) => document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: e.target } })));`
  and the same with `shopify:section:unload` → `kodza:destroy`.

### React / Next.js

- Files, side by side (e.g. in `components/kodza/`): `DotMap.tsx` (below), the CSS block under Code saved as `dot-map.css` and the JS block as `dot-map.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { DotMap } from '@/components/kodza/DotMap';` then `<DotMap />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-dot-map-bg': … }}`), `className`, `style` and the other props of its `<section>` (`onClick`, `aria-…`).
- Next.js App Router: it is a Client Component already (`'use client'`). Pages Router: Next.js takes global CSS only in `pages/_app`, so move the `import './dot-map.css'` line there.
- Safe under StrictMode, client-side navigation and prop changes: when a prop changes, the `key` gives React a fresh element, `kodza:destroy` stops the script on the old one and `kodza:init` starts it on the new one.
- Built from your data (tabs, rows, options)? Add what the markup is made of to the `key`, as plain values (ids, text, numbers). What the visitor changes (the open tab, the sort, what they type) is the script's: follow it with `onClick` or `onInput` on the root, and change it from your code as a visitor would (click the tab; set the field's value, then dispatch a bubbling `input` event). Fields stay uncontrolled (`defaultValue`, `name`).

```tsx
'use client';
// Kodza — Dot Map, for React. From https://kodza.dev/components/dot-map/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: dot-map.css and dot-map.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './dot-map.css';

export type DotMapProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** the paper — default: #f2f2f0 */
    "--kz-dot-map-bg"?: string | number;
    /** the dots, the text, the button — default: #0a0a0a */
    "--kz-dot-map-ink"?: string | number;
    /** default: rgba(10, 10, 10, 0.62) */
    "--kz-dot-map-muted"?: string | number;
    /** from one dot of land to the next — default: 9px */
    "--kz-dot-map-gap"?: string | number;
    /** dot radius — default: 1.5px */
    "--kz-dot-map-size"?: string | number;
    /** land at rest, 0 to 1 — default: 0.26 */
    "--kz-dot-map-rest"?: string | number;
    /** how much the dots light up under the text, 0 to 1 (at rest they fade half as far) — default: 0.3 */
    "--kz-dot-map-calm"?: string | number;
    /** the least height left to the map above the text — default: 260px */
    "--kz-dot-map-zone"?: string | number;
    /** default: 720px */
    "--kz-dot-map-height"?: string | number;
    /** the text column — default: 1152px */
    "--kz-dot-map-width"?: string | number;
    /** default: clamp(20px, 5vw, 64px) */
    "--kz-dot-map-side"?: string | number;
  };
  className?: string;
};

export function DotMap({ vars, className, style, ...rest }: DotMapProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-dot-map", className].filter(Boolean).join(' ');
  // Everything the markup is made of: when it changes, React gets a fresh element (see the top)
  const key = JSON.stringify([classes, vars, style, rest]);

  // Starts the script on this element once React has rendered it, stops it when it goes
  useEffect(() => {
    const root = ref.current;
    let live = true;
    // @ts-ignore: a plain script, no types to find
    import('./dot-map.js').then(() => {
      if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
    });
    return () => {
      live = false;
      document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
    };
  }, [key]);

  return (
    <section data-kodza-dot-map="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <div className="kodza-dot-map__content">
        <p className="kodza-dot-map__eyebrow">Support · three studios, one queue</p>
        <div className="kodza-dot-map__grid">
          <h1 className="kodza-dot-map__title">
            <span className="kodza-dot-map__line">Three studios.</span>
            {' '}
            <span className="kodza-dot-map__line">No night shift.</span>
          </h1>
          <div className="kodza-dot-map__side">
            <p className="kodza-dot-map__lead">Osaka hands over to Le Havre, Le Havre to Montréal. Your ticket follows daylight: nobody answers it at 3 a.m., and nobody makes you wait until morning.</p>
            <p className="kodza-dot-map__actions">
              <a className="kodza-dot-map__button" href="#">Open a ticket</a>
              {' '}
              <a className="kodza-dot-map__button kodza-dot-map__button--ghost" href="#">How handover works</a>
            </p>
          </div>
        </div>
      </div>
      <ul className="kodza-dot-map__places">
        <li className="kodza-dot-map__place" data-lat="34.69" data-lon="135.50" data-tz="Asia/Tokyo">Osaka</li>
        <li className="kodza-dot-map__place" data-lat="49.49" data-lon="0.11" data-tz="Europe/Paris">Le Havre</li>
        <li className="kodza-dot-map__place" data-lat="45.50" data-lon="-73.57" data-tz="America/Toronto">Montréal</li>
      </ul>
    </section>
  );
}
```

## Code

```html
<section class="kodza-dot-map" data-kodza-dot-map>
  <div class="kodza-dot-map__content">
    <p class="kodza-dot-map__eyebrow">Support · three studios, one queue</p>
    <div class="kodza-dot-map__grid">
      <h1 class="kodza-dot-map__title">
        <span class="kodza-dot-map__line">Three studios.</span>
        <span class="kodza-dot-map__line">No night shift.</span>
      </h1>
      <div class="kodza-dot-map__side">
        <p class="kodza-dot-map__lead">Osaka hands over to Le Havre, Le Havre to Montréal. Your ticket follows daylight: nobody answers it at 3 a.m., and nobody makes you wait until morning.</p>
        <p class="kodza-dot-map__actions">
          <a class="kodza-dot-map__button" href="#">Open a ticket</a>
          <a class="kodza-dot-map__button kodza-dot-map__button--ghost" href="#">How handover works</a>
        </p>
      </div>
    </div>
  </div>
  <ul class="kodza-dot-map__places">
    <li class="kodza-dot-map__place" data-lat="34.69" data-lon="135.50" data-tz="Asia/Tokyo">Osaka</li>
    <li class="kodza-dot-map__place" data-lat="49.49" data-lon="0.11" data-tz="Europe/Paris">Le Havre</li>
    <li class="kodza-dot-map__place" data-lat="45.50" data-lon="-73.57" data-tz="America/Toronto">Montréal</li>
  </ul>
</section>
```

```css
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Dot Map
   A hero over a world map in fine dots, framed on the places you list. Each place gets its dot,
   its name and its local time, kept current to the minute, and a ring tours them one after the
   other, lighting the dots it crosses. The hand lights them too. Without the script, the places
   stay as a plain list. Tuned with the --kz-dot-map-* variables. */
.kodza-dot-map {
  --kz-dot-map-bg: #f2f2f0;              /* the paper */
  --kz-dot-map-ink: #0a0a0a;             /* the dots, the text, the button */
  --kz-dot-map-muted: rgba(10, 10, 10, 0.62);
  --kz-dot-map-gap: 9px;                 /* from one dot of land to the next */
  --kz-dot-map-size: 1.5px;              /* dot radius */
  --kz-dot-map-rest: 0.26;               /* land at rest, 0 to 1 */
  --kz-dot-map-calm: 0.3;                /* how much the dots light up under the text, 0 to 1 (at rest they fade half as far) */
  --kz-dot-map-zone: 260px;              /* the least height left to the map above the text */
  --kz-dot-map-height: 720px;
  --kz-dot-map-width: 1152px;            /* the text column */
  --kz-dot-map-side: clamp(20px, 5vw, 64px);

  position: relative;
  isolation: isolate;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: var(--kz-dot-map-height);
  padding: 120px var(--kz-dot-map-side) 72px;
  overflow: hidden;
  background: var(--kz-dot-map-bg);
  color: var(--kz-dot-map-ink);
}

/* The map, drawn by the script; it fades in from the top and runs on behind the text */
.kodza-dot-map__canvas {
  position: absolute;
  inset: 0;
  z-index: -1;
  display: block;
  width: 100%;
  height: 100%;
  color: var(--kz-dot-map-ink); /* the script reads the dot colour here */
  pointer-events: none;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 16%);
  mask-image: linear-gradient(to bottom, transparent, #000 16%);
}

.kodza-dot-map__content {
  width: 100%;
  max-width: var(--kz-dot-map-width);
  margin: 0 auto;
}

.kodza-dot-map__eyebrow {
  margin: 0 0 20px;
  color: var(--kz-dot-map-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

.kodza-dot-map__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  align-items: end;
  gap: 28px 64px;
}

.kodza-dot-map__title {
  margin: 0;
  font-size: clamp(44px, 6.6vw, 92px);
  font-weight: 600;
  line-height: 0.94;
  letter-spacing: -0.055em;
}

.kodza-dot-map__line {
  display: block;
}

.kodza-dot-map__lead {
  max-width: 30em;
  margin: 0 0 24px;
  color: var(--kz-dot-map-muted);
  font-size: clamp(16px, 1.4vw, 18px);
  line-height: 1.5;
  text-wrap: pretty;
}

.kodza-dot-map__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0;
}

.kodza-dot-map__button {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 22px;
  border-radius: 999px;
  background: var(--kz-dot-map-ink);
  color: var(--kz-dot-map-bg);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
  text-decoration: none;
  white-space: nowrap;
  transition: opacity 0.18s ease;
}

.kodza-dot-map__button:hover {
  opacity: 0.86;
}

/* Opaque, so it sits on the map instead of letting the dots through */
.kodza-dot-map__button--ghost {
  background: var(--kz-dot-map-bg);
  color: inherit;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}

.kodza-dot-map__button:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

/* The places: a row of labels under the text, until the script sets them on the map */
.kodza-dot-map__places {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  width: 100%;
  max-width: var(--kz-dot-map-width);
  margin: 32px auto 0;
  padding: 0;
  list-style: none;
}

.kodza-dot-map__place {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 28px;
  margin: 0;
  padding: 0 11px 0 10px;
  border-radius: 999px;
  background: var(--kz-dot-map-bg);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--kz-dot-map-ink) 14%, transparent);
  color: var(--kz-dot-map-ink);
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.01em;
  white-space: nowrap;
}

/* The place's dot, drawn in CSS */
.kodza-dot-map__place::before {
  content: "";
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--kz-dot-map-ink);
}

.kodza-dot-map__time {
  color: var(--kz-dot-map-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}

/* On the map: the list is the frame the places are fitted into, above the text */
.kodza-dot-map.is-live .kodza-dot-map__places {
  position: relative;
  display: block;
  flex: 1 0 var(--kz-dot-map-zone);
  order: -1;
  max-width: none;
  margin: 0 0 24px;
}

/* Each label sits right of its place (left of it near the right edge); the dot is the place itself */
.kodza-dot-map.is-live .kodza-dot-map__place {
  position: absolute;
  top: 0;
  left: 0;
  translate: calc(var(--kz-dot-map-x, 0px) + 22px) calc(var(--kz-dot-map-y, 0px) - 50%);
  opacity: 0;
  /* The text turns when the fill is halfway: never pale on pale. A label comes in once the one it
     would cover has gone */
  transition: opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.15s, background-color 0.25s ease, color 0s linear 0.08s, box-shadow 0.25s ease;
}

.kodza-dot-map.is-live .kodza-dot-map__time {
  transition: color 0s linear 0.08s;
}

.kodza-dot-map.is-live .kodza-dot-map__place::before {
  position: absolute;
  top: calc(50% - 3px);
  left: -25px;
}

.kodza-dot-map.is-live .kodza-dot-map__place.is-left {
  translate: calc(var(--kz-dot-map-x, 0px) - 100% - 22px) calc(var(--kz-dot-map-y, 0px) - 50%);
}

.kodza-dot-map.is-live .kodza-dot-map__place.is-left::before {
  right: -25px;
  left: auto;
}

.kodza-dot-map.is-live .kodza-dot-map__place.is-on {
  opacity: 1;
}

/* Panned out of the frame, or about to meet another label: it leaves fast */
.kodza-dot-map.is-live .kodza-dot-map__place.is-away {
  opacity: 0;
  transition: opacity 0.15s ease, background-color 0.25s ease, color 0s linear 0.08s, box-shadow 0.25s ease;
}

/* Where the ring is: the label turns to ink */
.kodza-dot-map.is-live .kodza-dot-map__place.is-active {
  background: var(--kz-dot-map-ink);
  box-shadow: none;
  color: var(--kz-dot-map-bg);
}

.kodza-dot-map__place.is-active .kodza-dot-map__time {
  color: color-mix(in srgb, var(--kz-dot-map-bg) 66%, transparent);
}

/* Still, when the places cannot all be seen on the map at once: a row again, at the foot of the map */
.kodza-dot-map.is-live.is-listed .kodza-dot-map__places {
  display: flex;
  align-content: flex-end;
}

.kodza-dot-map.is-live.is-listed .kodza-dot-map__place {
  position: static;
  translate: none;
  opacity: 1;
}

.kodza-dot-map.is-live.is-listed .kodza-dot-map__place::before {
  position: static;
}

@media (max-width: 720px) {
  .kodza-dot-map {
    --kz-dot-map-gap: 7px;
    --kz-dot-map-size: 1.2px;
    --kz-dot-map-zone: 240px;
    padding: 96px var(--kz-dot-map-side) 56px;
  }

  .kodza-dot-map__grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kodza-dot-map__button,
  .kodza-dot-map.is-live .kodza-dot-map__place,
  .kodza-dot-map.is-live .kodza-dot-map__time {
    transition: none;
  }
}

/* kodza-guard 3dfe6f2fd696 — generated by Kodza, do not edit.
   Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
   component. :not(#_) is always true and weighs as much as an id, so these lines win over
   theme rules made of classes and tags. Customise the component through its --kz-* variables. */

/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */

:where(.kodza-dot-map__button):not(#_) {
  outline: revert;
  box-shadow: revert;
  outline-offset: revert;
}

:where(.kodza-dot-map__places):not(#_) {
  position: revert;
}

:where(.kodza-dot-map__place):not(#_) {
  transition: revert;
  position: revert;
}

:where(.kodza-dot-map__time):not(#_) {
  transition: revert;
}

/* What the browser gives, on everything the component leaves to it */

.kodza-dot-map__canvas:not(#_),
.kodza-dot-map__content:not(#_),
.kodza-dot-map__grid:not(#_),
.kodza-dot-map__line:not(#_),
.kodza-dot-map__side:not(#_),
.kodza-dot-map__name:not(#_) {
  box-sizing: revert;
}

.kodza-dot-map__eyebrow:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-weight: revert;
  font-style: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__title:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-style: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__lead:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: revert;
  font-family: revert;
  font-weight: revert;
  font-style: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__actions:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__button:not(#_) {
  border: revert;
  margin: revert;
  list-style: revert;
  float: revert;
  position: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-family: revert;
  font-style: revert;
  line-height: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__places:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  box-sizing: revert;
  float: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__place:not(#_) {
  border: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  float: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-family: revert;
  font-style: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__time:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  line-height: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-dot-map__eyebrow:not(#_):where(:not([hidden])),
.kodza-dot-map__title:not(#_):where(:not([hidden])),
.kodza-dot-map__lead:not(#_):where(:not([hidden])),
.kodza-dot-map__time:not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-dot-map__eyebrow:not(#_):where([hidden]),
.kodza-dot-map__title:not(#_):where([hidden]),
.kodza-dot-map__lead:not(#_):where([hidden]),
.kodza-dot-map__time:not(#_):where([hidden]) {
  display: none;
}

.kodza-dot-map__place:not(#_)::before {
  box-sizing: revert;
}

/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */

.kodza-dot-map__eyebrow:not(#_) {
  margin: 0 0 20px;
  color: var(--kz-dot-map-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

.kodza-dot-map__title:not(#_) {
  margin: 0;
  font-size: clamp(44px, 6.6vw, 92px);
  font-weight: 600;
  line-height: 0.94;
  letter-spacing: -0.055em;
}

.kodza-dot-map__lead:not(#_) {
  max-width: 30em;
  margin: 0 0 24px;
  color: var(--kz-dot-map-muted);
  font-size: clamp(16px, 1.4vw, 18px);
  line-height: 1.5;
}

.kodza-dot-map__actions:not(#_) {
  display: flex;
  margin: 0;
}

.kodza-dot-map__button:not(#_) {
  box-sizing: border-box;
  display: inline-flex;
  padding: 0 22px;
  border-radius: 999px;
  background: var(--kz-dot-map-ink);
  color: var(--kz-dot-map-bg);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
  text-decoration: none;
  transition: opacity 0.18s ease;
}

.kodza-dot-map__button--ghost:not(#_) {
  background: var(--kz-dot-map-bg);
  color: inherit;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}

.kodza-dot-map__button:focus-visible:not(#_) {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

.kodza-dot-map__places:not(#_) {
  display: flex;
  max-width: var(--kz-dot-map-width);
  margin: 32px auto 0;
  padding: 0;
  list-style: none;
}

.kodza-dot-map__place:not(#_) {
  box-sizing: border-box;
  display: inline-flex;
  margin: 0;
  padding: 0 11px 0 10px;
  border-radius: 999px;
  background: var(--kz-dot-map-bg);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--kz-dot-map-ink) 14%, transparent);
  color: var(--kz-dot-map-ink);
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.01em;
}

.kodza-dot-map__time:not(#_) {
  color: var(--kz-dot-map-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0;
}

.kodza-dot-map.is-live .kodza-dot-map__places:not(#_) {
  position: relative;
  display: block;
  max-width: none;
  margin: 0 0 24px;
}

.kodza-dot-map.is-live .kodza-dot-map__place:not(#_) {
  position: absolute;
  transition: opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.15s, background-color 0.25s ease, color 0s linear 0.08s, box-shadow 0.25s ease;
}

.kodza-dot-map.is-live .kodza-dot-map__time:not(#_) {
  transition: color 0s linear 0.08s;
}

.kodza-dot-map.is-live .kodza-dot-map__place.is-away:not(#_) {
  transition: opacity 0.15s ease, background-color 0.25s ease, color 0s linear 0.08s, box-shadow 0.25s ease;
}

.kodza-dot-map.is-live .kodza-dot-map__place.is-active:not(#_) {
  background: var(--kz-dot-map-ink);
  box-shadow: none;
  color: var(--kz-dot-map-bg);
}

.kodza-dot-map__place.is-active .kodza-dot-map__time:not(#_) {
  color: color-mix(in srgb, var(--kz-dot-map-bg) 66%, transparent);
}

.kodza-dot-map.is-live.is-listed .kodza-dot-map__places:not(#_) {
  display: flex;
}

.kodza-dot-map.is-live.is-listed .kodza-dot-map__place:not(#_) {
  position: static;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-dot-map__button:not(#_),
  .kodza-dot-map.is-live .kodza-dot-map__place:not(#_),
  .kodza-dot-map.is-live .kodza-dot-map__time:not(#_) {
    transition: none;
  }
}
```

```js
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Dot Map
// Draws a world map in fine dots behind the hero, framed on the places listed in it: each place
// gets its dot, its label and its local time, and a ring tours them one after the other, lighting
// the dots it crosses. Without the script, the places stay a plain list, with no times.
(() => {
  const SELECTOR = '[data-kodza-dot-map]';
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ---- Kodza field engine 1.1 ------------------------------------------------------------
  // The same copy in every canvas background of the family (Halftone Fade, Plus Pattern, Square
  // Grid Hover…): it owns the canvas, a grid of points on springs, the pointer, the pulses and the
  // pauses (off-screen, hidden tab, nothing moving). A background hands it a layer that says how
  // the points are pushed and drawn. Change it here, then copy it to the others as is.
  //   layer = { canvas: 'kodza-…__canvas', gap: '--kz-…-gap', spring: [stiffness, damping],
  //             quiet: '<the text over the field>', calm: '--kz-…-calm',
  //             pulseLife: seconds, ambient: true to never sleep while seen,
  //             setup(f), enter(f), push(f, dt), draw(f, ctx) }
  const MAX_POINTS = 5000;

  function field(root, signal, layer) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    if (!ctx) return null; // the still field drawn in CSS stays
    canvas.className = layer.canvas;
    canvas.setAttribute('aria-hidden', 'true');
    root.prepend(canvas);
    root.classList.add('is-live');

    const [stiffness, damping] = layer.spring || [46, 8.5];
    const f = {
      root, canvas, ctx, still: reduceMotion, time: 0, scale: 1,
      width: 0, height: 0, gap: 0, cols: 0, rows: 0, count: 0, x0: 0, y0: 0, ink: '', bg: '',
      pointer: { x: 0, y: 0, vx: 0, vy: 0, active: false, t: 0 },
      pulses: [],
      pulse(x, y, power = 1) {
        if (f.still) return;
        f.pulses.push({ x, y, power, t: f.time });
        if (f.pulses.length > 6) f.pulses.shift();
        wake();
      },
      wake: () => wake(),
    };
    let raf = 0;
    let last = 0;
    let onScreen = false;
    let shown = !document.hidden;
    let entered = false;
    let size = '';

    const colors = () => {
      f.ink = getComputedStyle(canvas).color;
      f.bg = getComputedStyle(root).backgroundColor;
    };
    // Screen pixels to the root's own: a parent scaled by transform must not shift the hand
    const scaleOf = () => root.getBoundingClientRect().width / root.offsetWidth || 1;

    // Under the text the field calms down (f.quiet: 1 in the open, `calm` under a line of text),
    // so the headline stays readable at the busiest moment. Feathered: never a visible box
    const quietZones = () => {
      f.quiet = new Float32Array(f.count).fill(1);
      const calm = parseFloat(getComputedStyle(root).getPropertyValue(layer.calm));
      f.calm = Number.isFinite(calm) ? Math.min(Math.max(calm, 0), 1) : 1;
      if (!layer.quiet || f.calm === 1) return;
      const box = root.getBoundingClientRect();
      const s = box.width / root.offsetWidth || 1;
      const range = document.createRange();
      const lines = [];
      // The lines of text only: a block inside (a span per line) is as wide as its column
      root.querySelectorAll(layer.quiet).forEach((el) => {
        const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
        for (let node = walker.nextNode(); node; node = walker.nextNode()) {
          if (!node.data.trim()) continue;
          range.selectNodeContents(node);
          for (const r of range.getClientRects()) {
            if (r.width && r.height) lines.push([(r.left - box.left) / s, (r.top - box.top) / s, (r.right - box.left) / s, (r.bottom - box.top) / s]);
          }
        }
      });
      const feather = f.gap * 1.5;
      for (let i = 0; i < f.count; i++) {
        let near = Infinity;
        for (const [l, t, r, b] of lines) {
          near = Math.min(near, Math.hypot(Math.max(l - f.hx[i], 0, f.hx[i] - r), Math.max(t - f.hy[i], 0, f.hy[i] - b)));
        }
        const k = Math.min(Math.max(near - f.gap / 2, 0) / feather, 1);
        f.quiet[i] = f.calm + (1 - f.calm) * k * k * (3 - 2 * k);
      }
    };

    // The grid is centred like the CSS dots (one on the centre), with a spare row past each edge
    const build = () => {
      const w = root.offsetWidth;
      const h = root.offsetHeight;
      if (!w || !h) return false;
      size = `${w}x${h}`;
      f.scale = scaleOf();
      const ratio = Math.min((window.devicePixelRatio || 1) * f.scale, 3);
      canvas.width = Math.round(w * ratio);
      canvas.height = Math.round(h * ratio);
      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
      const wanted = parseFloat(getComputedStyle(root).getPropertyValue(layer.gap)) || 24;
      const gap = Math.max(wanted, Math.sqrt((w * h) / MAX_POINTS)); // a huge section gets fewer dots, not a slow one
      const x0 = ((w / 2) % gap) - gap;
      const y0 = ((h / 2) % gap) - gap;
      const cols = Math.ceil((w - x0) / gap) + 1;
      const rows = Math.ceil((h - y0) / gap) + 1;
      const count = cols * rows;
      Object.assign(f, { width: w, height: h, gap, cols, rows, count, x0, y0 });
      ['hx', 'hy', 'ox', 'oy', 'vx', 'vy', 'fx', 'fy'].forEach((key) => { f[key] = new Float32Array(count); });
      for (let r = 0, i = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++, i++) {
          f.hx[i] = x0 + c * gap;
          f.hy[i] = y0 + r * gap;
        }
      }
      colors();
      quietZones();
      layer.setup?.(f);
      return true;
    };

    const paint = () => {
      ctx.clearRect(0, 0, f.width, f.height);
      layer.draw(f, ctx);
    };

    const running = () => onScreen && shown && !f.still;

    function wake() {
      if (raf || !running()) return;
      if (Math.abs(scaleOf() - f.scale) > 0.01) build(); // zoomed since: redraw at the new sharpness
      colors(); // the theme may have changed while asleep
      last = performance.now();
      raf = requestAnimationFrame(frame);
    }

    function frame(now) {
      raf = 0;
      if (signal.aborted || !running()) return;
      const dt = Math.min(Math.max((now - last) / 1000, 0), 1 / 30); // a frame stamp can predate the wake
      last = now;
      f.time += dt;
      const p = f.pointer;
      const slow = Math.exp(-dt * 8); // a hand that stops stops pushing forward
      p.vx *= slow;
      p.vy *= slow;
      f.fx.fill(0);
      f.fy.fill(0);
      layer.push(f, dt);

      // Springs back home, integrated semi-implicitly (stable at 30 fps too)
      const { ox, oy, vx, vy, fx, fy, count } = f;
      let moving = 0;
      let away = 0;
      for (let i = 0; i < count; i++) {
        vx[i] += (fx[i] - stiffness * ox[i] - damping * vx[i]) * dt;
        vy[i] += (fy[i] - stiffness * oy[i] - damping * vy[i]) * dt;
        ox[i] += vx[i] * dt;
        oy[i] += vy[i] * dt;
        moving = Math.max(moving, Math.abs(vx[i]) + Math.abs(vy[i]));
        away = Math.max(away, Math.abs(ox[i]) + Math.abs(oy[i]));
      }
      const life = layer.pulseLife || 2.5;
      f.pulses = f.pulses.filter((q) => f.time - q.t < life);

      // Asleep once it has all settled: no frame is drawn for nothing
      const settled = !layer.ambient && !f.pulses.length && moving < 1 && Math.abs(p.vx) + Math.abs(p.vy) < 4;
      if (settled && !p.active && away < 0.3) {
        ox.fill(0);
        oy.fill(0);
        vx.fill(0);
        vy.fill(0);
      }
      paint();
      if (!settled) raf = requestAnimationFrame(frame);
    }

    if (!f.still) {
      const local = (e) => {
        const box = root.getBoundingClientRect();
        const s = box.width / root.offsetWidth || 1;
        return [(e.clientX - box.left) / s, (e.clientY - box.top) / s];
      };
      root.addEventListener('pointermove', (e) => {
        const [x, y] = local(e);
        const p = f.pointer;
        const dt = (e.timeStamp - p.t) / 1000;
        if (p.active && dt > 0 && dt < 0.1) {
          const k = Math.max(dt, 1 / 240);
          p.vx = p.vx * 0.5 + ((x - p.x) / k) * 0.5;
          p.vy = p.vy * 0.5 + ((y - p.y) / k) * 0.5;
        }
        Object.assign(p, { x, y, t: e.timeStamp, active: true });
        wake();
      }, { signal });
      const leave = () => {
        f.pointer.active = false;
        wake();
      };
      root.addEventListener('pointerleave', leave, { signal });
      root.addEventListener('pointercancel', leave, { signal }); // the finger started a scroll
      root.addEventListener('pointerdown', (e) => {
        if (e.button > 0) return;
        f.pulse(...local(e));
      }, { signal });
      // A click from the keyboard sends its pulse from the control itself
      root.addEventListener('click', (e) => {
        if (e.detail !== 0 || !(e.target instanceof Element) || e.target === root) return;
        const box = e.target.getBoundingClientRect();
        f.pulse(...local({ clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }));
      }, { signal });
    }

    const io = new IntersectionObserver((entries) => {
      onScreen = entries[entries.length - 1].isIntersecting;
      if (onScreen && !entered && !f.still) {
        entered = true;
        layer.enter?.(f);
      }
      wake();
    });
    io.observe(root);
    document.addEventListener('visibilitychange', () => {
      shown = !document.hidden;
      wake();
    }, { signal });
    const ro = new ResizeObserver(() => {
      if (`${root.offsetWidth}x${root.offsetHeight}` !== size && build()) paint();
    });
    ro.observe(root);
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
      colors();
      paint();
    }, { signal });

    signal.addEventListener('abort', () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      ro.disconnect();
      canvas.width = 0; // hands the pixels back now, not at the next garbage collection
      canvas.height = 0;
      canvas.remove();
    });

    if (build()) paint();
    // The text moves once the web font is in: calm the field under its final lines
    document.fonts?.ready.then(() => {
      if (signal.aborted || !f.count) return;
      quietZones();
      paint();
    });
    return f;
  }
  // ---- end of the field engine -----------------------------------------------------------

  // The land, one cell per degree from 80 N down to 58 S (Natural Earth, public domain, by way of
  // world-atlas): 360 x 138 cells, west to east from 180 W, north to south. Written as runs that
  // alternate sea and land, sea first; each run is a number in base-64 digits, five bits a digit,
  // low bits first, the sixth bit saying that another digit follows.
  const MASK = [
    '0CXHvBePjCH0EBGFCCDRD1BgBHDBtCFoEDDCXKH0BhBEDCwCClEFBBFCHDBGCKJyBzCFfNjELEECCBNVnBtCHZZXICB5CCIC',
    'QBDKYlBrCEbZ9DIKBEEBFCDBDBEVjBrCDYmBEEOC8CQBDDFBDDJBEVhBqCENEIwBKIhBC1BFBOFGEQThBpCFMEBCB1BDBGLx',
    'BMfQFFDUOeBCtBHlBGB2ClBYEPDQCDBEGEEKNCBcqBObDE9CJEHBM1BGDHBDGCFGHQYsBWOCDKCFBtDMsCBGECCJKVvBbDCC',
    'ZCuDBDGBCvCJMJSwBUCGDcBrDBHExCBBLGDCKNRJaKDKFsEFCHEBpCCEENNLSIZLDLCuEKCHnCEDCBJHNLVCaMC9EQpCICEC',
    'GDRHxBMD-EPoCQGXGwBMEpEBDCJWRC0BQJXDwBNEmEGJbJBCKtBRJDClCNGjEFEmBEQtBPKBE6BBKDCGH1DSDoBCCCSqBPP4',
    'BEOFFBB0DRGlBCZtBLQ3BDLDBEF1DSHiBDcvBHS3BDKCBDH0DSHjCxBDWwBDCDJDBBD5DTFiCBCwBDXuBECEGnEBBNEnCwBC',
    'XuBDCHCrENCqCwBBXzBGCqEBBNCqClCBCzBDDsEBC5C8BDCEE0BuECC7CBB2BBDGGxBwECB9C7BGGyBuEDB9C8BDB6BhBBEC',
    'MD1CEB9CgC7BfDDBLF0CjD5BBD-BOCOJJE1CGB9C2BEB4BQDECMMID0CGE7C1B-BMGBBEDKNHEvCJE8C1B-BMJEDJEFEIFsC',
    'KB_CyBhCKHCFDBFCYDkCBHLC-CyBhCJICGBDDDYDiCCCCDMC_CwBiCJQBEDCXFgCHDLCgDvBjCIOCGCEXEhCBCEDJDhDuBmC',
    'BGKLBGCB5CFEGFiDtBmCBDNWBC0CGDEIjDrBmCSZ0CHBDHoDnBnCTY2CJEtDlBnCYFDM2CJCvDkBnCaEHDBD4CIBxDBCgBnC',
    'kE7DBBTBEGCnC6BCoC8DBBQNClC9BCnC7DDBOODjCvBBODlC-DBCNPCiCwBCOEBC_BgEBCMQBhCyBCPDBD8BhECCKzCyBCPC',
    'DK0BCB_DCCJyC0BCVJxBDCkEIOBBE9B1BDVJuBFBmEIICIC8B2BCULSEQBDuEJGEJE5B2BDSPOFPhDByBJFEOD2B1BERPMIN',
    'DCxEJBGKBEECByB2BDQQLKMDB0EOoC3BDNSKLNNCrELnC4BEKUJMOMCxEJjC5BDJWGSLLCyEJiC6BCHYGSLLC0EHiC7BBEcF',
    'SLMD1EEiC8BgBFSCCHlFCLE1B7BGCYFTBDGNBCB0EDHKCBBBuBiCZESBFELBCC4ECCBDEBKvBhCZDBBRCFBMB-EDBTvB_BbB',
    'CBRCXD5EBCTuB_BeCRCVBBD7EUvB8BfCSCOCGC8EZrBKFsBzBDMEkFZsBBNqBwBCCEKEkFb8BmByBCCCKFjFc9BkB0BCCCHH',
    'jFd7BkB2BFFJJB4Ee7BiB4BEGJCEDC2EhB5BhB6BEFJOByEkB2BhB7BEFHCDJDxEmB1Bf8BFEHCDKCCFqEqByBd-BEICDBBB',
    'EBBCDLnEsBwBd_BDNCBBMKHB-DuBvBbhCCQBPIDD_DuBvBbjCDdKHB8DtBwBakCGZJmEsBwBarCBCCBBECLBBDDClEsBwBbv',
    'CBEBWDkEqBxBbyHpBzBb7CBJBsEnBzBcIBxCGFBtElBzBdHCwCGFDsElBzBdHCrCDBHFEsEkBzBcGEqCMFEuEiBzBcEGqCOD',
    'FVBLBsDhBzBaGGoCYgBBvDe0BYIFpCYwEe1BWJFoCbuEe1BWJFlCfPB9Dd3BVIFjCiBQB8Dc4BWHFiClBrEZ7BWIEiClBqEY',
    '9BVJDiCnBpEX_BSMBkCnBoEX_BSxCnBoEX_BSxCnBoEWhCQyCoBmEWjCO0CmBnEWjCO0CmBnEVlCM2ClBnEUmCL3CKISoETn',
    'CJ4CJLCBOoESpCC_CDQBBOoEQjGNWBxDQlGKYBvDRlGKZBuDQoGCBCcDsDMsHDtDMtHCtDJyGDYCBBuDKxGDXDxDIzGBXDxD',
    'JpHExDIqHEyDIrHCzDJ-KJ_KIpEB3GGiLGID3KGjLGkLGlLBoe',
  ].join('');
  const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
  let LAND = null; // decoded once, for every map on the page

  const landAt = (lon, lat) => {
    const r = Math.floor(80 - lat);
    if (r < 0 || r >= 138) return 0;
    return LAND[r * 360 + ((Math.floor(lon + 180) % 360) + 360) % 360];
  };

  const decode = () => {
    LAND = new Uint8Array(360 * 138);
    for (let i = 0, at = 0, on = 0; i < MASK.length; on ^= 1) {
      let v = 0;
      let shift = 0;
      let d;
      do {
        d = ABC.indexOf(MASK[i++]);
        v |= (d & 31) << shift;
        shift += 5;
      } while (d & 32);
      if (on) LAND.fill(1, at, at + v);
      at += v;
    }
  };

  // The map: land only, a dot every few degrees, at rest a light grey. A ring tours the places in
  // the order they are listed, holds on each, then travels to the next, opening up on the way and
  // closing in as it lands; the land around it lights up, and so does the land under the hand. Under
  // the text that light is calmed, and the land at rest goes a little lighter: the map runs on
  // behind the words without crossing them. The first time it is seen, the map spreads out from
  // the first place.
  const MAX_DOTS = 16000;   // places on the lattice across the section at most: a huge one gets wider spacing
  const LIT = 0.95;         // alpha of a dot fully lit
  const GROW = 0.3;         // and how much bigger it gets
  const LEVELS = 20;        // dots are sorted into a few alphas: thousands of arcs, a few fills
  const REST_CALM = 0.5;    // under the text, the land at rest is calmed half as much as the light
  const NORTH = 14;         // degrees under the map's top edge (80 N) over which the land fades in
  const LIGHT = 26;         // px the ring's light reaches past the ring
  const LENS_EDGE = 18;     // px of soft edge on that light
  const REACH = 96;         // px lit around the hand
  const SETTLE = 8;         // how fast that light comes and goes (1/s)
  const SPREAD = 1100;      // px per second, the map spreading out from the first place
  const SPREAD_EDGE = 160;  // px of soft edge on that front
  const RING_DELAY = 0.5;   // s after the map starts spreading
  const RING_IN = 0.6;      // s for the ring to come in
  const HOLD = 2.5;         // s on each place
  const RING_REST = 18;     // px, the ring's radius on a place
  const RING_MOVE = 34;     // px, opened up halfway
  const RING_ALPHA = 0.55;
  const LABEL = 22;         // px from a place to its label
  const EDGE = 12;          // px a label keeps from the section's edges
  const LOOK = 0.3;         // s ahead a label checks it can still be read

  const smooth = (t) => {
    const k = Math.min(Math.max(t, 0), 1);
    return k * k * (3 - 2 * k);
  };
  const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

  // The camera on one axis: the middle of the places when they fit, else the ring, never past them.
  // Held between `min` and `max` (the map's edges at the section's edges) as long as the places stay
  // in the frame: no bare band where the map ends
  const follow = ([lo, hi, reach, min = -Infinity, max = Infinity], at) => {
    const fits = hi - lo <= 2 * reach;
    const c = fits ? (lo + hi) / 2 : Math.min(Math.max(at, lo + reach), hi - reach);
    const held = Math.min(Math.max(c, min), max);
    return fits ? Math.min(Math.max(held, hi - reach), lo + reach) : Math.min(Math.max(held, at - reach), at + reach);
  };

  // Where the map sits. The places are fitted into their frame (the list's box, above the text),
  // with room around them for the ring and the labels. A dot every 1.2 to 2.6 degrees: closer, the
  // coasts turn to steps; wider, the continents to noise. When the places cannot all fit at that,
  // the frame follows the ring instead; and still, where nothing follows, they go back to a row
  function view(f) {
    const box = f.root.getBoundingClientRect();
    const s = box.width / f.root.offsetWidth || 1;
    const zone = f.list?.offsetWidth ? f.list.getBoundingClientRect() : box;
    const zx = (zone.left - box.left) / s;
    const zy = (zone.top - box.top) / s;
    const zw = zone.width / s;
    const zh = zone.height / s;
    const wanted = parseFloat(getComputedStyle(f.root).getPropertyValue('--kz-dot-map-gap')) || 9;
    const spacing = Math.max(wanted, Math.sqrt((f.width * f.height) / MAX_DOTS));
    const padX = Math.max(48, zw * 0.1);
    const padY = 30;
    const P = f.places;
    let lon0 = -140; // no places: the world, Europe and Africa in the middle
    let lon1 = 160;
    let lat0 = -20;
    let lat1 = 60;
    if (P.length) {
      // The shortest arc that holds every place: the globe is cut at the widest stretch without one,
      // and the longitudes are unrolled from there (a tour across the Pacific stays in one piece)
      const sorted = P.map((p) => p.lon).sort((a, b) => a - b);
      let widest = -1;
      sorted.forEach((lon, i) => {
        const gap = i ? lon - sorted[i - 1] : lon + 360 - sorted[sorted.length - 1];
        if (gap > widest) {
          widest = gap;
          lon0 = lon;
        }
      });
      lon1 = lon0 + 360 - widest;
      P.forEach((p) => { p.u = lon0 + ((((p.lon - lon0) % 360) + 360) % 360); });
      lat0 = Math.min(...P.map((p) => p.lat));
      lat1 = Math.max(...P.map((p) => p.lat));
    }
    const fit = Math.min((zw - 2 * padX) / Math.max(lon1 - lon0, 1e-6), (zh - 2 * padY) / Math.max(lat1 - lat0, 1e-6));
    // Never more than a world across (no coast twice on screen), never shorter than the section (a
    // tall one, full screen, is covered from the top down), never past a dot per degree of the mask
    const k = Math.max(Math.min(fit, spacing / 1.2), spacing / 2.6, f.width / 340, Math.min(f.height / (138 - NORTH / 2), spacing));
    // The lattice, in degrees: a whole number of dots around the globe, so it wraps without a seam
    const nLon = Math.max(Math.round((360 * k) / spacing), 1);
    const step = 360 / nLon;
    const nLat = Math.ceil(138 / step);
    let cells = f.view?.nLon === nLon ? f.view.cells : null;
    if (!cells) {
      if (!LAND) decode();
      // Land where at least a third of the cell is: the islands stay, the coasts do not bloat
      cells = new Uint8Array(nLon * nLat);
      for (let j = 0; j < nLat; j++) {
        for (let i = 0; i < nLon; i++) {
          let hits = 0;
          for (let b = 0; b < 3; b++) {
            for (let a = 0; a < 3; a++) hits += landAt(-180 + (i + (a + 0.5) / 3) * step, 80 - (j + (b + 0.5) / 3) * step);
          }
          cells[j * nLon + i] = hits >= 3 ? 1 : 0;
        }
      }
    }
    const room = (Math.ceil(f.width / (step * k)) + 4) * (Math.ceil(f.height / (step * k)) + 4);
    const sized = f.view?.x?.length >= room ? f.view : null;
    f.view = {
      k, step, nLon, nLat, cells, zx, zy, zw, zh,
      cx: zx + zw / 2,
      cy: zy + zh / 2,
      lon: [lon0, lon1, (zw / 2 - padX) / k],
      // the top of the section no further north than halfway into the fade, its foot no further south than the mask
      lat: [lat0, lat1, (zh / 2 - padY) / k, -58 + (f.height - zy - zh / 2) / k, 80 - NORTH / 2 - (zy + zh / 2) / k],
      home: P.length ? [(lon0 + lon1) / 2, (lat0 + lat1) / 2] : [0, 0], // the camera with no ring
      x: sized?.x || new Float32Array(room),
      y: sized?.y || new Float32Array(room),
      r: sized?.r || new Float32Array(room),
      level: sized?.level || new Uint8Array(room),
      order: sized?.order || new Uint32Array(room),
      counts: new Uint32Array(LEVELS + 1),
    };
    // Each label on the side where it fits at every stop of the tour it is seen at (still, there is
    // one: the ring on the first place): decided here, not while the map pans, so a label never
    // jumps across its dot
    const stops = f.still ? P.slice(0, 1) : P;
    const at = (p, s) => [zw / 2 + (p.u - follow(f.view.lon, s.u)) * k, zh / 2 - (p.lat - follow(f.view.lat, s.lat)) * k];
    P.forEach((p) => {
      p.w = p.el.offsetWidth;
      p.h = p.el.offsetHeight;
      let right = 0;
      let left = 0;
      stops.forEach((s) => {
        const [x, y] = at(p, s);
        if (x < -4 || x > zw + 4 || y < -4 || y > zh + 4) return;
        right = Math.max(right, zx + x + LABEL + p.w - (f.width - EDGE));
        left = Math.max(left, EDGE - (zx + x - LABEL - p.w));
      });
      p.left = right > 0 && left < right;
    });
    // Still, a place that cannot be read with the ring on the first one would be lost for good:
    // the places go back to a row at the foot of the map, and the map shows no ring
    const boxes = [];
    f.listed = f.still && P.some((p) => !readable(f, p, ...at(p, P[0]), boxes));
    f.root.classList.toggle('is-listed', f.listed);
  }

  // A label can be read when its place, at (x, y) in the frame, is in the frame, the label whole
  // inside the section, and neither on a label already placed (kept in `boxes`)
  function readable(f, p, x, y, boxes) {
    const { zx, zy, zw, zh } = f.view;
    if (x < -4 || x > zw + 4 || y < -4 || y > zh + 4) return false;
    const lx = zx + x + (p.left ? -LABEL - p.w : LABEL);
    if (lx < EDGE - 1 || lx + p.w > f.width - EDGE + 1) return false;
    const box = [Math.min(lx, zx + x - 6), zy + y - p.h / 2, Math.max(lx + p.w, zx + x + 6), zy + y + p.h / 2];
    if (boxes.some((b) => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])) return false;
    boxes.push(box);
    return true;
  }

  // How long the ring takes from a place to the next: longer the further it goes
  const legOf = (f, i) => {
    const a = f.places[i];
    const b = f.places[(i + 1) % f.places.length];
    return Math.min(1.4 + (Math.hypot(b.u - a.u, b.lat - a.lat) * f.view.k) / 700, 3.6);
  };

  // Where the ring is (`ahead` seconds from now): on a place, or between two, and which label it lights
  function ringOf(f, ahead = 0) {
    const { places: P, tour } = f;
    if (!P.length) return null;
    let { i, moving, t, length } = tour;
    t += ahead;
    if (P.length > 1 && f.time + ahead >= tour.at) {
      if (!moving && t >= HOLD) [moving, t, length] = [true, t - HOLD, legOf(f, i)];
      if (moving && t >= length) [moving, t, i] = [false, t - length, (i + 1) % P.length];
    }
    const a = P[i];
    if (!moving) return { lon: a.u, lat: a.lat, r: RING_REST, on: i };
    const next = (i + 1) % P.length;
    const b = P[next];
    const u = Math.min(t / length, 1);
    const e = ease(u);
    return {
      lon: a.u + (b.u - a.u) * e,
      lat: a.lat + (b.lat - a.lat) * e,
      r: RING_REST + (RING_MOVE - RING_REST) * Math.sin(Math.PI * u),
      on: u < 0.12 ? tour.i : u > 0.88 ? next : -1, // the label lets go as the ring leaves, lights as it lands
    };
  }

  const quietAt = (f, x, y) => {
    const c = Math.min(Math.max(Math.round((x - f.x0) / f.gap), 0), f.cols - 1);
    const r = Math.min(Math.max(Math.round((y - f.y0) / f.gap), 0), f.rows - 1);
    return f.quiet[r * f.cols + c];
  };

  const flag = (p, name, on) => {
    if (p.flags[name] === on) return;
    p.flags[name] = on;
    p.el.classList.toggle(name, on);
  };

  const map = {
    canvas: 'kodza-dot-map__canvas',
    gap: '--kz-dot-map-gap',
    quiet: '.kodza-dot-map__eyebrow, .kodza-dot-map__title, .kodza-dot-map__lead',
    calm: '--kz-dot-map-calm',
    ambient: true, // the tour goes on while it is seen

    setup(f) {
      const css = getComputedStyle(f.root);
      const read = (name, fallback) => {
        const value = parseFloat(css.getPropertyValue(`--kz-dot-map-${name}`));
        return Number.isFinite(value) ? value : fallback;
      };
      f.size = Math.max(read('size', 1.5), 0.3);
      f.rest = Math.min(Math.max(read('rest', 0.26), 0), 1);
      f.presence = f.presence || 0;
      f.list ??= f.root.querySelector('.kodza-dot-map__places');
      f.places ??= [...f.root.querySelectorAll('.kodza-dot-map__place')]
        .map((el) => ({ el, lat: parseFloat(el.dataset.lat), lon: parseFloat(el.dataset.lon), x: Infinity, y: Infinity, w: 0, flags: {} }))
        .filter((p) => Number.isFinite(p.lon) && Math.abs(p.lat) <= 90)
        .map((p) => Object.assign(p, { lon: ((((p.lon + 180) % 360) + 360) % 360) - 180 }));
      // Still: the map is all there, the ring on the first place
      f.spreadAt ??= f.still ? -Infinity : Infinity;
      f.tour ??= { i: 0, moving: false, t: 0, length: 1, at: f.still ? -Infinity : Infinity };
      view(f);
    },

    enter(f) {
      f.spreadAt = f.time;
      f.tour.at = f.time + RING_DELAY;
    },

    push(f, dt) {
      const p = f.pointer;
      f.presence += ((p.active ? 1 : 0) - f.presence) * (1 - Math.exp(-dt * SETTLE));
      const { tour, places: P } = f;
      if (f.time < tour.at || P.length < 2) return;
      tour.t += dt;
      if (!tour.moving && tour.t >= HOLD) {
        Object.assign(tour, { moving: true, t: 0, length: legOf(f, tour.i) });
      } else if (tour.moving && tour.t >= tour.length) {
        Object.assign(tour, { moving: false, t: 0, i: (tour.i + 1) % P.length });
      }
    },

    draw(f, ctx) {
      const v = f.view;
      if (!v) return;
      const { k, step, nLon, nLat, cells, cx, cy } = v;
      const ring = f.listed ? null : ringOf(f);
      const ringIn = ring ? smooth((f.time - f.tour.at) / RING_IN) : 0;
      const camLon = follow(v.lon, ring ? ring.lon : v.home[0]);
      const camLat = follow(v.lat, ring ? ring.lat : v.home[1]);
      const sx = (lon) => cx + (lon - camLon) * k;
      const sy = (lat) => cy - (lat - camLat) * k;
      const first = f.places[0];
      const ox = first ? sx(first.u) : f.width / 2;
      const oy = first ? sy(first.lat) : f.height / 2;
      const front = (f.time - f.spreadAt) * SPREAD;
      const spread = front > Math.hypot(f.width, f.height) + SPREAD_EDGE;
      const rx = ring ? sx(ring.lon) : 0;
      const ry = ring ? sy(ring.lat) : 0;
      const lens = ringIn > 0 ? ring.r + LIGHT + LENS_EDGE : 0;
      const hand = f.presence > 0.002 ? f.pointer : null;

      // The lattice over the section, one spare dot past each edge, the longitudes unrolled
      const sk = step * k;
      const iMin = Math.ceil((camLon - cx / k - step + 180) / step - 0.5);
      const iMax = Math.floor((camLon + (f.width - cx) / k + step + 180) / step - 0.5);
      const jMin = Math.max(Math.ceil((80 - camLat - cy / k - step) / step - 0.5), 0);
      const jMax = Math.min(Math.floor((80 - camLat + (f.height - cy) / k + step) / step - 0.5), nLat - 1);
      const xMin = cx + (-180 + (iMin + 0.5) * step - camLon) * k;
      const { x: X, y: Y, r: R, level: L, order, counts } = v;
      counts.fill(0);
      let n = 0;
      for (let j = jMin; j <= jMax; j++) {
        const y = cy - (80 - (j + 0.5) * step - camLat) * k;
        const north = smooth(((j + 0.5) * step) / NORTH); // the map has no hard top edge
        const row = j * nLon;
        let c = ((iMin % nLon) + nLon) % nLon;
        for (let i = iMin, x = xMin; i <= iMax; i++, x += sk, c = c + 1 === nLon ? 0 : c + 1) {
          if (!cells[row + c]) continue;
          let e = 0;
          if (lens) {
            const dx = x - rx;
            const dy = y - ry;
            if (dx < lens && dx > -lens && dy < lens && dy > -lens) e = (1 - smooth((Math.hypot(dx, dy) - ring.r - LIGHT) / LENS_EDGE)) * ringIn;
          }
          if (hand) {
            const dx = x - hand.x;
            const dy = y - hand.y;
            if (dx < REACH && dx > -REACH && dy < REACH && dy > -REACH) {
              const t = 1 - Math.hypot(dx, dy) / REACH;
              if (t > 0) e = Math.max(e, t * t * (3 - 2 * t) * f.presence);
            }
          }
          const q = quietAt(f, x, y);
          e *= q;
          let a = (f.rest * (1 - REST_CALM * (1 - q)) + (LIT - f.rest) * e) * north;
          if (!spread) a *= smooth((front - Math.hypot(x - ox, y - oy)) / SPREAD_EDGE);
          const level = Math.round(a * LEVELS);
          if (!level) continue;
          X[n] = x;
          Y[n] = y;
          R[n] = f.size * (1 + GROW * e);
          L[n] = level;
          counts[level]++;
          n++;
        }
      }

      // Sorted by alpha (a counting sort), then one path per alpha
      for (let l = 1, at = 0; l <= LEVELS; l++) {
        const count = counts[l];
        counts[l] = at;
        at += count;
      }
      for (let m = 0; m < n; m++) order[counts[L[m]]++] = m;
      ctx.fillStyle = f.ink;
      for (let l = 1; l <= LEVELS; l++) {
        if (counts[l] === counts[l - 1]) continue;
        ctx.globalAlpha = l / LEVELS;
        ctx.beginPath();
        for (let m = counts[l - 1]; m < counts[l]; m++) {
          const q = order[m];
          ctx.moveTo(X[q] + R[q], Y[q]);
          ctx.arc(X[q], Y[q], R[q], 0, Math.PI * 2);
        }
        ctx.fill();
      }
      if (ringIn > 0) {
        ctx.globalAlpha = RING_ALPHA * ringIn;
        ctx.strokeStyle = f.ink;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.arc(rx, ry, ring.r, 0, Math.PI * 2);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;

      // The labels follow their place: written only when something changed. The one the ring is on
      // is placed first; one that cannot be read whole, now or a moment from now, waits away (it
      // leaves before the edge or another label reaches it, not after)
      const { zx, zy } = v;
      const on = ringIn > 0 ? ring.on : -1;
      const soon = f.listed ? null : ringOf(f, LOOK);
      const soonLon = follow(v.lon, soon ? soon.lon : v.home[0]);
      const soonLat = follow(v.lat, soon ? soon.lat : v.home[1]);
      const soonOn = ringIn > 0 && soon ? soon.on : -1;
      const later = [];
      const boxes = [];
      const ahead = (p, i) => { later[i] = readable(f, p, cx + (p.u - soonLon) * k - zx, cy - (p.lat - soonLat) * k - zy, boxes); };
      if (soonOn >= 0) ahead(f.places[soonOn], soonOn);
      f.places.forEach((p, i) => i !== soonOn && ahead(p, i));
      boxes.length = 0;
      const sync = (p, i) => {
        const x = sx(p.u) - zx;
        const y = sy(p.lat) - zy;
        if (Math.abs(x - p.x) > 0.05 || Math.abs(y - p.y) > 0.05) {
          p.x = x;
          p.y = y;
          p.el.style.setProperty('--kz-dot-map-x', `${x.toFixed(2)}px`);
          p.el.style.setProperty('--kz-dot-map-y', `${y.toFixed(2)}px`);
        }
        flag(p, 'is-left', p.left);
        flag(p, 'is-away', !(later[i] && readable(f, p, x, y, boxes))); // one leaving keeps no room
        flag(p, 'is-on', p.flags['is-on'] || Math.hypot(x + zx - ox, y + zy - oy) <= front);
        flag(p, 'is-active', on === i);
      };
      if (on >= 0) sync(f.places[on], on);
      f.places.forEach((p, i) => i !== on && sync(p, i));
    },
  };

  // One instance, with its own state: two copies on a page never interfere. Everything it
  // starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
  // 'abort' event), so kodza:destroy stops it all.
  function mount(root, signal) {
    // Each place: its name, then its local time, kept to the minute
    const clocks = [];
    root.querySelectorAll('.kodza-dot-map__place').forEach((el) => {
      const name = document.createElement('span');
      name.className = 'kodza-dot-map__name';
      name.append(...el.childNodes);
      el.append(name);
      let format = null;
      try {
        if (el.dataset.tz) format = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: el.dataset.tz });
      } catch {
        // an unknown time zone: the place stays, without a time
      }
      if (!format) return;
      const time = document.createElement('time');
      time.className = 'kodza-dot-map__time';
      el.append(' ', time); // read as "Osaka 17:07", not "Osaka17:07"
      clocks.push([format, time]);
    });
    let timer = 0;
    const tick = () => {
      const now = new Date();
      clocks.forEach(([format, time]) => {
        const parts = Object.fromEntries(format.formatToParts(now).map((part) => [part.type, part.value]));
        const text = `${parts.hour}:${parts.minute}`;
        if (time.textContent === text) return;
        time.textContent = text;
        time.dateTime = text;
      });
      timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 50);
    };
    if (clocks.length) tick();
    signal.addEventListener('abort', () => clearTimeout(timer));

    const f = field(root, signal, map);
    if (!f) return; // no canvas: the places stay a list, with their times
    // The frame the places are fitted into moves with the text (the web font, a wider column): refit
    const refit = () => {
      if (signal.aborted || !f.width) return;
      view(f);
      if (!f.still) {
        f.wake();
        return;
      }
      f.ctx.clearRect(0, 0, f.width, f.height);
      map.draw(f, f.ctx);
    };
    const ro = new ResizeObserver(refit);
    if (f.list) ro.observe(f.list);
    signal.addEventListener('abort', () => ro.disconnect());
    document.fonts?.ready.then(refit);
  }

  // Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
  // event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
  // markup back. For themes and apps that swap content without a page load. Each running root
  // keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
  // component inside another one is saved as written, not half-started, and stopped with it.
  const KODZA = Symbol.for('kodza');
  const instances = new WeakMap();
  const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
  const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
  const put = (el, saved) => {
    [...el.attributes].forEach((a) => el.removeAttribute(a.name));
    saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
    el.innerHTML = saved.html;
  };
  // The markup inside a root as written: Kodza components already running in it give back their own
  const written = (root) => {
    const all = [...root.querySelectorAll('*')];
    if (!all.some((el) => el[KODZA])) return root.innerHTML;
    const copy = root.cloneNode(true);
    const twins = copy.querySelectorAll('*');
    let last = null;
    all.forEach((el, i) => {
      if (!el[KODZA] || last?.contains(el)) return;
      last = el;
      put(twins[i], el[KODZA]);
    });
    return copy.innerHTML;
  };
  const init = (scope) => find(scope).forEach((root) => {
    if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
    const controller = new AbortController();
    const saved = { html: written(root), attrs: attrsOf(root), stop: () => destroy(root) };
    instances.set(root, { controller, saved });
    root[KODZA] = saved;
    mount(root, controller.signal);
    root.classList.add('is-ready');
  });
  const destroy = (scope) => find(scope).forEach((root) => {
    const instance = instances.get(root);
    if (!instance) return;
    instances.delete(root);
    // The Kodza components running inside it stop first: once its markup is put back, their own
    // script could not find them any more
    root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
    instance.controller.abort();
    delete root[KODZA];
    put(root, instance.saved);
  });
  document.addEventListener('kodza:init', (event) => init(event.detail?.root));
  document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
  init();
  // Loaded in <head>, the script runs before the markup exists: once more when it is parsed
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
```

Free to use, from Kodza (https://kodza.dev).
