v1.0

ScatterChart

Preview

Basic

Loading…

Preview

Code

ts
import ScatterChart from '@/components/data/ScatterChart';

src/components/data/ScatterChart.tsx

AI prompt

text
Build a scatter plot component in React + TypeScript + Tailwind CSS, drawn with Recharts (`ScatterChart`, `Scatter`, `ZAxis`), for two measures per item across up to three groups.

## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, a legend when there are 2+ groups (8px dot swatch + 11px label), and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`).
- Plot: default 300px tall, full width, margin top 8 / right 12 / left 0 / bottom 16.
- Full grid (both directions), #e2e8f0 / dark #334155. Ticks 11px #64748b / dark #94a3b8, no tick marks. Both axes numeric. The x-axis title sits inside the bottom edge; the y-axis title is rotated −90° and centred on the left edge; both 11px in the tick colour. y-axis 52px wide, no axis line.
- Markers: one fixed size (8px — area encodes nothing), filled in the group colour and ringed 2px in the card's colour (#ffffff / dark #1e293b) so overlapping points stay distinct.
- Group colours, first three categorical slots only — light #2a78d6, #eb6834, #1baf7a; dark #3987e5, #d95926, #199e70.

## Behaviour
- At most THREE groups. In a scatter every pair of groups can touch, and three is the most that stay distinguishable pairwise under colour-blind simulation. A fourth is dropped, with a development-only `console.warn` suggesting small multiples or a grey "Other".
- Hover: a 1px crosshair in the tick colour and a tooltip for the point under the pointer — an opaque floating card (`px-3 py-2 text-xs`): the point's label in medium slate-800 / dark slate-100, then two lines, each the formatted value (semibold, tabular-nums) followed by its axis name in slate-500.
- Table view columns: Group, Item (label or "—"), x, y (right-aligned, formatted). All groups empty → the 240px "No data for the selected period." placeholder.
- Animation off. Default formats: compact number.

## API
`title`, `hint?`, `groups: { label: string; points: { x: number; y: number; label?: string }[] }[]`, `xLabel: string`, `yLabel: string`, `xFormat?`, `yFormat?` (`(v: number) => string`), `height = 300`, `className?`.

## Demo
"Deal size against sales cycle", x "Days to close", y "Deal size" as compact currency: SMB (14 deals, 8–38 days, $2K–11K), Mid-market (12 deals, 28–68 days, $14K–44K), Enterprise (9 deals, 60–115 days, $48K–138K), each point labelled like "Enterprise deal 3".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { CartesianGrid, ResponsiveContainer, Scatter, ScatterChart as RScatterChart, Tooltip, XAxis, YAxis, ZAxis } from 'recharts';
import ChartCard from './ChartCard';
import { axisProps } from './chartSeries';
import { compactNumber, useChartPalette } from './chartTheme';

export type ScatterPoint = { x: number; y: number; label?: string };
export type ScatterGroup = { label: string; points: ScatterPoint[] };

/**
 * Two measures per item, to show how they relate — and up to THREE groups.
 *
 * The cap is not arbitrary. In a scatter every pair of groups can overlap,
 * not just neighbours in a legend, and the palette's first three slots are
 * the most it validates for every pair under colour-blind simulation. A
 * fourth group is dropped here with a console warning; facet into small
 * multiples, or fold the rest into one grey "Other".
 *
 * Markers are 8px and ringed 2px in the card's colour so overlapping points
 * stay distinct; the hover target is larger than the mark.
 */
export default function ScatterChart({
  title,
  hint,
  groups,
  xLabel,
  yLabel,
  xFormat = compactNumber,
  yFormat = compactNumber,
  height = 300,
  className,
}: {
  title: string;
  hint?: string;
  groups: ScatterGroup[];
  xLabel: string;
  yLabel: string;
  xFormat?: (v: number) => string;
  yFormat?: (v: number) => string;
  height?: number;
  className?: string;
}) {
  const p = useChartPalette();
  const axis = axisProps(p);
  if (groups.length > 3 && process.env.NODE_ENV !== 'production') {
    console.warn(`ScatterChart "${title}": ${groups.length} groups, showing 3 — facet or fold the rest into "Other".`);
  }
  const shown = groups.slice(0, 3);

  return (
    <ChartCard
      title={title}
      hint={hint}
      className={className}
      empty={shown.every((g) => g.points.length === 0)}
      legend={shown.map((g, i) => ({ label: g.label, color: p.categorical[i], shape: 'dot' }))}
      table={{
        columns: [
          { key: 'group', label: 'Group' },
          { key: 'label', label: 'Item' },
          { key: 'x', label: xLabel, align: 'right', format: (v) => xFormat(Number(v)) },
          { key: 'y', label: yLabel, align: 'right', format: (v) => yFormat(Number(v)) },
        ],
        rows: shown.flatMap((g) => g.points.map((pt) => ({ group: g.label, label: pt.label ?? '—', x: pt.x, y: pt.y }))),
      }}
    >
      <div style={{ height }}>
        <ResponsiveContainer width="100%" height="100%">
          <RScatterChart margin={{ top: 8, right: 12, left: 0, bottom: 16 }}>
            <CartesianGrid stroke={p.grid} />
            <XAxis
              type="number"
              dataKey="x"
              name={xLabel}
              tickFormatter={xFormat}
              label={{ value: xLabel, position: 'insideBottom', offset: -8, fontSize: 11, fill: p.axis }}
              {...axis}
            />
            <YAxis
              type="number"
              dataKey="y"
              name={yLabel}
              tickFormatter={yFormat}
              width={52}
              label={{ value: yLabel, angle: -90, position: 'insideLeft', fontSize: 11, fill: p.axis, style: { textAnchor: 'middle' } }}
              {...axis}
              axisLine={false}
            />
            {/* A fixed marker size: area is not encoding anything here. */}
            <ZAxis range={[64, 64]} />
            <Tooltip
              cursor={{ stroke: p.axis, strokeWidth: 1 }}
              content={({ active, payload }) => {
                const pt = payload?.[0]?.payload as (ScatterPoint & { group?: string }) | undefined;
                if (!active || !pt) return null;
                return (
                  <div className="panel panel-solid px-3 py-2 text-xs shadow-lg">
                    {pt.label && <p className="mb-1 font-medium text-slate-800 dark:text-slate-100">{pt.label}</p>}
                    <p className="tabular-nums"><span className="font-semibold text-slate-900 dark:text-slate-50">{xFormat(pt.x)}</span> <span className="text-slate-500">{xLabel}</span></p>
                    <p className="tabular-nums"><span className="font-semibold text-slate-900 dark:text-slate-50">{yFormat(pt.y)}</span> <span className="text-slate-500">{yLabel}</span></p>
                  </div>
                );
              }}
            />
            {shown.map((g, i) => (
              <Scatter
                key={g.label}
                name={g.label}
                data={g.points}
                fill={p.categorical[i]}
                stroke={p.surface}
                strokeWidth={2}
                isAnimationActive={false}
              />
            ))}
          </RScatterChart>
        </ResponsiveContainer>
      </div>
    </ChartCard>
  );
}

Props

PropTypeDefaultDescription
title*string—
groups*ScatterGroup[]—
xLabel*string—
yLabel*string—
hintstring—
xFormat(v: number) => stringcompactNumber
yFormat(v: number) => stringcompactNumber
heightnumber300
classNamestring—