← Files DataARCHIVED FILE

templates/data-app/base/src/charting/SankeyRenderer.jsx

18.7 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import React, { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { Sankey, Tooltip, useChartWidth } from "recharts";

import { useChartText } from "./ChartAnnotations.jsx";
import { ChartTooltip } from "./ChartTooltip.jsx";
import { sankeyGraph } from "./chart-transforms.js";
import { categoryLabel, compact, compactAxisCategory, percentage } from "./chart-theme.js";

const sankeyMargin = { top: 12, right: 0, bottom: 12, left: 0 };
const expandedSankeyMargin = { top: 18, right: 6, bottom: 18, left: 6 };
const emptySankeyCriteria = Object.freeze([]);
const emptySankeyColorMap = Object.freeze({});
const SankeyInteractionContext = /* @__PURE__ */ createContext(null);

function sankeyNodeKey(node) {
  return JSON.stringify([Number(node?.stage), String(node?.name ?? "")]);
}

function sankeyLinkKey(source, target) {
  return JSON.stringify([
    Number(source?.stage),
    String(source?.name ?? ""),
    Number(target?.stage),
    String(target?.name ?? ""),
  ]);
}

function sankeySelection(node) {
  return {
    field: node.field,
    stage: Number(node.stage),
    name: String(node.name ?? ""),
    key: sankeyNodeKey(node),
  };
}

function sankeyLinkSelection(link) {
  const source = sankeySelection(link.source);
  const target = sankeySelection(link.target);
  const key = sankeyLinkKey(link.source, link.target);
  return {
    type: "link",
    key,
    linkKey: key,
    sourceKey: source.key,
    targetKey: target.key,
    criteria: [source, target],
  };
}
function SankeyNode({ x, y, width, height, payload, colorFor, showValues, lastStage, labelMaxLength = 10,
  labelFontSize = 11, valueFontSize = 10, penultimateLabelAlignment = "right",
  terminalLabelAlignment = "auto" }) {
  const interaction = useContext(SankeyInteractionContext);
  const chartWidth = useChartWidth();
  const { measureFont, measureText } = useChartText(400, labelFontSize);
  const nodeKey = sankeyNodeKey(payload);
  const reportNodePosition = interaction?.reportNodePosition;
  useLayoutEffect(() => {
    if (Number.isFinite(y)) reportNodePosition?.(nodeKey, y);
  }, [nodeKey, reportNodePosition, y]);
  const selected = interaction?.selectedNodeKeys.has(nodeKey) ?? false;
  const hovered = interaction?.hoveredNodeKey === nodeKey;
  const active = !interaction?.engaged || interaction.activeNodeKeys.has(nodeKey) || selected || hovered;
  const color = payload.color
    ?? colorFor({ field: payload.field, dimension: payload.field, value: payload.name, index: payload.stage });
  const beforeTerminal = lastStage > 0 && payload.stage === lastStage - 1;
  const centerY = y + height / 2;
  const name = categoryLabel(payload.field, payload.name);
  let visibleName = compactAxisCategory(name, Math.max(4, Math.min(40, Number(labelMaxLength) || 10)));
  const value = compact(payload.value);
  const columnStep = payload.depth > 0 ? payload.x / payload.depth : 0;
  const terminal = payload.targetNodes?.length === 0 && payload.sourceNodes?.length > 0;
  const labelWidth = Math.min(140, Math.max(72, visibleName.length * labelFontSize * 0.58));
  const labelGap = 10;
  const clearance = 6;
  const requiredStep = width / 2 + labelWidth / 2 + clearance + labelWidth + labelGap;
  const placeTerminalLeft = terminal && columnStep >= requiredStep;
  const terminalAbove = terminal && !placeTerminalLeft;
  const placeTerminalRight = terminal && terminalLabelAlignment === "right";
  const reviewedTerminalLeft = placeTerminalLeft && !placeTerminalRight;
  const reviewedTerminalAbove = terminalAbove && !placeTerminalRight;
  const rightAlignPenultimate = beforeTerminal && penultimateLabelAlignment === "right";
  const placeAboveNode = beforeTerminal || terminalAbove;
  const reviewedPlaceAboveNode = placeTerminalRight ? false : placeAboveNode;
  const valueVisible = !beforeTerminal && !placeAboveNode &&
    (showValues === true ? height >= 20 : showValues !== false && height >= 32);
  const textX = placeTerminalRight ? x + width + labelGap
    : rightAlignPenultimate ? x + width
    : reviewedTerminalLeft ? x - labelGap
    : reviewedTerminalAbove ? x + width
      : reviewedPlaceAboveNode ? x + width / 2 : x + width + labelGap;
  const textAnchor = placeTerminalRight ? "start"
    : rightAlignPenultimate || reviewedTerminalLeft || reviewedTerminalAbove
    ? "end" : reviewedPlaceAboveNode ? "middle" : "start";
  const availableLabelWidth = Math.max(0, textAnchor === "start" ? chartWidth - textX - 4
    : textAnchor === "end" ? textX - 4 : 2 * Math.min(textX - 4, chartWidth - textX - 4));
  while (visibleName.length > 1 && measureText(visibleName) > availableLabelWidth) {
    visibleName = `${visibleName.replace(/…$/u, "").slice(0, -1).trimEnd()}…`;
  }
  const nameY = reviewedPlaceAboveNode ? Math.max(11, y - 5) : centerY + (valueVisible ? -3 : 4);
  return (
    <g className="chart-sankey-node" data-active={active || undefined} data-selected={selected || undefined}
      data-hovered={hovered || undefined} opacity={active ? 1 : 0.16} role="button" tabIndex={0}
      aria-pressed={selected} aria-label={`${name}, ${value}`}
      onKeyDown={(event) => {
        if (event.key !== "Enter" && event.key !== " ") return;
        event.preventDefault();
        event.stopPropagation();
        interaction?.toggleNode(payload);
      }}>
      <rect x={x} y={y} width={width} height={height} rx={Math.min(4, height / 3)} fill={color}
        stroke={selected || hovered ? "var(--text)" : "none"} strokeWidth={selected ? 2.5 : hovered ? 1.5 : 0} />
      <text
        ref={measureFont}
        x={textX}
        y={nameY}
        textAnchor={textAnchor}
        fill="var(--text)"
        stroke="var(--surface)"
        strokeWidth={3}
        strokeLinejoin="round"
        paintOrder="stroke"
        fontSize={labelFontSize}
      >
        <title>{name}</title>
        {visibleName}
      </text>
      {valueVisible && (
        <text
          x={textX}
          y={centerY + 12}
          textAnchor={textAnchor}
          fill="var(--secondary)"
          stroke="var(--surface)"
          strokeWidth={3}
          strokeLinejoin="round"
          paintOrder="stroke"
          fontSize={valueFontSize}
        >
          {value}
        </text>
      )}
    </g>
  );
}

function SankeyTooltip({ active, payload = [], total = 0 }) {
  if (!active || !payload.length) return null;
  const item = payload[0]?.payload?.payload ?? payload[0]?.payload;
  if (!item) return null;
  const value = Number(item.value) || 0;
  const isLink = item.source && item.target;
  const sourceName = isLink ? categoryLabel(item.source.field, item.source.name) : null;
  const targetName = isLink ? categoryLabel(item.target.field, item.target.name) : null;
  const name = isLink ? `${sourceName} → ${targetName}` : categoryLabel(item.field, item.name);
  const denominator = isLink ? Number(item.source.value) || 0 : total;
  return <ChartTooltip active label={name} details={[
    { label: "Count", value: compact(value) },
    {
      label: isLink ? "Share of source" : "Share of all",
      value: denominator > 0 ? percentage(value / denominator).replace("+", "") : "—",
    },
  ]} />;
}

function SankeyLinkPath({ sourceX, targetX, sourceY, targetY, sourceControlX, targetControlX,
  linkWidth, payload, colorMode = "source" }) {
  const interaction = useContext(SankeyInteractionContext);
  const linkKey = sankeyLinkKey(payload.source, payload.target);
  const selected = interaction?.selectedLinkKeys.has(linkKey) ?? false;
  const hovered = interaction?.hoveredLinkKey === linkKey;
  const effectiveHover = hovered && !interaction?.pinned;
  const active = !interaction?.engaged || interaction.activeLinkKeys.has(linkKey) || selected || effectiveHover;
  const path = `M${sourceX},${sourceY}C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`;
  const stroke = payload.source.color ?? "var(--chart-1)";
  const reviewedStroke = colorMode === "target" ? payload.target.color ?? stroke : stroke;
  const strength = Number(payload.source.colorStrength) || 60;
  const restingOpacity = 0.07 + strength / 100 * 0.21;
  const activeOpacity = 0.3 + strength / 100 * 0.32;
  const opacity = selected ? 0.78 : effectiveHover ? 0.7
    : active ? interaction?.engaged ? activeOpacity : restingOpacity : 0.035;
  const sourceName = categoryLabel(payload.source.field, payload.source.name);
  const targetName = categoryLabel(payload.target.field, payload.target.name);
  const hitInset = Math.min(16, Math.max(0, (targetX - sourceX) / 4));
  const hitPath = `M${sourceX + hitInset},${sourceY}C${sourceControlX},${sourceY} `
    + `${targetControlX},${targetY} ${targetX - hitInset},${targetY}`;
  return <g className="chart-sankey-link" data-active={active || undefined}
    data-selected={selected || undefined} data-hovered={hovered || undefined}
    role="button" tabIndex={0} aria-pressed={selected}
    aria-label={`${sourceName} to ${targetName}, ${compact(payload.value)}`}
    onKeyDown={(event) => {
      if (event.key !== "Enter" && event.key !== " ") return;
      event.preventDefault();
      event.stopPropagation();
      interaction?.toggleLink(payload);
    }}>
    <path className="chart-sankey-link-visible" d={path} fill="none" stroke={reviewedStroke}
      strokeWidth={Math.max(1, linkWidth)} strokeOpacity={opacity} pointerEvents="none" />
    <path className="chart-sankey-link-hit-area" d={hitPath} fill="none" stroke="transparent"
      strokeWidth={Math.max(12, linkWidth)}
      pointerEvents="stroke" />
  </g>;
}

export function SankeyRenderer({ rows, stages, spec, colorFor, onSelection }) {
  const {
    y: valueField,
    colors: colorMap = emptySankeyColorMap,
    colorByColumn = true,
    showValues,
    labelMaxLength,
    sankeyNodeWidth: nodeWidth = 12,
    sankeyLabelFontSize: labelFontSize = 11,
    sankeyValueFontSize: valueFontSize = 10,
    penultimateLabelAlignment,
    sankeyTerminalLabelAlignment: terminalLabelAlignment = "auto",
    sankeyLinkColorMode: linkColorMode = "source",
    sankeySortNodes: sortNodes = true,
  } = spec;
  const [selectedByField, setSelectedByField] = useState({});
  const [selectedLink, setSelectedLink] = useState(null);
  const [hoveredItem, setHoveredItem] = useState(null);
  const graph = useMemo(() => sankeyGraph(rows, stages, valueField), [rows, stages, valueField]);
  const total = useMemo(() => rows.reduce((sum, row) => {
    const value = Number(row[valueField]);
    return sum + (Number.isFinite(value) && value > 0 ? value : 0);
  }, 0), [rows, valueField]);
  const graphSignature = useMemo(() => JSON.stringify({
    nodes: graph.nodes.map(sankeyNodeKey),
    links: graph.links.map(({ source, target, value }) => [
      sankeyNodeKey(graph.nodes[source]),
      sankeyNodeKey(graph.nodes[target]),
      Number(value) || 0,
    ]),
  }), [graph]);
  const [nodeLayout, setNodeLayout] = useState({ signature: "", positions: {} });
  const reportNodePosition = useCallback((key, position) => {
    const y = Math.round(Number(position) * 100) / 100;
    if (!Number.isFinite(y)) return;
    setNodeLayout((current) => {
      if (current.signature !== graphSignature) {
        return { signature: graphSignature, positions: { [key]: y } };
      }
      if (current.positions[key] === y) return current;
      return { ...current, positions: { ...current.positions, [key]: y } };
    });
  }, [graphSignature]);
  const nodePositions = nodeLayout.signature === graphSignature ? nodeLayout.positions : {};
  const coloredGraph = useMemo(() => {
    const rankings = new Map();
    for (let stage = 0; stage < stages.length; stage += 1) {
      const nodes = graph.nodes.filter((node) => node.stage === stage);
      const positioned = nodes.every((node) => Number.isFinite(nodePositions[sankeyNodeKey(node)]));
      if (!positioned) {
        nodes.forEach((node) => rankings.set(sankeyNodeKey(node), { index: 0, count: 1 }));
        continue;
      }
      const ordered = nodes.map((node, index) => ({ node, index }))
        .sort((left, right) => nodePositions[sankeyNodeKey(left.node)] - nodePositions[sankeyNodeKey(right.node)]
          || left.index - right.index)
        .map(({ node }) => node);
      ordered.forEach((node, index) => rankings.set(sankeyNodeKey(node), { index, count: nodes.length }));
    }
    return {
      ...graph,
      nodes: graph.nodes.map((node) => {
        const exactColor = !colorByColumn || node.stage === stages.length - 1 ? colorMap?.[node.name] : undefined;
        const baseColor = colorMap?.[node.field]
          ?? colorFor({ field: node.field, dimension: node.field,
            ...(colorByColumn ? {} : { value: node.name }), index: node.stage });
        const rank = rankings.get(sankeyNodeKey(node)) ?? { index: 0, count: 1 };
        const strength = rank.count <= 1 ? 72 : 100 - Math.round(rank.index / (rank.count - 1) * 76);
        return {
          ...node,
          colorStrength: strength,
          color: exactColor ?? (colorByColumn
            ? `color-mix(in oklch, ${baseColor} ${strength}%, var(--surface))`
            : baseColor),
        };
      }),
    };
  }, [colorByColumn, colorFor, colorMap, graph, nodePositions, stages.length]);
  const availableNodeKeys = useMemo(() => new Set(graph.nodes.map(sankeyNodeKey)), [graph]);
  const availableLinkKeys = useMemo(() => new Set(graph.links.map(({ source, target }) =>
    sankeyLinkKey(graph.nodes[source], graph.nodes[target]))), [graph]);

  useEffect(() => {
    setHoveredItem(null);
    setSelectedLink((current) => current && availableLinkKeys.has(current.key) ? current : null);
    setSelectedByField((current) => {
      const entries = Object.entries(current).filter(([, selected]) => availableNodeKeys.has(selected.key));
      return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries);
    });
  }, [availableLinkKeys, availableNodeKeys]);

  const toggleNode = useCallback((node) => {
    const selected = sankeySelection(node);
    setSelectedLink(null);
    setSelectedByField((current) => {
      const next = { ...current };
      if (next[selected.field]?.key === selected.key) delete next[selected.field];
      else next[selected.field] = selected;
      return next;
    });
  }, []);
  const toggleLink = useCallback((link) => {
    const selected = sankeyLinkSelection(link);
    setSelectedByField({});
    setSelectedLink((current) => current?.key === selected.key ? null : selected);
  }, []);
  const handleMouseEnter = useCallback((item, type) => {
    if (type === "node") {
      const node = sankeySelection(item.payload);
      setHoveredItem({ type, nodeKey: node.key, criteria: [node] });
      return;
    }
    setHoveredItem(sankeyLinkSelection(item.payload));
  }, []);
  const handleMouseLeave = useCallback(() => setHoveredItem(null), []);
  const handleClick = useCallback((item, type, event) => {
    event?.stopPropagation();
    if (type === "node") toggleNode(item.payload);
    else if (type === "link") toggleLink(item.payload);
  }, [toggleLink, toggleNode]);
  const selectedNodes = useMemo(() => Object.values(selectedByField), [selectedByField]);
  useEffect(() => { onSelection?.(selectedLink?.criteria ?? selectedNodes); }, [onSelection, selectedLink, selectedNodes]);
  const focusedLink = selectedLink ?? (!selectedNodes.length && hoveredItem?.type === "link" ? hoveredItem : null);
  const criteria = selectedNodes.length ? selectedNodes
    : focusedLink ? emptySankeyCriteria : hoveredItem?.criteria ?? emptySankeyCriteria;
  const matchingRows = useMemo(() => {
    if (!criteria.length) return rows;
    return rows.filter((row) => criteria.every((selected) =>
      String(row[selected.field] ?? "") === selected.name));
  }, [criteria, rows]);
  const activeGraph = useMemo(() => {
    if (!criteria.length) return graph;
    return sankeyGraph(matchingRows, stages, valueField);
  }, [criteria.length, graph, matchingRows, stages, valueField]);
  const selectedNodeKeys = useMemo(() => new Set(selectedNodes.map(({ key }) => key)), [selectedNodes]);
  const selectedLinkKeys = useMemo(() => new Set(selectedLink ? [selectedLink.key] : []), [selectedLink]);
  const activeNodeKeys = useMemo(() => {
    if (focusedLink) return new Set([focusedLink.sourceKey, focusedLink.targetKey]);
    const keys = new Set(activeGraph.nodes.map(sankeyNodeKey));
    selectedNodes.forEach(({ key }) => keys.add(key));
    return keys;
  }, [activeGraph, focusedLink, selectedNodes]);
  const activeLinkKeys = useMemo(() => focusedLink ? new Set([focusedLink.key])
    : new Set(activeGraph.links.map(({ source, target }) =>
      sankeyLinkKey(activeGraph.nodes[source], activeGraph.nodes[target]))), [activeGraph, focusedLink]);
  const interaction = useMemo(() => ({
    engaged: Boolean(focusedLink) || criteria.length > 0,
    pinned: Boolean(selectedLink) || selectedNodes.length > 0,
    activeNodeKeys,
    activeLinkKeys,
    selectedNodeKeys,
    selectedLinkKeys,
    hoveredNodeKey: selectedLink || selectedNodes.length ? null
      : hoveredItem?.type === "node" ? hoveredItem.nodeKey : null,
    hoveredLinkKey: hoveredItem?.type === "link" ? hoveredItem.linkKey : null,
    toggleNode,
    toggleLink,
    reportNodePosition,
  }), [activeLinkKeys, activeNodeKeys, criteria.length, focusedLink, hoveredItem, selectedLink, selectedLinkKeys,
    selectedNodeKeys, selectedNodes.length, reportNodePosition, toggleLink, toggleNode]);
  const nodeRenderer = useMemo(() => <SankeyNode colorFor={colorFor} showValues={showValues}
    lastStage={Math.max(0, stages.length - 1)} labelMaxLength={labelMaxLength}
    labelFontSize={labelFontSize} valueFontSize={valueFontSize}
    penultimateLabelAlignment={penultimateLabelAlignment} terminalLabelAlignment={terminalLabelAlignment} />,
  [colorFor, labelFontSize, labelMaxLength, penultimateLabelAlignment, showValues, stages.length,
    terminalLabelAlignment, valueFontSize]);
  const linkRenderer = useMemo(() => <SankeyLinkPath colorMode={linkColorMode} />, [linkColorMode]);
  const expandedLayout = nodeWidth > 12 || labelFontSize > 11;
  const sankeyLayoutMargin = terminalLabelAlignment === "right"
    ? { ...expandedSankeyMargin, right: 320 }
    : expandedLayout ? expandedSankeyMargin : sankeyMargin;

  if (!graph.nodes.length || !graph.links.length) {
    return <svg role="img" aria-label="No matching flow paths" viewBox="0 0 100 100"
      preserveAspectRatio="xMidYMid meet">
      <text x="50" y="50" textAnchor="middle" dominantBaseline="middle"
        fill="var(--secondary)" fontSize="4">No matching flows</text>
    </svg>;
  }
  return <SankeyInteractionContext.Provider value={interaction}>
    <Sankey data={coloredGraph} node={nodeRenderer} link={linkRenderer} nodeWidth={nodeWidth}
      nodePadding={expandedLayout ? 18 : 16} iterations={48}
      margin={sankeyLayoutMargin} align="justify" sort={sortNodes}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave} onClick={handleClick}>
      <Tooltip content={<SankeyTooltip total={total} />} isAnimationActive={false} cursor={false} />
    </Sankey>
  </SankeyInteractionContext.Provider>;
}

SHA-256: 48c412a1e6394f152ae6bf8fef5023f76d6347844abb0a3710aa0f48f748a9c2