Skip to contentVibraUI
Charts

World map

A dotted world map with a proportional marker wherever something happened.

The land is dotted-map's own lattice — 60 rows of a diagonal grid, mercator, the default window of −168°..168° — drawn as one path rather than three thousand circles: a subpath that is a lone moveto renders as a filled dot under a round stroke cap, which keeps the markup a few kilobytes and the DOM one node. Laying that lattice out costs a couple of hundred milliseconds, so it is built once on first use and kept for the life of the process; render the map from a server component and the reader never pays for it or downloads the library at all. Area carries the value, so a marker's radius goes as the square root of it — a country with twice the traffic draws twice the area, which is the one scaling a reader estimates correctly — with a floor so the smallest is still something to point at, and a ring in the surface colour so two that touch still read as two. A marker is painted in --brand and the land in --muted-foreground, both already measured against the card. To a screen reader the plot is one image with a summary, and every marker is repeated underneath as text; each marker also carries a <title>, which is the tooltip a browser shows on hover with no JavaScript at all. A place the projection cannot put on this map — a pole, or a longitude past the window — is left out rather than drawn at the edge. The box's height is a class that reads --world-map-height, which height sets, never an inline height: a height class of the page's own, such as max-sm:h-44 for a shorter box on a phone, takes over at its breakpoint with no !important.

Install

npx shadcn@latest add @vibra/world-map

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
markers{ id: string; lat: number; lng: number; value: number; label: string }[]—One marker per place. The largest value sets the scale the rest are drawn against.
valueFormatter(n: number) => stringthousands-separated numberFormats every number the map prints: the tooltips, the text rows and the summary.
heightnumber280The height of the map's box in pixels; the map keeps its own proportions inside it. A height class in className — h-44, max-sm:h-44 — wins over it.
aria-labelstringa generated summaryReplaces the summary a screen reader hears in place of the plot.

Dependencies

Source

components/ui/world-map.tsx
import * as React from "react"
import DottedMap from "dotted-map"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"

/**
 * The land, as a lattice of dots. 60 rows of a diagonal grid is fine enough for
 * a coastline to read at card width and coarse enough to stay a stipple rather
 * than a photograph.
 */
const GRID_HEIGHT = 60

/** How wide a dot is drawn, in grid units — a little under the 1-unit spacing. */
const DOT_WIDTH = 0.62

/** The largest marker, in grid units, and the floor a small one never drops under. */
const MAX_RADIUS = 3.4
const MIN_RADIUS = 0.9

/** The ring that keeps two overlapping markers apart, in grid units. */
const MARKER_RING = 0.35

const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

type Land = { path: string; width: number; height: number; project: DottedMap }

// Built on first use and kept, because laying out the grid costs a couple of
// hundred milliseconds: on a server that is once per process, and a page that
// never draws a map never pays it at all.
let land: Land | undefined

/**
 * The land dots as one path rather than three thousand circles.
 *
 * A subpath that is a lone `moveto` is drawn as a filled dot when the stroke has
 * round caps, which is what turns the whole lattice into a single element: the
 * markup stays a few kilobytes and the DOM stays one node deep, where a circle
 * per dot is three thousand nodes on every page that shows a map.
 */
function landDots(): Land {
  if (land) return land
  const map = new DottedMap({ height: GRID_HEIGHT, grid: "diagonal" })
  const path = map
    .getPoints()
    .map((point) => `M${round(point.x)} ${round(point.y)}h0`)
    .join("")
  land = { path, width: map.image.width, height: map.image.height, project: map }
  return land
}

/** One decimal is a fiftieth of a dot's width — invisible, and a third of the bytes. */
function round(value: number): number {
  return Math.round(value * 10) / 10
}

export type WorldMapMarker = {
  id: string
  lat: number
  lng: number
  value: number
  label: string
}

export type WorldMapProps = React.ComponentProps<"div"> & {
  markers: WorldMapMarker[]
  valueFormatter?: (n: number) => string
  /**
   * The height of the map's box in pixels; the map keeps its own proportions
   * inside it. A height class in `className` — `h-44`, `max-sm:h-44` — wins
   * over it, so a page can give the map another box at a breakpoint.
   */
  height?: number
}

/**
 * A dotted world map with a proportional marker at each place.
 *
 * Area carries the value, so the radius goes as its square root — a country
 * twice another's traffic draws a marker twice the area, not twice the width,
 * which is the one scaling a reader estimates correctly. A marker under the
 * floor would be a speck nobody can point at, so the smallest is still a dot.
 *
 * The map itself is decoration for a screen reader — it is one `role="img"` with
 * a summary — and every marker is repeated underneath as text, so the numbers
 * are readable without a pointer. Each marker also carries a `<title>`, which is
 * the tooltip a browser shows on hover without any JavaScript at all.
 */
function WorldMap({
  className,
  style,
  markers,
  valueFormatter = DEFAULT_FORMAT,
  height = 280,
  "aria-label": ariaLabel,
  ...props
}: WorldMapProps) {
  const { path, width, height: rows, project } = landDots()

  // A marker is placeable only when it has a measurable value *and* a point
  // the projection can plot — a latitude the projection cannot place, a pole
  // under Mercator, has no point on this map, and leaving it out beats
  // drawing it at the edge. Computed once, so the drawing, the summary and
  // the text rows can never name a different set of markers than each other.
  const placeable = markers.flatMap((marker) => {
    if (!Number.isFinite(marker.value)) return []
    const pin = project.getPin({ lat: marker.lat, lng: marker.lng })
    if (!pin) return []
    return [{ marker, x: round(pin.x), y: round(pin.y) }]
  })
  const values = placeable.map(({ marker }) => marker.value)
  const peak = values.length > 0 ? Math.max(...values) : 0
  const summary =
    ariaLabel ??
    (values.length === 0
      ? "World map with no markers."
      : `World map with ${placeable.length} marker${placeable.length === 1 ? "" : "s"}, ${valueFormatter(
          Math.min(...values)
        )} to ${valueFormatter(peak)}.`)

  const placed = placeable.map(({ marker, x, y }) => {
    const share = peak > 0 ? Math.sqrt(Math.max(0, marker.value) / peak) : 0
    return { marker, x, y, r: Math.max(MIN_RADIUS, MAX_RADIUS * share) }
  })

  return (
    <div
      data-slot="world-map"
      // The height travels as a custom property the class reads, not as an
      // inline height no class could outrank.
      className={cn("h-(--world-map-height) w-full", className)}
      style={{ "--world-map-height": `${height}px`, ...style } as React.CSSProperties}
      {...props}
    >
      <svg
        role="img"
        aria-label={summary}
        viewBox={`0 0 ${width} ${rows}`}
        preserveAspectRatio="xMidYMid meet"
        className="h-full w-full"
      >
        <path
          data-slot="world-map-land"
          d={path}
          fill="none"
          stroke="var(--muted-foreground)"
          strokeWidth={DOT_WIDTH}
          strokeLinecap="round"
        />
        <g data-slot="world-map-markers">
          {placed.map(({ marker, x, y, r }) => (
            <circle
              key={marker.id}
              data-slot="world-map-marker"
              cx={x}
              cy={y}
              r={round(r)}
              fill="var(--brand)"
              // Ringed in the surface it sits on, so two markers that touch
              // still read as two.
              stroke="var(--chart-surface, var(--card))"
              strokeWidth={MARKER_RING}
            >
              <title>{`${marker.label}: ${valueFormatter(marker.value)}`}</title>
            </circle>
          ))}
        </g>
      </svg>

      {/* The same rows as text: a map a reader cannot see is still a table. */}
      <ul data-slot="world-map-data" aria-label="World map data" className="sr-only">
        {placeable.map(({ marker }) => (
          <li key={marker.id}>{`${marker.label}: ${valueFormatter(marker.value)}`}</li>
        ))}
      </ul>
    </div>
  )
}

export { WorldMap }