Embedding Projection
A four-state 2-D embedding projection in plain SVG: clusters wrapped in trimmed convex-hull blobs with numbered centroid badges, a nearest-mark hover readout that prints the item's own text, a cross-tab that always adds to 100, and a caveat naming the groups whose distances mean nothing.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-embedding-projection.jsonPrompt
Build a React + TypeScript + Tailwind "ChartEmbeddingProjection" card in plain
SVG with zod. Not recharts: the marks are the easy part — the work is a trimmed
convex outline per group, ordinal badges that push each other apart, a cross-tab
that has to add to exactly 100, and a caveat sentence that names the groups it
is about.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
method: { name: string; sourceDim?: number;
params?: Record<string, number>; trustworthiness?: number };
points: { id: string; x: number; y: number;
label?: string; cluster?: string; text?: string }[];
clusters?: { id: string; label: string }[];
colorBy?: "cluster" | "label" }
refined so "ready" requires at least one point.
- Props = z.infer of that schema plus hullCoverage? (0.5–1, default 0.9),
unassignedLabel?, unlabelledLabel?, onRetry?, and the div's own attributes
minus `title`. No hand-written parallel interface. forwardRef, and spread the
remaining props on the root.
- x / y are whatever the reducer emitted — unbounded, negative, asymmetric. The
component never runs UMAP; it draws coordinates it was handed.
- `cluster` is the unsupervised channel, `label` the supervised one. Points may
carry neither, one, or both.
Behavior
- Four first-class branches in one bg-card panel: loading (square plot skeleton
with three blobs and rail chips, aria-hidden), empty (two dashed blobs and
"run <method.name> upstream"), error (message plus a Try again button only
when onRetry exists), ready. A "ready" payload whose rows are all unplottable
renders the empty branch — half a projection has a shape, and that shape
would be wrong.
- Layout: ONE shared scale for both axes into a square 100x100 view box, y
flipped, centred. Never fit x and y independently: a projection's coordinates
carry no unit, and stretching one axis invents neighbourhoods.
- colorBy picks the channel that groups, colours, outlines and counts. The other
channel becomes the cross-tab shown for whichever group is selected, so
flipping the prop asks "does my clustering agree with my taxonomy?".
- Group order: the caller's `clusters` order first (so a cluster keeps its
colour across a re-fetch), then first appearance. Rows with no value in the
active channel form a last bucket drawn muted, with NO centroid and NO
outline — noise is not a cluster and giving it a centre invents one.
- Outline per group: sort members by distance from the centroid, keep the
innermost ceil(coverage x n), take the convex hull (monotone chain, collinear
vertices dropped), push every vertex a fixed pad away from the hull's own
centre, then close a quadratic spline through the edge midpoints. Fewer than
three hull vertices falls back to a circle.
- The trimmed set is the REQUEST, not the result: the spline cuts each corner
back by 0.125 x |v_prev + v_next - 2v|, which on a spike beats any fixed pad,
so a kept member can land outside the curve and a dropped one well inside it.
Sample the drawn spline into a polyline and count the members actually inside
it ("outline encloses 43 of 46"). Build the `d` attribute and the polyline
from ONE arc list, so the shape that is drawn and the shape that is counted
cannot drift apart, and never print "the k innermost" as if smoothing had not
happened.
- Centroids are ORDINAL BADGES, not names: a numbered 20px chip cannot collide
the way a cluster name does, so there is no label-dropping solver and nothing
clips at the edge. Push overlapping badges apart, clamp them inside the frame,
and for any badge that had to move draw a dashed leader plus a tick at the
real centroid.
- Pointer: one transparent hit rect over the plot picks the NEAREST mark within
a radius, not per-mark pointerenter — at a few hundred marks the gaps are
wider than the dots. Ignore touch MOVE (that is a scroll); a touch DOWN still
picks.
- Keyboard: the group rail is a role="radiogroup" of role="radio" buttons ("All"
plus one per group) with roving tabindex; arrows move AND select, Home/End
jump, and every move writes a sentence into an sr-only role="status".
- Selecting a group dims the rest to 20% and restricts picking to that group, so
the readout can never describe something the reader cannot see. A selection
pointing at a group that vanished falls back to "all" during render, not in an
effect.
- Readout box, one branch at a time, min-height fixed so nothing jumps: the
hovered item wins (group, other-channel value, the item's text verbatim,
distance to its centroid, id); else the selected group (count, share, outline
coverage, spread, cross-tab bar and list, and the member nearest the centroid);
else a one-line verdict plus how to drive the card.
- Every percentage that partitions a whole — group shares of the projection, and
the cross-tab inside a group — is apportioned by LARGEST REMAINDER (floor,
hand the leftovers to the largest fractions, ties to the bigger count then the
earlier index), so each set adds to exactly 100 in every filter state.
- Rows with a repeated id or a non-finite coordinate are dropped and COUNTED in
the summary line: a silently thinner cloud is indistinguishable from a correct
one.
- The caveat is not boilerplate. It names the method, the source dimensionality
and three real group names, and says that the distance and direction between
outlines are artefacts of the layout. Take those three names from the GEOMETRY
— the largest outlined group, then its nearest and its farthest neighbour by
centroid distance in view units — never from group order, or the card prints
"A looks nearer B than C" while the plot shows the opposite; fall back to the
generic wording with fewer than three centroids. A scale bar (largest
1/2/2.5/5 x 10^n under a quarter of the extent) gives the one distance worth
reading.
Rendering & styling
- Colour comes only from tokens: group i uses --chart-{(i % 5) + 1} for its
marks, its outline stroke, its badge border and its rail swatch — one index,
four consumers, so they cannot drift apart. Outline fill is
fill-chart-N/12 dark:fill-chart-N/24, doubled in dark because a 12% tint of a
bright token lands within a few sRGB steps of a near-black card; the stroke
stays full strength so the fill is never the only carrier of identity.
- The cross-tab uses neutral ink steps (bg-foreground/75 down to /16), never
chart tokens: a slice painted --chart-3 would read as group 3.
- Panel: rounded-xl border bg-card; muted-foreground for secondary text;
destructive reserved for the error branch; tabular-nums on every number that
can change; cn() merges className. No axis ticks at all — a projection axis
has no unit, and drawing one invites reading it.
- SVG strokes use vectorEffect="non-scaling-stroke" so hairlines stay hairlines
at any card width; centroid badges and the scale-bar caption are HTML overlays
positioned in percent, because view units are already percentages of the plot.
- Motion: opacity transitions only, each with motion-reduce:transition-none, and
the loading pulse with motion-reduce:animate-none. Nothing animates position.
- a11y: the plot is role="img" with a sentence-long aria-label (the marks are
not focusable, so nothing inside needs a name of its own); every figure it
draws is repeated in an sr-only table; the rail carries focus-visible rings.
Customization levers
- hullCoverage: 1 draws the true convex hull, outliers and all; 0.8 gives a
tight core blob. The card measures the drawn curve and prints what it enclosed
either way, so tightening it never becomes a lie.
- Outline shape: drop the spline for a hard-edged hull, or raise the offset pad
for softer, deliberately overlapping blobs.
- colorBy: expose it as a toggle above the card, or lock one channel and delete
the cross-tab block entirely if your corpus has only one.
- Density: the mark radius already steps down past 260 and 600 points; past a
few thousand, sample with a stride upstream rather than drawing them all.
- Badges: swap the ordinal for a two-letter code, or delete the badge layer and
put names back at the centroid if you have four or fewer separated groups.
- Readout: it is the natural home for an "open this item" action — take an
onSelect and render the hovered id as a button; the card wires no navigation
of its own.
- Palette: the colour formula cycles 5 chart tokens, and past five groups the
badge ordinal is what keeps them apart — keep the badges if you expect more.
- Copy: unassignedLabel / unlabelledLabel rename the two "missing value"
buckets; the caveat paragraph is one block and can be swapped wholesale for a
linear reducer, where between-cluster distance IS readable.Concepts
- Trimmed hull, not a hull — and the drawn curve is what gets counted — a convex hull is decided entirely by its extremes, so one stray member drags a lobe across half the plot. The outline is asked to wrap the innermost
ceil(coverage x n)members by distance from the centroid, but that is only the request: the corner-rounding spline cuts each vertex back, so the card samples the curve it actually drew, tests every member against it, and prints that ("outline encloses 62 of 68"). The number can only ever describe the shape on screen. - One channel, four consumers — a group's position in the group order picks its
--chart-Nfor the marks, the outline stroke, the badge border and the rail swatch at once. Nothing can drift, and re-theming the host re-colours the whole plot for free. - Ordinal badge instead of a floating name — cluster names collide at the centroid and every fix is bad (drop the label, nudge it into a lie, shrink it to unreadable). A numbered chip is small enough to never need a solver, the rail is its key, and any badge that still had to move gets a leader line back to the true centroid.
- Nearest-mark pick — the hit target is one transparent rect, not several hundred circles: the reader points at a neighbourhood and the card resolves the nearest mark within a radius. Selecting a group narrows what is pickable to what is still bright, so the readout can never name something that has been dimmed away.
- Cross-tab against the inactive channel — whichever field is not colouring the plot becomes the composition of the selected group, apportioned by largest remainder so it reads exactly 100. That single panel is the cluster-purity answer: "this cluster is 81% performance, 9% context".
- The caveat is derived, not decorative — the sentence names the reducer, the source dimensionality and three of the card's own groups: the largest one, its nearest neighbour on screen and its farthest, measured between centroids in view units. Taken in group order instead, the sentence would describe an appearance the plot contradicts — a generic "distances may be misleading" gets skimmed, but a wrong specific one gets believed. Inside a blob, closeness is real; between blobs, the gap, the direction and the blob size are all layout, which is also why the axes carry no ticks and a scale bar carries the only readable distance.
Attention Heads
A small-multiples grid of causal attention maps — one miniature per (layer, head), sortable by row entropy or previous-token mass, with the picked head enlarged beside it under token labels on both axes and its mass split into four shares that add to exactly 100.
Eval Leaderboard
A model × benchmark leaderboard: every column normalised in its own direction, cells shaded with the raw score printed, per-column bests marked, rank with movement arrows against the previous snapshot, and a weighted index whose whole-percent weights always total 100.