Charts

Voronoi Territory Map

A four-state Voronoi tessellation that derives territory from seed positions — half-plane clipping, an optional capacity-weighted power diagram, map colouring so no two neighbours share a fill, direct labels elided to the cell that holds them, and a lift on hover or focus.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  cellAdjacency,
  isFiniteNumber,
  mapColors,
  polygonArea,
  polygonCentroid,
  polygonInradius,
  polygonPath,
  powerRadii,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-voronoi.json

Prompt

Build a React + TypeScript + Tailwind "ChartVoronoi" card in plain SVG with zod.
Recharts has no tessellation primitive and none of this is a cartesian series, so
the diagram, the colouring and the label fitting are all done by hand, in small
pure functions that live beside the schema.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    description?: string;
    x: { label: string; unit?: string; domain?: [number, number] };
    y: { label: string; unit?: string; domain?: [number, number] };
    noun?: { one: string; many: string };
    weight?: { label: string; unit?: string };
    sites: { id: string; label: string; x: number; y: number;
             weight?: number }[] }
  A site needs an id, because a cell is hovered, focused, selected and tabled and
  every one of those needs a handle that survives a re-tessellation; and a label,
  because a territory's identity is carried by text printed inside it, never by
  its fill. TERRITORY IS NEVER SUPPLIED: a cell is a consequence of where every
  other site sits, so a pre-computed polygon feed goes stale the moment one seed
  moves.
- superRefine rejects a ready chart with zero sites, duplicate ids, and any fixed
  domain whose two bounds are equal.
- Props = z.infer of the schema plus weighting: "none" | "power" (default
  "none"), weightStrength (0-1.5, default 0.7), fill: "separate" | "share"
  (default "separate"), showPoints (default true), pointsToggle (default true),
  showLabels: boolean | "auto" (default "auto"), locale (default "en-US"),
  formatX / formatY, onSelect, onRetry, className and the rest of the div's
  native props. forwardRef to the card element.
- Export the maths beside the schema so a test can print the same numbers the
  picture is made of: powerBisector(), clipHalfPlane(), voronoiPolygons(),
  polygonArea(), polygonCentroid(), polygonInradius(), polygonPath(),
  cellAdjacency(), mapColors(), powerRadii(), resolveAxisScale(), niceStep().

Behavior
- THE FRAME IS THE SUBJECT, not the backdrop. A Voronoi cell is unbounded until
  something bounds it, so the plot rectangle is what gets divided and every share
  is a share OF THAT RECTANGLE. Shares therefore always add to 100%: a
  tessellation leaves no gaps and no overlaps, and that invariant is worth
  stating in the summary because it is what makes the areas comparable at all.
  Resolve each axis first (fitted domains snapped outward onto the tick step,
  fixed domains honoured exactly), then project seeds into view units, then
  tessellate.
- TESSELLATION BY HALF-PLANE CLIPPING. Each cell starts as the frame polygon and
  is clipped, once per rival, by the half-plane of points closer to this seed
  than to that one (Sutherland-Hodgman: keep the vertices that satisfy the plane,
  insert the crossing point wherever an edge changes side, and treat fewer than
  three survivors as no cell at all). It is O(n^2) and worth it: exact, no
  incremental structure to keep in sync, and identical code for both modes. It
  stays comfortable into the low hundreds of seeds; past that reach for a
  sweep-line or Delaunay-dual implementation.
- WEIGHTED MODE IS A POWER (LAGUERRE) DIAGRAM, and it is linear for a reason.
  Power distance is |p - s|^2 - r^2; expanding pd_i(p) <= pd_j(p) cancels |p|^2
  on both sides, so what remains is a straight line and a weighted cell is still
  a convex polygon clipped by the same clipper. The border sits (r1^2 - r2^2)/2d
  off the midpoint. Multiplicative weights would give circular arcs and need a
  different renderer entirely — do not reach for them casually.
  Normalise the plane by the distance between the two seeds so that a*x + b*y - c
  reads as a signed DISTANCE in view units; that one division is what lets the
  clipper and the adjacency test share a single plain epsilon instead of each
  guessing a tolerance for squared coordinates.
  powerRadii() turns weights into radii: r = strength * sqrt(area / n) *
  sqrt(w / wMax). Weights are normalised against the heaviest, so doubling every
  weight changes nothing — only ratios are readable, which is all a weighted
  diagram claims to show.
- AN EMPTY CELL IS AN ANSWER, NOT A FAILURE. Two sites on the same coordinates
  cannot both own the spot (break that tie by weight, then by index, so the
  picture never depends on iteration order), and in a weighted diagram a light
  site beside a heavy one can be dominated outright. Both are counted, named by
  reason ("standing on another seed" vs "outweighed by a neighbour"), listed in
  the table and drawn as a hollow seed marker — never silently skipped.
- ADJACENCY IS MEASURED ON THE FINISHED POLYGONS, not remembered from the
  clipping order: a plane that cut the cell early can be sliced away again by a
  later one, and a border that no longer exists is not a border. For each edge
  midpoint, ask whether it lies on the bisector with some rival. Skip zero-length
  edges first — four cells meeting at one point leave a duplicate vertex there,
  and without that guard a symmetric four-seed square reports its two DIAGONAL
  cells as neighbours (measured: adjacency [[1,2,3],[0,2,3],...] instead of
  [[1,2],[0,3],...]). That one array feeds three things: the colouring, the
  arrow-key map and the border count in the readout.
- MAP COLOURING. Fills exist to keep neighbours apart, so colour them like a map:
  smallest-last (degeneracy) ordering, then greedy. A Voronoi adjacency graph is
  planar and planar graphs always hold a vertex of degree <= 5, which is what
  keeps the greedy pass inside five tints instead of drifting up with the input
  order. Five is not a theorem, so when a cell really does have all five taken it
  takes the one its neighbours use least AND gets a hatch, and the note says how
  many cells needed one.
- LABELS FIT OR ELIDE, and the cell decides which. polygonInradius() is the
  radius of the largest circle that fits at the centroid — and the centroid of a
  convex polygon is always inside it, which is why no point-in-polygon test is
  needed. "auto" prints a name once that radius clears 13 units and adds the
  share on a second line past 26; the name is cut to the characters the radius
  holds (2 * r * 0.9 / (fontSize * 0.58)) with an ellipsis, and dropped entirely
  under three characters. Nothing overflows a border, and nothing is lost: the
  full name stays in the SVG <title>, the readout line, the aria-label and the
  table.
- KEYBOARD, BECAUSE HOVER IS NOT A PATH. One roving tab stop over the cells;
  Tab lands on the LARGEST territory, since on a territory map that is the
  finding. The stop is stored as a site id, not an index, so it survives a
  re-tessellation.
    ArrowLeft / Right / Up / Down  the neighbour lying most nearly in that
                                   direction (cosine against the centroid
                                   vector, minimum 0.3) — navigation follows the
                                   borders, because a tessellation has no rows
    Home / End                     largest / smallest territory
    Enter / Space                  onSelect(cell) when onSelect is given;
                                   without it Space is left alone so the page
                                   still scrolls
- THE SEED-POINT TOGGLE IS PAINT ONLY. It shows or hides the markers and never
  re-tessellates; it is a real button with aria-pressed, and it announces the new
  state through a polite live region that starts empty so nothing is spoken on
  mount. Never use the native disabled attribute on it.
- DEGENERATE DATA is the test that matters. Zero sites, or zero drawable sites,
  renders the empty branch and names how many arrived without a usable position.
  One site claims 100% of the frame with no borders (its fitted, zero-width
  domain is padded by +/-50% first). Non-finite coordinates are dropped and
  COUNTED in the visible note. A site outside a FIXED frame is kept, not dropped:
  it still owns the ground nearest to it, and dropping it would hand that ground
  to the wrong owner — instead an arrow on the frame edge points the way and the
  note says how many there are.
- CLEANUP: there is nothing to tear down. No timer, no rAF, no listener, no
  ResizeObserver — responsiveness is viewBox + preserveAspectRatio, and the plot
  is a pure function of props plus its interaction state (hover, focus, the
  roving cursor, the point layer). Keep it that way; a chart that leaks is a
  chart with an observer in it.

Rendering & styling
- COLOUR. Everything comes from var(--chart-1) .. var(--chart-5) plus the
  semantic tokens (bg-card, text-muted-foreground, stroke-border, stroke-ring,
  fill-foreground). No hex, no invented hue. In fill="separate" the tint is a
  cycling palette index and MEANS NOTHING — say so under the chart, because a
  reader who assumes a scale where there is none is worse off than one with no
  colour at all. In fill="share" the tint is a position on the same five tokens
  read as a sequential ramp (they are ordered monotone in lightness in both
  themes), mixing neighbouring stops with color-mix(in oklab, ...) so the scale
  reads continuously.
- COLOUR IS NEVER ALONE. Area is the value and area is visible; the share is
  printed inside every cell that has room; the hatch separates the rare cell that
  could not get a distinct tint; and every number is in the readout, the
  aria-label and the ranked table. A legend is never the only way to reach a name.
- Cells carry a --card stroke so borders read as seams rather than outlines, and
  fill-opacity 0.85 at rest. Seed markers are a --card halo behind a foreground
  dot, hollow when the site owns no ground. Text gets a --card halo through
  paint-order: stroke, the SVG equivalent of a text outline.
- LIFT. Hover or focus contracts one cell to 0.965 about its own centroid, so a
  card-coloured seam opens around it and it reads as lifted out of the mosaic —
  a scale INWARD, never outward, because the tessellation has no spare room and
  an outward lift would slide under its neighbours. Add a foreground outline
  (dashed --ring for focus) drawn above every cell, and take fill-opacity to 1.
  The transition is motion-safe gated; with animation off the lift is instant and
  nothing is lost.
- ACCESSIBILITY. The plot lives in a <figure> labelled by the card heading and
  described by an sr-only paragraph carrying the FINDING, not a coordinate dump:
  how many territories divide the rectangle, which is largest and smallest, that
  the shares add to 100%, both axis ranges, and anything not drawn. Each cell is
  role="img" (or role="button" with onSelect) with its own label. Below, an
  sr-only WRAPPER DIV holds a real table ranked by territory, with position,
  weight, share and border count, plus a row for every site that holds no
  territory; cap it at 120 rows and account for the tail in the caption. Put
  sr-only on the wrapper, never on the table: CSS width is only a lower bound for
  a table box, so width:1px does not hold one back and a narrow viewport picks up
  hundreds of px of horizontal scroll.
- The visible readout line follows hover or focus and is aria-hidden on purpose —
  a focused cell already announces itself, and a live region would say all of it
  twice. The live region is reserved for the point toggle, which nothing else
  announces.
- Ticks sit OUTSIDE the frame with no interior gridlines: a filled mosaic hides
  them, and a gridline you cannot see is ink with no reader. Axis and tick text is
  text-muted-foreground at 12-13 units; the frame and ticks are --border.
- Motion: the only animation is the loading mosaic's pulse (motion-reduce:
  animate-none) and the lift transition (motion-safe only). The skeleton is the
  real tessellator run over a hard-coded seed list, so it is deterministic and
  identical on the server and the client.

Customization levers
- weighting / weightStrength: the biggest knob. "none" answers "who is nearest",
  "power" answers "who can actually take it". Strength 0 collapses to the
  unweighted diagram, 0.7 is a visible tilt, 1.5 lets a heavy site swallow a
  neighbour — turn it up until the picture disagrees with the unweighted one in
  the places you can defend.
- fill: "separate" when the reader is tracing borders and ownership, "share" when
  they are ranking territories. To re-point the palette, change the token run in
  one function; the tint index and the ramp fraction are the only things the
  colour functions consume.
- showLabels / showPoints / pointsToggle: full labels for a full-width panel,
  showLabels={false} plus showPoints={false} for a dashboard tile where the
  tooltip and readout carry the names. Loosen MIN_LABEL_RADIUS / TWO_LINE_RADIUS
  to print more (and accept tighter type), or raise them for a denser network.
- Axes: fix both domains whenever two renders have to be comparable — the frame
  IS the denominator, so a fitted frame silently redefines every share. formatX /
  formatY take over the ticks for coordinates, currencies or SI units.
- Interaction: onSelect turns every cell into a button — wire it to a drill-down
  that filters the underlying rows by ownership. The readout line, the note under
  the chart and the summary are the three places to reword for a domain that is
  not depots; noun and weight in the contract cover the rest.

Concepts

  • Derived territory — the cells are not data, they are a consequence of the data. Nothing upstream decides how much ground a depot gets: move one seed and every neighbouring border moves with it. That is the whole reason to reach for a tessellation instead of a treemap, where the areas are numbers somebody handed you.
  • Half-plane clipping — a cell is "everything closer to me than to you", intersected over every rival. Each of those is one straight cut, so a cell is built by starting from the frame and clipping it once per rival, and it stays convex by construction — which in turn is why the centroid can be trusted as a label anchor and why the same code serves the weighted mode.
  • Power weighting — additive weights move a border off the midpoint by (r₁² − r₂²) / 2d and keep it straight, because the quadratic terms cancel. That linearity is the whole trick: a capacity-weighted map is still polygons, still clipped by the same function, and a small site hemmed in by two large ones can honestly end up with nothing.
  • Map colouring, not a colour scale — with fill="separate" the fills carry no value at all; their only job is that no two territories sharing a border share a tint. Smallest-last greedy colouring gets that done in five tokens on a planar graph, and the rare cell that still collides gets a hatch instead of a sixth invented hue.
  • The frame is the denominator — a Voronoi cell is unbounded until something bounds it, so every share is a share of the plotted rectangle and the shares add to exactly 100%. Fitting the frame to the data silently redefines that denominator, which is why a comparable map pins both domains.
  • Counted, not hidden — a seed with no usable position, a seed standing on another, a seed outweighed into nothing, a seed outside the frame: four ways for a row to fail to become a territory, and each is named under the chart and in the table rather than quietly disappearing between the payload and the picture.

On This Page