Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  CartesianGrid,
  Scatter,
  ScatterChart,
  XAxis,
  YAxis,
  usePlotArea,
  useXAxisScale,
  useYAxisScale,
} from "recharts"

Installation

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

Prompt

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 Scatter hit targets underneath still drive the readout.

On This Page