import { useMemo, useState, type PointerEvent } from "react"; import { sankey, sankeyLinkHorizontal, type SankeyNodeMinimal, type SankeyLinkMinimal } from "d3-sankey"; import { useParentSize } from "@visx/responsive"; import { localPoint } from "@visx/event"; import type { CashflowNode, CashflowLink, SankeyNodeKind } from "@/lib/cashflowSankey"; import { shareColor } from "@/components/dashboard/shareColors"; import { useMoney } from "@/hooks/useMoney"; import { ChartTooltip } from "@/components/charts/ChartTooltip"; type LaidOutNode = CashflowNode & Required, "x0" | "x1" | "y0" | "y1" | "depth">> & { value?: number }; type LaidOutLink = CashflowLink & SankeyLinkMinimal & { source: LaidOutNode; target: LaidOutNode }; const NODE_WIDTH = 10; const NODE_PADDING = 16; const ROW_HEIGHT = 34; //: Node labels render outside the node rects (left column labels to their //: left, right column to their right), so the layout's own extent has to //: leave room for them or the SVG's default overflow:hidden clips every //: label off the canvas entirely. const LABEL_MARGIN = 152; // Emphasis levels for hover/focus. A hovered node lights up every link // touching it (and those links' other endpoints); a hovered link lights up // only itself and its own two endpoints. Everything else fades. const LINK_OPACITY = 0.4; const LINK_OPACITY_CONNECTED = 0.8; const LINK_OPACITY_ACTIVE = 0.9; const LINK_OPACITY_DIMMED = 0.06; const NODE_OPACITY_DIMMED = 0.25; const TRANSITION = "opacity 200ms ease, stroke-opacity 200ms ease"; type Hover = { type: "node"; id: string } | { type: "link"; index: number } | null; function nodeFill(kind: SankeyNodeKind, expenseRank: number): string { if (kind === "income" || kind === "surplus") return "hsl(var(--chart-green))"; if (kind === "hub") return "hsl(var(--foreground))"; return shareColor(expenseRank); } //: An income source is a merchant name and can be arbitrarily long; without //: a cap it would run past the reserved label margin regardless of size. function truncate(s: string, max = 18): string { return s.length > max ? `${s.slice(0, max - 1)}…` : s; } /** * Income sources -> Cash flow -> expense categories (+ surplus), Sure's * dashboard Cashflow diagram. Built directly on d3-sankey (no visx * equivalent exists for this layout) with a plain SVG render: node rects * carry identity colour, each link is tinted by whichever endpoint isn't the * hub, so a flow visually belongs to the source or category it touches * rather than the neutral hub in the middle. */ export function CashflowSankeyChart({ nodes, links }: { nodes: CashflowNode[]; links: CashflowLink[] }) { const { money, compact } = useMoney(); const { parentRef, width } = useParentSize({ debounceTime: 30 }); const [hover, setHover] = useState(null); const [pointer, setPointer] = useState<{ x: number; y: number } | null>(null); const expenseRank = useMemo(() => { const ranks = new Map(); nodes.filter((n) => n.kind === "expense").forEach((n, i) => ranks.set(n.id, i)); return ranks; }, [nodes]); const maxColumnRows = useMemo(() => { const byDepthCount = new Map(); for (const n of nodes) byDepthCount.set(n.kind, (byDepthCount.get(n.kind) ?? 0) + 1); return Math.max(byDepthCount.get("income") ?? 0, (byDepthCount.get("expense") ?? 0) + (byDepthCount.get("surplus") ?? 0)); }, [nodes]); const height = Math.max(220, maxColumnRows * ROW_HEIGHT + 40); const { laidOutNodes, laidOutLinks } = useMemo(() => { if (nodes.length === 0 || width === 0) return { laidOutNodes: [] as LaidOutNode[], laidOutLinks: [] as LaidOutLink[] }; const gen = sankey() .nodeId((d) => d.id) .nodeWidth(NODE_WIDTH) .nodePadding(NODE_PADDING) .extent([ [LABEL_MARGIN, 10], [Math.max(LABEL_MARGIN + 40, width - LABEL_MARGIN), height - 10], ]); const graph = gen({ nodes: nodes.map((d) => ({ ...d })), links: links.map((d) => ({ ...d })), }); return { laidOutNodes: graph.nodes as LaidOutNode[], laidOutLinks: graph.links as LaidOutLink[] }; }, [nodes, links, width, height]); // Total flow through the hub — d3-sankey gives every node a `.value` of // max(incoming, outgoing) when none is supplied explicitly, so the hub's is // the total cash flow for the period. Link tooltips show each flow's share // of this, not of their own column, so a link reads the same whether it's // hovered on the income or the expense side. const totalFlow = useMemo( () => laidOutNodes.find((n) => n.kind === "hub")?.value ?? 0, [laidOutNodes] ); // What a hover lights up: the hovered thing itself, plus everything one // hop away from it (a node's links and their other ends, or a link's own // two endpoints). const connected = useMemo(() => { if (!hover) return null; const nodeIds = new Set(); const linkIndexes = new Set(); if (hover.type === "node") { nodeIds.add(hover.id); laidOutLinks.forEach((l, i) => { if (l.source.id === hover.id || l.target.id === hover.id) { linkIndexes.add(i); nodeIds.add(l.source.id); nodeIds.add(l.target.id); } }); } else { const l = laidOutLinks[hover.index]; if (l) { linkIndexes.add(hover.index); nodeIds.add(l.source.id); nodeIds.add(l.target.id); } } return { nodeIds, linkIndexes }; }, [hover, laidOutLinks]); const linkOpacity = (index: number): number => { if (!hover) return LINK_OPACITY; if (hover.type === "link") return index === hover.index ? LINK_OPACITY_ACTIVE : LINK_OPACITY_DIMMED; return connected?.linkIndexes.has(index) ? LINK_OPACITY_CONNECTED : LINK_OPACITY_DIMMED; }; const nodeOpacity = (id: string): number => { if (!hover) return 1; return connected?.nodeIds.has(id) ? 1 : NODE_OPACITY_DIMMED; }; const clearHover = () => { setHover(null); setPointer(null); }; const track = (e: PointerEvent) => { const pt = localPoint(e); if (pt) setPointer({ x: pt.x, y: pt.y }); }; const linkPath = sankeyLinkHorizontal(); if (nodes.length === 0) { return (
No income or spending in this period.
); } const maxDepth = Math.max(...laidOutNodes.map((n) => n.depth), 0); const hoveredLink = hover?.type === "link" ? laidOutLinks[hover.index] : undefined; return (
{width > 0 && ( {laidOutLinks.map((l, i) => { // The link "belongs" to whichever side isn't the neutral hub, // so income -> hub is coloured by the source and hub -> expense // by the target. const owner = l.source.kind === "hub" ? l.target : l.source; const d = linkPath(l) ?? ""; const pct = totalFlow > 0 ? (l.value / totalFlow) * 100 : 0; return ( {/* Wider transparent hit area so thin flows and the gap right at a node's edge are still easy to hover, which is what caused the flicker moving from a node onto its links. */} { setHover({ type: "link", index: i }); track(e); }} onPointerMove={track} onPointerLeave={clearHover} onFocus={() => setHover({ type: "link", index: i })} onBlur={clearHover} /> ); })} {laidOutNodes.map((n) => { const fill = nodeFill(n.kind, expenseRank.get(n.id) ?? 0); const isLeft = n.depth === 0; const isRight = n.depth === maxDepth; const cy = (n.y0 + n.y1) / 2; const value = n.kind === "hub" ? undefined : n.value; return ( setHover({ type: "node", id: n.id })} onPointerLeave={clearHover} onFocus={() => setHover({ type: "node", id: n.id })} onBlur={clearHover} > {n.kind !== "hub" && ( {n.name} {truncate(n.name)} {value != null && ( {compact(value)} )} )} ); })} )} {hoveredLink && pointer && (

{hoveredLink.source.name} → {hoveredLink.target.name}

{money(hoveredLink.value)} ({totalFlow > 0 ? ((hoveredLink.value / totalFlow) * 100).toFixed(1) : "0.0"}%)

)}
); }