Connected Scatter
A four-state connected scatter: both axes carry a metric, the stops are joined in time order, and direction is encoded four ways — a width ramp, pixel-spaced arrowheads, a start ring against a terminal arrowhead, and named endpoint labels.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-connected-scatter.jsonPrompt
Build a React + TypeScript + Tailwind "ChartConnectedScatter" card on the shadcn
chart primitives (ChartContainer/ChartTooltip over recharts ScatterChart) with zod.
It plots two METRICS against each other and joins the readings in time order —
time is the order of the points, never an axis.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string;
xAxis: { label: string; prefix?: string; suffix?: string };
yAxis: { label: string; prefix?: string; suffix?: string };
series: { id, label, stops: { label: string; x: number; y: number }[] }[] }.
- `stops` is chronological and the array index IS the time axis, so the component
must never sort it by x or y. `stop.label` names the period ("Q1 '23",
"Week 12") and is printed at the endpoints and in every readout.
- Axis money needs a prefix and axis units need a suffix — a single `unit` field
cannot print "$412" and "3.4%" from the same contract.
- At most 5 series (one chart token each); at least 1 stop per series.
- Component props = z.infer of the schema plus endpointLabels?: boolean (default
true), onRetry?: () => void and className. No parallel hand-written interface.
Behavior — direction is the whole job
- A connected scatter that only draws the line is unreadable: nothing says which
end is "now". Encode direction FOUR times, so no single failure mode hides it:
1. Width ramp: segment i is drawn at lerp(1.25px, 3.25px, i/(n-1)), and the
stop markers grow 2.5px -> 3.5px on the same ramp. Later segments are drawn
last, so at a self-crossing the more recent pass is painted on top.
2. Mid-path arrowheads spaced by ARC LENGTH IN PIXELS, not per segment: the gap
is clamp(pathPixelLength / 5, 34, 64), so the same data keeps its cues on a
190px phone plot and does not smear on a 1200px one. An arrowhead is skipped
when its segment is under 16px, when it would land within 9px of any stop
marker (checked at BOTH the tip and the tail — the glyph reaches backwards),
within 14px of the start or 22px of the end. If every candidate is rejected,
place exactly one at the midpoint of the longest usable segment: suppression
must never be able to delete the channel outright.
3. Shape at the two ends: the first stop is a hollow ring, the last is a filled
arrowhead rotated to the incoming direction. There are exactly two per
trajectory, so crowding can never remove them.
4. Endpoint labels naming the first and last period (plus the series name at the
end when there is more than one series).
- Do NOT fade old segments. Measured on this palette: --chart-1 is 3.63:1 on a
light card at full opacity, 2.73:1 at 0.8 alpha and 1.38:1 at 0.28 — the oldest
part of the path, which is exactly where you look to find the start, would be
the least visible thing on the plot. The ramp is width only.
- Degenerate geometry, all of which real feeds produce:
- Zero-length segment (a reading repeats): it has no direction, so skip atan2 on
it. A naive atan2(0,0) returns 0 and spins the terminal arrowhead to point
right. The terminal arrowhead inherits the last NON-degenerate angle instead.
- Revisited coordinate: stops within 1.2% of both padded domain spans are
grouped; every member gets a dashed halo ring and the readout names the others
("Also here: Week 2, Week 10"). Two stops on one pixel would otherwise render
as one marker and the second would simply not exist for the reader.
- Self-intersection: nothing special is drawn at a crossing — it is not a data
point. It stays readable because the two passes differ in width and the later
one is on top.
- Non-finite readings are dropped before anything is measured (one NaN poisons
the whole domain) and the number dropped is PRINTED above the plot.
- A series left with one usable stop renders a lone start ring and is called out
in the summary — dropping it silently would be worse.
- Endpoint labels are placed on the side the path is NOT on: past the arrow tip at
the end, behind the ring at the start, decided from sin(angle). Horizontally
they always lean towards the middle of the plot, so a label can never be pushed
out of the chart box by its own length. Below ~200px of plot width they are
dropped rather than overlapped (measured: at a 375px viewport the plot is 193px
and 3 of 12 labels lay across their own strokes).
- Legend chips are real <button type="button">: hover or focus highlights that
trajectory (the others drop to 0.25 opacity), click pins it (aria-pressed),
click again to unpin. The effective series is hovered ?? pinned, re-derived
against the current data every render so a stale key stops matching instead of
dimming everything.
- Four first-class branches in one bg-card panel: loading (a literal path
silhouette at the same 320px height, aria-hidden, animate-pulse with
motion-reduce:animate-none), empty (dashed frame + a dashed path with an
arrowhead), error (message + a "Try again" button only when onRetry exists),
ready. A "ready" payload with nothing plottable falls into empty.
- No mount animation anywhere. The direction cues are the message; making the
reader wait for a draw-in to learn which way time ran is backwards, and it makes
prefers-reduced-motion a non-issue outside the skeleton pulse.
Rendering & styling
- recharts owns the scales, axes, grid and tooltip; every visible mark of the
trajectories is drawn by one child component that reads usePlotArea(),
useXAxisScale() and useYAxisScale() (recharts >= 3.8) and emits plain SVG in
plot pixel space. Only pixels can answer "are these two arrowheads about to
collide", and Customized is deprecated in recharts 3 — a plain child works.
- That layer carries pointerEvents="none"; the hover targets are one <Scatter>
per series whose shape is a transparent r=11 circle. Otherwise the drawn markers
would swallow the pointer and the tooltip would never fire.
- Domain: pad both axes by 12% of their span so endpoint labels and markers clear
the plot edge; a zero span falls back to the value's own magnitude. Padding
pushes the ends onto values like 231.4, so ticks are snapped to a 1/2/5x10^n
step (target 4) and the padding stays blank.
- Numbers: decimals follow the axis SPAN, not the value (>=100 -> 0 decimals,
>=10 -> 1, >=1 -> 2, else 3), compact notation past 10,000, explicit "en-US",
never Intl(undefined). One formatter per axis feeds the ticks, the tooltip, the
endpoint labels and the data table, so notation can never disagree with itself.
- Colours come only from the chart tokens: series i uses var(--chart-{(i%5)+1})
for its strokes, markers, arrowheads and legend swatch. Chart tokens are never
used as text colour — labels are var(--foreground)/var(--muted-foreground), and
identity travels a second, colour-independent channel: the series name is
printed at the end of its own path.
- Markers are hollow: fill var(--card), coloured stroke, so overlapping stops
still read as separate rings in both themes.
- Accessibility: the chart is role="img" with an aria-label that spells out each
trajectory as a sentence ("Self-serve: over 8 stops from Q1 '23 to Q4 '24, Cost
per new customer rose from $248 to $261 while 30-day activation rose from 31.2%
to 39.8%"). accessibilityLayer={false} and tabIndex={-1} on the chart, so no
empty tab stop sits inside a presentational subtree. The exact numbers live in a
sibling data table wrapped in a div carrying sr-only — a bare table is
display:table, ignores width:1px and drags a horizontal scrollbar onto the page.
Customization levers
- Direction channels: ARROWS_PER_PATH (5) sets the arrow density and the gap
clamp (34-64px) sets its floor and ceiling; raise the floor for a sparser look.
WIDTH_OLDEST/WIDTH_NEWEST (1.25 -> 3.25) is the ramp; flattening it to a single
width costs you the channel that survives crowding, so widen the arrow budget
if you do. Never trade the ramp for an opacity fade unless your palette has more
contrast headroom than this one.
- Labels: endpointLabels={false} once four or five trajectories end near each
other — the legend and the readout still carry identity. LABEL_MIN_PLOT_PX (200)
is the width below which they drop themselves; lower it only after measuring at
your narrowest breakpoint.
- Coincidence: COINCIDE_FRACTION (0.012 of each padded span) decides what counts
as "the same spot". Raise it for noisy feeds where near-identical readings
should be called revisits; drop it to 0 to mark only exact repeats.
- Density: h-[320px] with p-6 suits a full-width card; ~240px and p-4 for a
dashboard tile, in which case drop the endpoint labels and the CartesianGrid.
- Readout: the tooltip prints the move since the previous stop ("since Q1 '24:
−$28 · +3.3%"), which is the reason to connect the dots at all. Swap it for a
cumulative delta from the first stop when the question is "how far have we come
overall".
- Palette: pin a fixed token per series id (brand vs competitor) instead of the
index formula; keep the direct end labels if the host palette is monochrome.
- Interaction: markers are inert by design. Give the hit-target Scatter an onClick
and route it to { seriesId, order } to open the period behind a stop.Concepts
- Time is the order, not an axis — both axes carry a metric, and the array order of the stops is the only place time lives. That is what lets the chart show a trade-off: a path that goes right-and-down and then loops back left-and-up says "we paid for it, then we got it back", which two separate time series can only imply.
- Four direction channels — a width ramp (survives crowding and cropping), arrowheads (precise, but suppressed exactly where they would collide), a start ring against a terminal arrowhead (exactly two per path, so they can never be crowded out), and endpoint labels naming the first and last period. Every one of them fails somewhere; together they do not.
- Arrow spacing is an arc-length budget in pixels — the gap is derived from the drawn path length and an arrowhead is dropped when it would sit on a marker or on either endpoint. Dense stretches therefore go quiet instead of turning into a smear, and a floor rule puts one arrow on the longest segment when the budget rejects everything.
- A repeated reading has no direction — a zero-length segment is skipped rather than fed to
atan2, which would return 0 and turn the terminal arrowhead to point right. The arrowhead inherits the last real heading, so a trajectory that ends on a stall still points the way it was going. - Revisits are marked, never moved — stops that land on the same spot keep their true coordinates and gain a dashed halo, and the readout names the other periods that share it. Nudging them apart would be a lie about a path whose whole point is where it has been.
- Layered ownership — recharts owns the scales, axes and tooltip; a single child component reads the plot area and the axis scales and draws every visible mark in pixel space. Collision questions are pixel questions, and the layer is pointer-transparent so invisible
Scatterhit targets underneath still drive the readout.
Seasonality
A four-state seasonal decomposition card — one series split into trend, season and residual across three panels on one shared x axis, with anomalous residuals marked against a robust cut-off and undecomposable feeds refused instead of fitted.
Lift Curve
A four-state gain and lift curve for model evaluation — a cumulative capture curve read against the random diagonal and the perfect-model bound, with key quantiles marked and degenerate inputs named instead of drawn.