Skip to content
Component

Streamgraph

A chart that shows how the mix of several parts changes over time as flowing layers.

  • #arc
  • #component
  • #chart
  • #time
  • #composition
  • #data

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/streamgraph.json

streamgraph.module.csscss
.figure { display: grid; min-width: 0; gap: var(--space-4); margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }

/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */

/* Layer toggles: a rect swatch mirrors the area it names. Hover or focus previews that layer in the plot. */
.legend { display: flex; flex-wrap: wrap; gap: 2px 4px; min-width: 0; margin-inline: -8px; }
.toggle { display: inline-flex; min-height: 32px; align-items: center; gap: 8px; padding: 0 10px 0 8px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.toggle:active { transform: scale(.97); }
.toggle[data-active] { color: var(--foreground); background: color-mix(in oklab, var(--foreground) 5%, transparent); }
.toggle[data-dim] { opacity: .56; }
.toggle[aria-pressed="false"] { color: var(--text-muted); }
.toggleLabel { white-space: nowrap; }
.toggle[aria-pressed="false"] .toggleLabel { opacity: .72; text-decoration: line-through; text-decoration-color: color-mix(in oklab, currentColor 55%, transparent); }
.swatch { width: 10px; height: 10px; flex: none; border-radius: 3px; background: var(--series); transition: transform var(--duration-standard) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
.toggle[aria-pressed="false"] .swatch { opacity: .35; transform: scale(.6); }
@media (hover: hover) and (pointer: fine) { .toggle:hover { color: var(--foreground); } }

.chart { position: relative; display: grid; grid-template-rows: auto 26px; min-width: 0; }
.plot { position: relative; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: crosshair; }
.svg { display: block; overflow: visible; }

/* A 2px surface stroke is the gap between neighbouring layers. */
.layer { fill: color-mix(in oklab, var(--series) 82%, var(--surface)); stroke: var(--surface); stroke-width: 2; stroke-linejoin: round; transition: opacity var(--duration-fast) var(--ease-standard), fill var(--duration-standard) var(--ease-standard); }
.chart[data-layer] .layer:not([data-active]) { opacity: .34; }
.layer[data-active] { fill: var(--series); }

.crosshair { stroke: var(--foreground); stroke-opacity: .5; stroke-width: 1; shape-rendering: crispEdges; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); pointer-events: none; }
.chart[data-active] .crosshair { opacity: 1; }

.label { fill: var(--foreground); font-size: var(--text-xs); font-weight: 500; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; stroke-opacity: .85; stroke-linejoin: round; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-standard), fill var(--duration-fast) var(--ease-standard); }
.label[data-hidden] { opacity: 0 !important; }
.chart[data-layer] .label[data-dim] { opacity: 0 !important; }

.tooltip { position: absolute; top: 0; left: 0; z-index: 1; display: grid; min-width: 156px; max-width: min(256px, 100%); gap: 4px; padding: 10px 12px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
.chart[data-active] .tooltip { opacity: 1; scale: 1; }
.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }
.tipRow { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0; font-size: var(--text-sm); line-height: var(--leading-body); transition: opacity var(--duration-fast) var(--ease-standard); }
.tipRow[data-dim] { opacity: .5; }
.tipSwatch { width: 10px; height: 2.5px; flex: none; border-radius: 2px; background: var(--series); }
.tipName { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.tipRow[data-active] .tipName { color: var(--foreground); }
.tipValue { flex: none; margin-left: 8px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; white-space: nowrap; }

.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }

.axis { position: relative; min-width: 0; }
.axisLabel { position: absolute; top: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; transition: opacity var(--duration-standard) var(--ease-standard); }
/* New labels fade in while the stream morphs to a new range instead of popping in a single frame. */
@starting-style { .axisLabel { opacity: 0; } }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .toggle, .swatch, .layer, .crosshair, .label, .tooltip, .tipRow, .axisLabel { transition: none; }
}
streamgraph.tsxtsx
"use client";

import { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from "react";
import { animate, motion, useInView, useMotionValue, useSpring } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./streamgraph.module.css";

export interface StreamgraphSeries {
  /** Stable identity. A layer keeps its place and color across data changes, so switching datasets morphs it. */
  key: string;
  /** Name in the legend, the direct label, the tooltip, and the data table. */
  label: string;
  /** Any CSS color. Defaults to the chart series for the first four layers (the first in the accent), then neutral steps. */
  color?: string;
}

export interface StreamgraphDatum {
  /** Stable identity, such as an ISO week. */
  key: string;
  /** Full label for the tooltip and screen readers, such as "9월 14일 주". */
  label: string;
  /** Short label on the time axis. Leave it out to keep the axis quiet at that point. */
  axisLabel?: string;
  /** One value per series key. Missing values count as zero; negatives are treated as zero. */
  values: Record<string, number | undefined>;
}

/** Use a streamgraph for how the mix of several parts changes over time, when the overall shape matters more than exact values. */
export interface StreamgraphProps {
  data: StreamgraphDatum[];
  /** Layers from the centre out: the first runs through the middle of the stream, the rest alternate above and below it. */
  series: StreamgraphSeries[];
  /** What is measured, such as "주제별 문의 건수". Names the chart for assistive technology. */
  label: string;
  /** Unit after each value, such as "건". */
  unit?: string;
  /** Plot height in pixels. The width follows the container. */
  height?: number;
  /** "wiggle" minimises layer slopes for a flowing stream, "silhouette" centres the stack, "zero" stacks from a flat baseline. */
  offset?: "wiggle" | "silhouette" | "zero";
  formatValue?: (value: number, series: StreamgraphSeries) => string;
  /** Controlled hidden layer keys. A hidden layer thins to nothing and the rest reflow. */
  hiddenSeries?: string[];
  defaultHiddenSeries?: string[];
  onHiddenSeriesChange?: (hidden: string[]) => void;
  /** Called as the reading moves, and with nulls when it leaves. */
  onActiveChange?: (index: number | null, seriesKey: string | null) => void;
  /** Layer toggles above the plot. */
  legend?: boolean;
  /** Names each layer inside its widest stretch when it is thick enough to hold the text. */
  directLabels?: boolean;
  /** Header of the first column in the screen reader table. */
  categoryLabel?: string;
  emptyLabel?: string;
  ref?: Ref<HTMLElement>;
  className?: string;
}

type Hover = { index: number; key: string | null; y: number | null };
type Frame = { lower: number[][]; upper: number[][]; toY: (value: number) => number; center: number[] };

const { spring } = motionTokens;
const physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .0005) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
const settle = physical(spring.smooth);
const swell = physical({ visualDuration: .9, bounce: 0 });
const glide = physical(spring.snappy, .01);
const follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };

/** Samples across the plot: every layer is resampled onto the same grid, so datasets of any length morph into each other. */
const N = 161;
const PAD = 10, GAP = 14;
/** Neutral steps by layer. Stacked from the centre out, neighbours always sit at least ten steps apart; the accent is kept for the layer in focus. */
/** The first four layers take the chart series in order (the first in the accent); later layers fall back to neutral steps that alternate in lightness. */
const SHADES = [30, 18, 24, 14];
const colorOf = (item: StreamgraphSeries, index: number) => item.color ?? (index < 4 ? `var(--series-${index + 1})` : `color-mix(in oklab, var(--foreground) ${SHADES[(index - 4) % SHADES.length]}%, var(--surface))`);
/** The first layer sits in the middle of the stream and the rest alternate above and below it; a flat baseline stacks them in order. */
function stackOrder(count: number, mode: StreamgraphProps["offset"]) {
  const order = Array.from({ length: count }, (_, index) => index);
  if (mode === "zero") return order;
  return order.reduce<number[]>((list, index) => index === 0 ? [0] : index % 2 ? [...list, index] : [index, ...list], []);
}
const grouped = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));

/** Resamples values onto the shared grid with monotone cubic tangents, so curves never swing past the data or below zero. */
function resample(raw: number[]): number[] {
  const values = raw.map(value => Math.max(0, value || 0));
  const out = new Array<number>(N);
  if (!values.length) return out.fill(0);
  if (values.length === 1) return out.fill(values[0]!);
  const last = values.length - 1;
  const slopes = values.slice(1).map((value, index) => value - values[index]!);
  const tangents = values.map((_, index) => index === 0 || index === last ? 0 : (Math.sign(slopes[index - 1]!) + Math.sign(slopes[index]!)) * Math.min(Math.abs(slopes[index - 1]!), Math.abs(slopes[index]!), Math.abs(slopes[index - 1]! + slopes[index]!) / 4) || 0);
  if (last > 1) { tangents[0] = (3 * slopes[0]! - tangents[1]!) / 2; tangents[last] = (3 * slopes[last - 1]! - tangents[last - 1]!) / 2; } else { tangents[0] = tangents[1] = slopes[0]!; }
  for (let k = 0; k < N; k++) {
    const at = (k / (N - 1)) * last, seg = Math.min(last - 1, Math.floor(at)), t = at - seg, t2 = t * t, t3 = t2 * t;
    const a = values[seg]!, b = values[seg + 1]!;
    const value = (2 * t3 - 3 * t2 + 1) * a + (t3 - 2 * t2 + t) * tangents[seg]! + (3 * t2 - 2 * t3) * b + (t3 - t2) * tangents[seg + 1]!;
    out[k] = clamp(value, Math.min(a, b), Math.max(a, b));
  }
  return out;
}

/** The baseline under the stack. Wiggle follows Byron and Wattenberg, then removes its linear drift so the stream stays level. */
function baseline(layers: number[][], mode: StreamgraphProps["offset"]): number[] {
  const g = new Array<number>(N).fill(0);
  const totals = g.map((_, j) => layers.reduce((sum, layer) => sum + layer[j]!, 0));
  if (mode === "zero" || !layers.length) return g;
  if (mode === "silhouette") return totals.map(total => -total / 2);
  for (let j = 1; j < N; j++) {
    let s1 = 0, s2 = 0, below = 0;
    for (const layer of layers) {
      const change = layer[j]! - layer[j - 1]!;
      s1 += layer[j]!;
      s2 += (below + change / 2) * layer[j]!;
      below += change;
    }
    g[j] = g[j - 1]! - (s1 ? s2 / s1 : 0);
  }
  // Least squares line through the centre of the stream, subtracted so it never tilts off the plot.
  const centers = g.map((value, j) => value + totals[j]! / 2);
  const mx = (N - 1) / 2, my = centers.reduce((sum, value) => sum + value, 0) / N;
  let num = 0, den = 0;
  centers.forEach((value, j) => { num += (j - mx) * (value - my); den += (j - mx) ** 2; });
  const slope = den ? num / den : 0;
  return g.map((value, j) => value - (my + slope * (j - mx)));
}

const subscribeNothing = () => () => {};
function useReducedMotionSafe() {
  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
  return !!useReducedMotion() && hydrated;
}

/** Axis labels that fit: at most one per 72 pixels, counted back from the latest. */
function axisPicks(data: StreamgraphDatum[], width: number) {
  const labeled = data.flatMap((item, index) => item.axisLabel ? [index] : []);
  if (!width || !labeled.length) return [];
  const stride = Math.ceil(labeled.length / Math.max(2, Math.floor(width / 72)));
  return labeled.reverse().filter((_, rank) => rank % stride === 0).reverse();
}

export function Streamgraph({ data, series, label, unit = "", height = 260, offset = "wiggle", formatValue, hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, onActiveChange, legend = true, directLabels = true, categoryLabel = "날짜", emptyLabel = "이 기간에는 데이터가 없어요", ref, className }: StreamgraphProps) {
  const reduced = useReducedMotionSafe();
  const figure = useRef<HTMLElement>(null);
  const plot = useRef<HTMLDivElement>(null);
  const tip = useRef<HTMLDivElement>(null);
  const paths = useRef(new Map<string, SVGPathElement>());
  const labels = useRef(new Map<string, SVGTextElement>());
  useImperativeHandle(ref, () => figure.current as HTMLElement);
  const inView = useInView(figure, { once: true, amount: .3 });
  const last = data.length - 1;
  const empty = data.length === 0;

  const [ownHidden, setOwnHidden] = useState<string[]>(defaultHiddenSeries ?? []);
  const hidden = hiddenSeries ?? ownHidden;
  const toggle = (key: string) => {
    const next = hidden.includes(key) ? hidden.filter(item => item !== key) : [...hidden, key];
    if (hiddenSeries === undefined) setOwnHidden(next);
    onHiddenSeriesChange?.(next);
  };
  const visible = series.filter(item => !hidden.includes(item.key));

  // Each layer becomes a dense curve on the shared grid; the painter blends from what is on screen to it.
  const signature = `${series.map(item => item.key).join(",")}|${data.map(item => series.map(line => item.values[line.key] ?? 0).join(",")).join(";")}`;
  const targets = useMemo(() => new Map(series.map(line => [line.key, resample(data.map(item => item.values[line.key] ?? 0))])), [signature]); // eslint-disable-line react-hooks/exhaustive-deps

  const [hover, setHover] = useState<Hover | null>(null);
  const [seenSignature, setSeenSignature] = useState(signature);
  if (seenSignature !== signature) { setSeenSignature(signature); setHover(null); }
  const index = hover && !empty ? Math.min(hover.index, last) : null;
  const [legendKey, setLegendKey] = useState<string | null>(null);
  const wanted = hover?.key ?? legendKey;
  const activeKey = wanted && visible.some(item => item.key === wanted) ? wanted : null;

  const width = useRef(0);
  const [plotWidth, setPlotWidth] = useState(0);
  const shapes = useRef(new Map<string, number[]>());
  const presence = useRef(new Map<string, { value: number; stop: (() => void) | null }>());
  const frame = useRef<Frame | null>(null);
  const spots = useRef(new Map<string, number>());
  const grow = useMotionValue(0);
  const live = useRef({ series, offset });
  useLayoutEffect(() => { live.current = { series, offset }; });

  // One writer for every drawn attribute: morphs, toggles, the first swell, and resizes all repaint through here.
  const paint = useCallback(() => {
    const w = width.current;
    if (!w) return;
    const { series: order, offset: mode } = live.current;
    const layers = order.map(line => { const shape = shapes.current.get(line.key); const p = presence.current.get(line.key)?.value ?? 1; return shape ? shape.map(value => value * p) : new Array<number>(N).fill(0); });
    const stack = stackOrder(order.length, mode);
    const g = baseline(stack.map(i => layers[i]!), mode);
    const lower: number[][] = new Array(order.length), upper: number[][] = new Array(order.length);
    let running = g.slice();
    for (const i of stack) { const layer = layers[i]!; lower[i] = running; running = running.map((value, j) => value + layer[j]!); upper[i] = running; }
    let lo = Infinity, hi = -Infinity;
    for (let j = 0; j < N; j++) { lo = Math.min(lo, g[j]!); hi = Math.max(hi, running[j]!); }
    if (!Number.isFinite(lo) || hi - lo < 1e-9) { lo = -1; hi = 1; }
    const span = hi - lo, s = grow.get();
    const center = g.map((value, j) => (value + running[j]!) / 2);
    const toY = (value: number) => PAD + (1 - (value - lo) / span) * (height - PAD * 2);
    const yAt = (value: number, j: number) => toY(center[j]! + (value - center[j]!) * s);
    frame.current = { lower, upper, toY, center };
    const xs = Array.from({ length: N }, (_, j) => (j / (N - 1)) * w);
    order.forEach((line, i) => {
      const top = upper[i]!, bottom = lower[i]!;
      const node = paths.current.get(line.key);
      if (node) {
        let d = "";
        for (let j = 0; j < N; j++) d += `${j ? "L" : "M"}${xs[j]!.toFixed(1)},${yAt(top[j]!, j).toFixed(1)}`;
        for (let j = N - 1; j >= 0; j--) d += `L${xs[j]!.toFixed(1)},${yAt(bottom[j]!, j).toFixed(1)}`;
        node.setAttribute("d", `${d}Z`);
      }
      // The label sits where its layer is thickest, and holds its spot until another stretch is clearly thicker.
      const text = labels.current.get(line.key);
      if (!text) return;
      let best = 4;
      for (let j = 4; j < N - 4; j++) if (top[j]! - bottom[j]! > top[best]! - bottom[best]!) best = j;
      const held = spots.current.get(line.key);
      const j = held !== undefined && top[held]! - bottom[held]! >= (top[best]! - bottom[best]!) * .8 ? held : best;
      spots.current.set(line.key, j);
      const thickness = (top[j]! - bottom[j]!) / span * (height - PAD * 2) * s;
      const room = text.getComputedTextLength?.() ?? 60;
      const x = clamp(xs[j]!, room / 2 + 6, w - room / 2 - 6);
      text.setAttribute("x", x.toFixed(1));
      text.setAttribute("y", yAt((top[j]! + bottom[j]!) / 2, j).toFixed(1));
      text.style.opacity = thickness >= 19 && room + 16 < w ? "1" : "0";
    });
  }, [grow, height]);

  useLayoutEffect(() => {
    const node = plot.current;
    if (!node) return;
    const read = () => { width.current = node.clientWidth; setPlotWidth(node.clientWidth); paint(); };
    read();
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(read);
    observer.observe(node);
    return () => observer.disconnect();
  }, [paint]);

  // New data blends every layer from the shape on screen; a layer that arrives grows out of the one below it.
  const morph = useRef<{ stop: () => void } | null>(null);
  useEffect(() => {
    const moves: { key: string; from: number[]; to: number[] }[] = [];
    for (const [key, to] of targets) {
      const from = shapes.current.get(key);
      if (!from || reduced) { shapes.current.set(key, to); continue; }
      moves.push({ key, from, to });
    }
    for (const key of [...shapes.current.keys()]) if (!targets.has(key)) shapes.current.delete(key);
    morph.current?.stop();
    if (!moves.length) { paint(); return; }
    morph.current = animate(0, 1, { ...settle, onUpdate: t => { for (const move of moves) shapes.current.set(move.key, move.from.map((value, j) => value + (move.to[j]! - value) * t)); paint(); }, onComplete: () => { for (const move of moves) shapes.current.set(move.key, move.to); paint(); } });
    return () => morph.current?.stop();
  }, [paint, reduced, targets]);

  // A hidden layer thins to nothing while the others close over it.
  const hiddenKey = hidden.join("|");
  useEffect(() => {
    for (const line of series) {
      const to = hidden.includes(line.key) ? 0 : 1;
      const track = presence.current.get(line.key);
      if (!track) { presence.current.set(line.key, { value: to, stop: null }); continue; }
      if (track.value === to) continue;
      track.stop?.();
      if (reduced) { track.value = to; track.stop = null; continue; }
      const controls = animate(track.value, to, { ...settle, onUpdate: value => { track.value = value; paint(); } });
      track.stop = () => controls.stop();
    }
    paint();
  }, [hiddenKey, paint, reduced, series.length]); // eslint-disable-line react-hooks/exhaustive-deps

  // The first time the chart is seen, the stream swells out of a hairline along its centre.
  useEffect(() => {
    if (empty) return;
    if (reduced) { grow.jump(1); paint(); return; }
    if (!inView || grow.get() >= 1) return;
    const controls = animate(grow, 1, { ...swell, onUpdate: paint });
    return () => controls.stop();
  }, [empty, grow, inView, paint, reduced]);

  // The crosshair glides between points; the tooltip follows on its own spring and never leaves the plot.
  const cursor = useMotionValue(0);
  const cursorSpring = useSpring(cursor, follow);
  const tipX = useMotionValue(0), tipY = useMotionValue(0);
  const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);
  const wasActive = useRef(false);
  useLayoutEffect(() => {
    const bubble = tip.current, w = width.current;
    if (index === null || !bubble || !w) { wasActive.current = false; return; }
    const x = last > 0 ? (index / last) * w : w / 2;
    let y = hover?.y ?? null;
    const f = frame.current;
    if (y === null && f && activeKey) {
      const i = series.findIndex(line => line.key === activeKey), j = Math.round((x / w) * (N - 1));
      if (i >= 0) y = f.toY((f.upper[i]![j]! + f.lower[i]![j]!) / 2);
    }
    const tw = bubble.offsetWidth, th = bubble.offsetHeight;
    let left = x + GAP;
    if (left + tw > w) left = x - GAP - tw;
    const top = clamp((y ?? height / 2) - th / 2, 0, Math.max(0, height - th));
    const instant = !wasActive.current || reduced;
    cursor.set(x); tipX.set(clamp(left, 0, Math.max(0, w - tw))); tipY.set(top);
    if (instant) { cursorSpring.jump(x); tipSpringX.jump(tipX.get()); tipSpringY.jump(top); }
    wasActive.current = true;
  });

  const onActive = useRef(onActiveChange);
  useLayoutEffect(() => { onActive.current = onActiveChange; });
  useEffect(() => { onActive.current?.(index, activeKey); }, [index, activeKey]);

  // The layer under the pointer, found in the frame that is on screen.
  const read = (clientX: number, clientY: number): Hover | null => {
    const rect = plot.current?.getBoundingClientRect(), f = frame.current;
    if (!rect?.width || empty) return null;
    const share = clamp((clientX - rect.left) / rect.width, 0, 1), y = clientY - rect.top;
    const at = last > 0 ? Math.round(share * last) : 0, j = Math.round(share * (N - 1));
    let key: string | null = null;
    if (f) series.forEach((line, i) => { if (hidden.includes(line.key)) return; const top = f.toY(f.upper[i]![j]!), bottom = f.toY(f.lower[i]![j]!); if (y >= top - 1 && y <= bottom + 1) key = line.key; });
    return { index: at, key, y };
  };
  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === "mouse" || event.buttons) setHover(read(event.clientX, event.clientY)); };
  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== "mouse") event.currentTarget.setPointerCapture?.(event.pointerId); setHover(read(event.clientX, event.clientY)); };
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (empty) return;
    if (event.key === "Escape" && hover) { event.preventDefault(); setHover(null); return; }
    const from = index ?? last;
    const keys = visible.map(line => line.key), at = activeKey ? keys.indexOf(activeKey) : -1;
    const page = Math.max(1, Math.round(data.length / 6));
    const moves: Record<string, () => Hover> = {
      ArrowLeft: () => ({ index: clamp(from - 1, 0, last), key: activeKey, y: null }),
      ArrowRight: () => ({ index: clamp(index === null ? last : from + 1, 0, last), key: activeKey, y: null }),
      PageDown: () => ({ index: clamp(from - page, 0, last), key: activeKey, y: null }),
      PageUp: () => ({ index: clamp(from + page, 0, last), key: activeKey, y: null }),
      Home: () => ({ index: 0, key: activeKey, y: null }),
      End: () => ({ index: last, key: activeKey, y: null }),
      ArrowUp: () => ({ index: from, key: keys[at < 0 ? keys.length - 1 : Math.min(keys.length - 1, at + 1)] ?? null, y: null }),
      ArrowDown: () => ({ index: from, key: keys[at < 0 ? 0 : Math.max(0, at - 1)] ?? null, y: null }),
    };
    const move = moves[event.key];
    if (!move) return;
    event.preventDefault();
    setHover(move());
  };

  const format = (value: number, line: StreamgraphSeries) => `${formatValue ? formatValue(value, line) : grouped.format(value)}${unit ? ` ${unit}` : ""}`;
  const reading = index === null ? null : data[index];
  const total = (datum: StreamgraphDatum) => visible.reduce((sum, line) => sum + Math.max(0, datum.values[line.key] ?? 0), 0);
  const announce = reading ? `${reading.label}. ${activeKey ? `${series.find(line => line.key === activeKey)?.label} ${format(reading.values[activeKey] ?? 0, series.find(line => line.key === activeKey)!)}, ` : ""}층 ${visible.length}개, 합계 ${grouped.format(total(reading))}${unit ? ` ${unit}` : ""}` : "";
  const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${data[0]!.label} ~ ${data[last]!.label}. ${visible.map(line => `${line.label}, 최신 ${format(data[last]!.values[line.key] ?? 0, line)}`).join(". ")}.`;
  const picks = axisPicks(data, plotWidth);
  const tipLeft = reduced ? tipX : tipSpringX, tipTop = reduced ? tipY : tipSpringY;

  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(" ")} aria-label={label}>
    {legend && series.length > 1 && <div className={styles.legend} role="group" aria-label={`${label} 층`} onPointerLeave={() => setLegendKey(null)}>
      {series.map((line, at) => <button key={line.key} type="button" className={styles.toggle} aria-pressed={!hidden.includes(line.key)} data-active={activeKey === line.key || undefined} data-dim={(activeKey !== null && activeKey !== line.key) || undefined} onClick={() => toggle(line.key)}
        onPointerEnter={event => { if (event.pointerType === "mouse") setLegendKey(line.key); }} onFocus={event => { if (event.currentTarget.matches(":focus-visible")) setLegendKey(line.key); }} onBlur={() => setLegendKey(null)}
        style={{ "--series": colorOf(line, at) } as CSSProperties}>
        <span className={styles.swatch} aria-hidden="true" />
        <span className={styles.toggleLabel}>{line.label}</span>
      </button>)}
    </div>}
    <div className={styles.chart} data-active={index !== null || undefined} data-layer={activeKey ? true : undefined}>
      <div ref={plot} className={styles.plot} style={{ height }} role="group" tabIndex={empty ? -1 : 0} aria-roledescription="스트림 그래프" aria-label={`${label}. 좌우 화살표 키로 시간을 옮기고, 위아래 화살표 키로 층을 바꿔요.`}
        onPointerMove={onPointerMove} onPointerDown={onPointerDown} onPointerUp={event => { if (event.pointerType !== "mouse") setHover(null); }} onPointerCancel={() => setHover(null)} onPointerLeave={event => { if (event.pointerType === "mouse") setHover(null); }}
        onKeyDown={onKeyDown} onBlur={() => setHover(null)} onFocus={event => { if (event.currentTarget.matches(":focus-visible") && !empty) setHover(current => current ?? { index: last, key: null, y: null }); }}>
        <svg className={styles.svg} width="100%" height={height} aria-hidden="true" focusable="false">
          {series.map((line, at) => <path key={line.key} ref={node => { if (node) paths.current.set(line.key, node); else paths.current.delete(line.key); }} className={styles.layer} data-active={activeKey === line.key || undefined} data-custom={line.color ? true : undefined} style={{ "--series": colorOf(line, at) } as CSSProperties} />)}
          <motion.line className={styles.crosshair} x1={0} x2={0} y1={0} y2={height} style={{ x: reduced ? cursor : cursorSpring }} />
          {directLabels && series.map(line => <text key={line.key} ref={node => { if (node) labels.current.set(line.key, node); else labels.current.delete(line.key); }} className={styles.label} data-active={activeKey === line.key || undefined} data-custom={line.color ? true : undefined} data-hidden={hidden.includes(line.key) || undefined} data-dim={(activeKey !== null && activeKey !== line.key) || undefined} textAnchor="middle" dominantBaseline="central">{line.label}</text>)}
        </svg>
        <motion.div ref={tip} className={styles.tooltip} style={{ x: tipLeft, y: tipTop }} aria-hidden="true">
          <p className={styles.tipTitle}>{reading?.label ?? ""}</p>
          {stackOrder(series.length, offset).reverse().map(at => series[at]!).filter(line => !hidden.includes(line.key)).map(line => <p key={line.key} className={styles.tipRow} data-active={activeKey === line.key || undefined} data-dim={(activeKey !== null && activeKey !== line.key) || undefined} style={{ "--series": colorOf(line, series.indexOf(line)) } as CSSProperties}>
            <span className={styles.tipSwatch} />
            <span className={styles.tipName}>{line.label}</span>
            <span className={styles.tipValue}>{reading ? format(reading.values[line.key] ?? 0, line) : ""}</span>
          </p>)}
        </motion.div>
        {(empty || visible.length === 0) && <p className={styles.message}>{empty ? emptyLabel : "보여 줄 층을 골라 주세요"}</p>}
      </div>
      <div className={styles.axis} aria-hidden="true">
        {picks.map(at => { const share = last > 0 ? at / last : .5; const item = data[at]!; return <span key={`${item.key}`} className={styles.axisLabel} style={{ left: `${share * 100}%`, translate: `${-share * 100}% 0` }}>{item.axisLabel}</span>; })}
      </div>
    </div>
    <p className={styles.srOnly} aria-live="polite" aria-atomic="true">{announce}</p>
    <p className={styles.srOnly}>{summary}</p>
    {!empty && <div className={styles.srOnly}><table>
      <caption>{label}</caption>
      <thead><tr><th scope="col">{categoryLabel}</th>{series.map(line => <th key={line.key} scope="col">{line.label}</th>)}</tr></thead>
      <tbody>{data.map(item => <tr key={item.key}><th scope="row">{item.label}</th>{series.map(line => <td key={line.key}>{format(item.values[line.key] ?? 0, line)}</td>)}</tr>)}</tbody>
    </table></div>}
  </figure>;
}

export default Streamgraph;