Cross-filter A selection in one panel narrows the rest. The whole pattern lives or dies on whether the reader can see that the other panels are now filtered, and undo it in one move. npx shadcn@latest add button npm i recharts Tokens this needs: --card, --card-foreground, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // CrossFilter.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Line, LineChart, ReferenceArea, XAxis, YAxis } from "recharts"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; /** One interval of the series, with its count broken down by every facet. */ export type Bucket = { t: number; count: number; by: Record> }; export type Facet = { key: string; label: string }; /** Half-open: from is inside, to is not. */ export type Selection = { from: number; to: number }; const hhmm = (t: number) => { const d = new Date(t); return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; }; /** Sum a facet over the buckets in play, largest first. */ const tally = (buckets: Bucket[], facet: string) => { const sums: Record = {}; for (const b of buckets) for (const [k, n] of Object.entries(b.by[facet] ?? {})) sums[k] = (sums[k] ?? 0) + n; return Object.entries(sums).sort((a, b) => b[1] - a[1]); }; /** * Brushing and linking. The selection is controlled, so the page can put it in * the URL, and it is always shown in words with one control to clear it: a * page that has silently filtered itself will be screenshotted and misread. */ export function CrossFilter({ title, buckets, facets, selection, onSelectionChange }: { title: string; buckets: Bucket[]; facets: Facet[]; selection: Selection | null; onSelectionChange: (s: Selection | null) => void; }) { const [anchor, setAnchor] = useState(null); const inPlay = selection ? buckets.filter((b) => b.t >= selection.from && b.t < selection.to) : buckets; const total = inPlay.reduce((n, b) => n + b.count, 0); // Drag on the chart. The anchor is where the press landed; every move // redraws the region from there, and release commits it. const drag = (e: { activeLabel?: string | number }, phase: "down" | "move" | "up") => { const t = Number(e?.activeLabel); if (Number.isNaN(t)) return; if (phase === "down") { setAnchor(t); onSelectionChange(null); return; } if (anchor === null) return; const [from, to] = anchor < t ? [anchor, t] : [t, anchor]; if (from !== to) onSelectionChange({ from, to: to + 60_000 }); if (phase === "up") setAnchor(null); }; return (

{title}

drag(e, "down")} onMouseMove={(e) => drag(e, "move")} onMouseUp={(e) => drag(e, "up")}> {selection && ( )}
{selection && (
{hhmm(selection.from)}–{hhmm(selection.to)} · {total.toLocaleString("en-US")} requests
)} {facets.map((f) => { const rows = tally(inPlay, f.key); const max = rows[0]?.[1] ?? 1; // One value taking two-thirds of the selection is the "because". Say so. const dominant = selection && rows[0] && rows[0][1] / total > 2 / 3 ? rows[0][0] : null; return (

{f.label}

    {rows.map(([k, n]) => (
  • {k} {n.toLocaleString("en-US")}
  • ))}
); })}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { CrossFilter, type Bucket, type Selection } from "./CrossFilter"; /** * Requests per minute from 13:50, flat until a bump at 14:05 that one build * explains. Each minute carries its own split by build, region and endpoint, * so the facets can be re-summed for any selection. */ const T0 = Date.UTC(2026, 8, 15, 13, 50); const BUMP_AT = 15; const BUMP = [100, 108, 116, 124, 130, 136, 140, 142, 148, 142, 140, 136, 130, 124, 116, 108, 100]; const QUIET = [40, 44, 42, 46, 44, 48]; const split = (count: number, shares: Record) => { const out: Record = {}; let left = count; const keys = Object.keys(shares); keys.forEach((k, i) => { out[k] = i === keys.length - 1 ? left : Math.round(count * shares[k]); left -= out[k]; }); return out; }; const BUCKETS: Bucket[] = Array.from({ length: 50 }, (_, m) => { const inBump = m >= BUMP_AT && m < BUMP_AT + BUMP.length; const count = inBump ? BUMP[m - BUMP_AT] : QUIET[m % QUIET.length]; return { t: T0 + m * 60_000, count, by: { build: split(count, inBump ? { "4a91c": 0.82, "3f07e": 0.11, "2b1d9": 0.07 } : { "4a91c": 0.34, "3f07e": 0.33, "2b1d9": 0.33 }), region: split(count, { "eu-west": 0.38, "us-east": 0.32, "ap-south": 0.3 }), endpoint: split(count, { "/checkout": 0.4, "/cart": 0.34, "/search": 0.26 }), }, }; }); const FACETS = [ { key: "build", label: "By build" }, { key: "region", label: "By region" }, { key: "endpoint", label: "By endpoint" }, ]; export default function Demo() { // Starts with the bump selected. Drag on the chart to move it; ✕ clears it. const [selection, setSelection] = useState({ from: T0 + 15 * 60_000, to: T0 + 32 * 60_000 }); return ; }