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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-voronoi.jsonPrompt
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₂²) / 2dand 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.
Circle Packing
A four-state zoomable circle packing in hand-rolled SVG — front-chain sibling packing with a verified smallest enclosing circle, area strictly proportional to value, zoom as a re-projection rather than a re-layout, keyboard-walkable circles and an sr-only breakdown table.
Adjacency Matrix
A graph drawn as its adjacency matrix — one node set on both axes, cells carrying edge weight, reorderable by name, degree or cluster, with a row-and-column crosshair, directed or symmetric halves, and a foldable triangle.