# Dotted Map (Moonarc)

A world map drawn as a grid of dots, with markers that pulse. The land is a 1° bitmask from Natural Earth, drawn on the server as one <path>: each run of land cells in a row is a segment, and a dash pattern of zero-length dashes with round caps turns it into dots: a few hundred segments, not five thousand circles. Markers ping on a delay from the stagger token and carry a title. Zero JS.

- Import: `import DottedMap from '@moonarc/core/DottedMap'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/dotted-map.json`
- Tier A · category background · trigger always
- Readout: `<DottedMap markers={[…]} density="medium">`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 4.9 kB raw)
- Page: https://moonarc.dev/components/dotted-map/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `markers` | `{ lat, lng, label? }[]` | `[]` | Cities to mark; a label becomes a <title> and a line in the hidden list. |
| `density` | `'low' | 'medium' | 'high'` | `'medium'` | Grid spacing: 3°, 2° or 1.5°. Denser is more HTML; the path size is on the card. |
| `color` | `string` | `ink at 32 %` | Dot colour. |
| `accent` | `string` | `'currentColor'` | Marker colour. |
| `ping` | `boolean` | `true` | Pulse the markers. |
| `duration` | `number` | `2.4` | Seconds per pulse. |
| `antarctica` | `boolean` | `false` | Include Antarctica (the map is cut at 58° S otherwise). |
| `label` | `string` | `'World map'` | Accessible name; marker labels are appended. |

## Usage

```astro
<div class="relative aspect-[2/1]">
  <DottedMap
    markers={[
      { lat: 40.7, lng: -74, label: 'New York' },
      { lat: 51.5, lng: -0.1, label: 'London' },
      { lat: 1.3, lng: 103.8, label: 'Singapore' },
    ]}
    accent="var(--action)"
  />
</div>
```

## Reduced motion

The rings stand still around the markers at half strength.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- One path, dashed into dots: `M x y h len` per run of land with stroke-dasharray 0 step and round caps puts a dot on every grid point. The path is a few kilobytes where circles would be tens.
- The land data is built once from Natural Earth 110m (public domain) into a 1° bitmask; the map reads it on the server and ships no data to the client.
- The dot radius is 0.42 of the grid step in map units, so density changes both spacing and size together and the map never looks sparse or clogged.
- Markers pulse with scale and opacity on the exit curve, each delayed by its index times the relaxed stagger, so five cities read as a sequence.
- role="img" with the cities in the name, a <title> per marker and a visually hidden list: the map is decorative, the markers are content.

## Replaces

- DottedMap (Magic UI)
- WorldMap (Aceternity)
- dotted-map (npm)

## Source

```astro
---
/**
 * DottedMap — the world as a grid of dots, with markers that pulse. The
 * land comes from a 1° bitmask (Natural Earth 110m, built once into
 * lib/land.ts) and is drawn on the server as one <path>: every run of land
 * cells in a row is a horizontal segment, and a dash pattern of zero-length
 * dashes with round caps turns the segment into dots on the grid — so a
 * 2° map is a few hundred segments, not five thousand circles. Markers are
 * a dot and a ring scaling out on a delay from the stagger token; each
 * carries a <title> and the labels are listed for readers. Zero script.
 */
import type { HTMLAttributes } from 'astro/types';
import { LAND, LAND_H, LAND_W } from '../lib/land';
import { decode, maskAt, toMap, type Marker } from '../lib/geo';

interface Props extends HTMLAttributes<'div'> {
  /** Cities to mark: latitude, longitude and an optional label. */
  markers?: Marker[];
  /** Grid spacing: low 3°, medium 2°, high 1.5°. */
  density?: 'low' | 'medium' | 'high';
  /** Dot colour. */
  color?: string;
  /** Marker colour. */
  accent?: string;
  /** Pulse the markers. */
  ping?: boolean;
  /** Seconds per pulse. */
  duration?: number;
  /** Include Antarctica. */
  antarctica?: boolean;
  /** Accessible name; the marker labels are appended. */
  label?: string;
}

const { markers = [], density = 'medium', color = 'color-mix(in srgb, var(--ma-ink) 32%, transparent)', accent = 'currentColor', ping = true, duration = 2.4, antarctica = false, label = 'World map', class: className, style, ...rest } = Astro.props;
const step = density === 'low' ? 3 : density === 'high' ? 1.5 : 2;
const mask = decode(LAND, LAND_W, LAND_H);
const south = antarctica ? -90 : -58;
const north = 84;
// the drawing is 200 × 100 (2:1, equirectangular); one path, one `M x y h len` per run of land cells in a row
const W = 200;
const segs: string[] = [];
let dots = 0;
for (let lat = north - step / 2; lat > south; lat -= step) {
  let run = 0;
  let x0 = 0;
  const y = Math.round(((90 - lat) / 180) * 100 * 100) / 100;
  const cols = Math.round(360 / step);
  for (let i = 0; i <= cols; i++) {
    const lng = -180 + (i + 0.5) * step;
    const land = i < cols && maskAt(mask, lat, lng);
    if (land) {
      if (run === 0) x0 = Math.round(((lng + 180) / 360) * W * 100) / 100;
      run++;
      dots++;
    } else if (run) {
      segs.push(`M${x0} ${y}h${Math.round((run - 1) * step * (W / 360) * 100) / 100}`);
      run = 0;
    }
  }
}
const d = segs.join('');
const pins = markers.map((m, i) => {
  const [x, y] = toMap(m.lat, m.lng);
  return { ...m, i, x: Math.round(x * W) / 100, y: Math.round(y * 100) / 100 };
});
const viewTop = ((90 - north) / 180) * 100;
const viewH = ((north - south) / 180) * 100;
const name = pins.length ? `${label}: ${pins.map((p) => p.label ?? `${p.lat}, ${p.lng}`).join(', ')}` : label;
const inline = [`--ma-map-step:${(step * W) / 360}`, `--ma-map-color:${color}`, `--ma-map-accent:${accent}`, `--ma-map-dur:${duration}s`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-map', className]} data-ma-map data-dots={dots} data-path-bytes={d.length} style={inline} {...rest}>
  <svg class="ma-map__svg" viewBox={`0 ${viewTop} ${W} ${viewH}`} preserveAspectRatio="xMidYMid meet" role="img" aria-label={name}>
    <path class="ma-map__land" d={d} />
    {pins.map((p) => (
      <g class="ma-map__pin" transform={`translate(${p.x} ${p.y})`} style={`--ma-map-i:${p.i}`}>
        {p.label && <title>{p.label}</title>}
        {ping && <circle class="ma-map__ring" r="1" />}
        <circle class="ma-map__dot" r="0.9" />
      </g>
    ))}
  </svg>
  {pins.some((p) => p.label) && (
    <ul class="ma-map__list">
      {pins.map((p) => p.label && <li>{p.label}</li>)}
    </ul>
  )}
</div>

<style is:global>
  @layer components {
    :where(.ma-map) {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      pointer-events: none;
    }
    :where(.ma-map__svg) {
      width: 100%;
      height: 100%;
      overflow: visible;
    }
    /* dots on the grid: zero-length dashes, one per step, round caps — the dot radius is 0.42 of the step, in map units */
    :where(.ma-map__land) {
      fill: none;
      stroke: var(--ma-map-color);
      stroke-width: calc(var(--ma-map-step, 1.11) * 0.84);
      stroke-linecap: round;
      stroke-dasharray: 0 var(--ma-map-step, 1.11);
    }
    :where(.ma-map__dot) {
      fill: var(--ma-map-accent, currentColor);
    }
    :where(.ma-map__ring) {
      fill: none;
      stroke: var(--ma-map-accent, currentColor);
      stroke-width: 0.35;
      transform-box: fill-box;
      transform-origin: center;
      opacity: 0;
      animation: ma-map-ping var(--ma-map-dur) var(--ma-ease-out) infinite;
      animation-delay: calc(var(--ma-map-i, 0) * var(--ma-stagger-relaxed) * 3);
    }
    :where(.ma-map__list) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-map__ring) {
        animation: none;
        opacity: 0.5;
        scale: 2;
      }
    }
  }

  @keyframes ma-map-ping {
    0% {
      opacity: 0.9;
      scale: 1;
    }
    100% {
      opacity: 0;
      scale: 4;
    }
  }
</style>

```
