Embedding Map
A 2-D embedding scatter in pure SVG — clusters carried by chart token and mark shape, centroid names that yield instead of overlapping, a query wired to the retriever's own ranking, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/embedding-map.jsonPrompt
Build a React + TypeScript + Tailwind "EmbeddingMap" component with zod and
lucide-react. The scatter is hand-drawn SVG — no chart library — because every
mark needs its own accessible name, focus target and hit area, which a
generic chart renderer will not give you.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
points: { id, x: number, y: number, cluster: string, label: string,
score?: number, meta?: string }[];
clusters?: { id, label }[];
query?: { label, x, y, neighborIds?: string[] } | null }.
- `x` / `y` are raw reducer output (UMAP, t-SNE, PCA): any range, negative,
unbounded. The component fits the extent itself.
- There is NO per-point colour field. Colour comes from the cluster's index in
the cluster order, so it always resolves to --chart-1..5; a hex on the data
would be the one thing a theme swap could not reach.
- `clusters` is optional and does two jobs: human names, and a STABLE order —
the order is what pins each cluster's colour and shape across a re-fetch that
returns the points shuffled. Without it, first appearance decides.
- `query.neighborIds` is the ranking from the vector store, in the
high-dimensional space where the search actually ran, nearest first.
- Component props = z.infer of the schema plus: aspect (1.6), padding (6 view
units), pointRadius (0.9), maxPoints (1200), neighborCount (5),
showClusterHalos, showCentroidLabels, showLegend, labelMinWidth (320px),
selectedPointId / defaultSelectedPointId, onSelectPoint(point | null),
onClusterIsolate(clusterId | null), onRetry, renderTooltip, formatScore,
emptyState, errorMessage, skeletonRows/skeletonColumns, labels (every string,
merged over the defaults), label ("Embedding map"), className plus the rest of
the div props, ref forwarded to the root.
- Every numeric prop is clamped (aspect 0.5–3, pointRadius 0.3–4, maxPoints
>= 1) so a 0 or a NaN can never produce an empty or infinite plot.
Behavior
- PROJECTION USES ONE SCALE FOR BOTH AXES. Compute the extent over the sampled
points AND the query, then scale by min(innerW/spanX, innerH/spanY) and centre
the result — letterboxing the spare room. Fitting x and y independently would
stretch one axis and invent neighbourhoods that are not in the data, which is
the one thing a projection must never do. Flip y on the way out (data +y is
up, SVG +y is down).
- Degenerate extents are centred, not divided by. A single point, or a whole
column of identical x, gives span 0: widen that axis to 1 unit around the data
so the cloud sits in the middle instead of collapsing onto the left edge.
- Rows with a non-finite coordinate, and rows repeating an id already seen, are
DROPPED AND COUNTED, and the count is printed in the footnote. A silently
thinner cloud is indistinguishable from a correct one. (Duplicate ids also
collide on the React key and on the keyboard slot.)
- Over `maxPoints`, sample with a STRIDE across the array, never `slice(0, cap)`:
data that arrives grouped by cluster would lose whole clusters to a head
slice, while a stride keeps every cluster's share. Then force-keep every id in
`query.neighborIds`, or a connector would point at a dot that is not drawn.
The footnote says "Showing 700 of 3,964 points (evenly sampled)".
- THE QUERY'S NEIGHBOURS ARE THE CALLER'S RANKING, not the picture's. When
`neighborIds` is present, draw a dashed connector to each one and ring the
points in exactly that order (the rank itself is in the tooltip and in the
accessible name, where it can be read); ids that are not in `points` are
counted and reported in the footnote, never invented.
Only when the caller has no ranking does the component fall back to the 2-D
nearest — and the footnote then admits the neighbours are estimated. The
projection dropped hundreds of dimensions; the visually closest dot is
regularly not the top hit, and a confident line drawn from the picture is a
lie about the retriever.
- The neighbourhood ring around the query is the MEDIAN neighbour distance, not
the maximum. One far-but-genuinely-relevant hit — exactly the case
`neighborIds` exists for — would otherwise inflate a ring that swallows the
card and says nothing; the median ring means "half the retrieved set is
inside".
- Cluster identity travels on TWO channels: colour = index % 5 → --chart-N, and
mark shape = floor(index / 5) % 5 → circle / square / triangle / diamond /
cross. Five tokens alone cannot separate cluster 6 from cluster 1, and colour
alone fails in greyscale and for a colour-blind reader. The legend swatch
draws the same shape in the same colour, which is how the mapping is taught.
- Centroid name chips are placed greedily, biggest cluster first, testing an
estimated box against the boxes already placed and trying a few vertical
nudges. A chip with no free slot is DROPPED, never nudged somewhere that
misreports where its cluster is — the legend still names it. Below
`labelMinWidth` no chips are drawn at all. Measure the plot with a
ResizeObserver (disconnected on unmount) because the placement is in pixels;
with no ResizeObserver present, chips are simply absent.
- Chips and the tooltip are HTML overlays positioned in percentages, not SVG
<text>: view-unit type shrinks with the container and becomes unreadable on a
narrow card, while an HTML chip inherits the design system's type scale.
- ISOLATING A CLUSTER DIMS, IT NEVER RE-FITS. Re-running the extent over the
filtered subset would move every remaining point, and the reader would lose
the picture they were reading. Dimmed points keep their exact position, lose
their handlers, their role and their tab stop, and go aria-hidden — an
invisible option you can still Tab into is a keyboard trap.
- Keyboard: the point layer is a listbox with ROVING TABINDEX (one tab stop for
the whole cloud, not one per point). Arrow keys move to the nearest point in
that direction — a 45° cone first so ArrowRight cannot jump to something almost
straight up, then the whole half-plane so the key is never a dead no-op with
points still to the right. Home/End go to the first/last visible point,
Enter/Space toggles the selection, and Escape unwinds one rung at a time:
first the selection, then the cluster filter.
- Selection is derived-guarded: an id that is no longer visible (filtered away,
or gone from a new dataset) simply stops being selected in the same render
that hides it, with no effect and no sync. Isolating away the selected point
is a user action, so it also reports onSelectPoint(null); a selection that
disappears because the DATA changed is only dropped from the render, because
the consumer already knows what it sent.
- The tooltip follows hover, then keyboard focus, then the selection, and flips
side near the edges (above by default, below near the top; left/centre/right
by horizontal position). It is aria-hidden: it duplicates the option's
accessible name, and announcing it again would read every point twice.
- Four states are four branches. Loading is a shimmer grid inside EXACTLY the
box the plot will occupy, so nothing jumps when the projection lands; error is
role="alert" with an optional retry; empty owns its own body — and a `ready`
payload with nothing drawable falls through to the empty body rather than
painting a blank frame.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for the chrome,
var(--chart-1..5) for cluster marks and halos, var(--primary) for the query,
its rings and its connectors, var(--ring) for focus, var(--foreground) for the
selected mark's ring, bg-muted for the shimmer, text-destructive for the error
branch. No hard-coded colours anywhere.
- Geometry lives in view units where 1 unit = 1% of the plot width, so the same
numbers drive the SVG viewBox and the CSS percentages of the HTML overlays.
The plot box carries `aspect-ratio`, so the viewBox never letterboxes against
its own container.
- Strokes use vector-effect="non-scaling-stroke" — a 1-unit stroke would be 6px
on a wide card and 2px on a narrow one.
- Each mark carries an invisible-but-painted hit circle about 2.4× its radius:
the visible dot is ~6px across, far below a usable pointer target.
- Only the query's outer ring animates (a pulse), and it is
motion-reduce:animate-none; nothing about identifying a point depends on it.
- Root is a div holding the plot, the legend (aria-pressed toggle chips) and one
sr-only role="status" that announces the isolate filter — the only change a
screen-reader user cannot feel through focus. cn() merges the consumer's
className, the rest of the div props are spread and the ref is forwarded.
Customization levers
- Shape of the box: `aspect` (wide strip vs near-square), `padding` (how much
air around the cloud), `pointRadius` — drop it to ~0.6 for a dense corpus,
raise it to ~1.4 for a few dozen points.
- Density budget: `maxPoints` decides where stride sampling starts. Raise it for
a debugging surface, lower it for a card in a dashboard — but never make it
silent; the footnote count is part of the reading.
- Which sub-blocks exist: `showClusterHalos`, `showCentroidLabels`,
`showLegend`, `labelMinWidth`. Turn all three off for a sparkline-sized map.
- Retrieval layer: `neighborCount` (0 draws no connectors at all), and
`query.neighborIds` — pass the real ranking whenever you have it.
- Content: `renderTooltip` replaces the tooltip body entirely (add a thumbnail,
a score bar, an "open chunk" affordance), `formatScore` swaps the number's
wording (cosine, L2, a percentage), `meta` is your own provenance line.
- Wording: every string goes through `labels`, so the whole card localises
without a fork; `label` names the listbox for assistive tech.
- Wiring: `onSelectPoint` drives a detail panel, `onClusterIsolate` mirrors the
filter into your own state, `selectedPointId` makes the selection controlled
when the detail panel owns it.Concepts
- One scale for both axes — a projection's coordinates carry no unit and mean nothing except distance, so the fit uses a single shared scale and letterboxes whatever room is left. Stretching x and y independently makes two topics look adjacent because the axis was squashed, which is the one lie a map like this must not tell.
- The ranking beats the picture — the connectors follow
query.neighborIds, the order the vector store produced in the space where the search actually ran. A line drawn from the 2-D picture instead would regularly skip the real top hit, so when no ranking is passed the component falls back to 2-D nearest and says so in the footnote. - Dim, never re-fit — isolating a cluster fades the others in place. Re-running the extent over the survivors would move every remaining point and destroy the mental map the reader had just built; a filter should change what is emphasised, not where things are.
- Two channels for identity — colour cycles through
--chart-1..5and the mark shape cycles one step slower, so 25 clusters stay distinguishable, greyscale still works, and the legend swatch teaches the pairing by drawing the same mark. - Directional keyboard travel — arrow keys jump to the nearest point in that direction (a 45° cone first, the half-plane as a fallback), not to the next node in the DOM. With a roving tabindex the whole cloud is one tab stop, so a 1,200-point map does not become 1,200 tab stops.
- Truncation is announced — over the point cap the sample is a stride across the dataset, which keeps every cluster's share, and the footnote states the real total. Dropped rows (NaN coordinates, duplicate ids) are counted in the same line: a quietly thinner cloud looks exactly like a correct one.
Few Shot Editor
A few-shot example manager — paired input/output cards you add, duplicate, drag-reorder and switch off, with per-example and total token estimates against a budget, and gentle hints for blank outputs and near-duplicate inputs.
Structured Output
A JSON-mode response rendered through its schema — labelled rows in schema order, kind-aware values, field-level expected-vs-got validation, pending fields while the stream runs, off-schema keys kept, and a raw JSON view.