import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { sankey as d3Sankey, sankeyLinkHorizontal } from 'd3-sankey' import api from '../lib/api' // "Follow the money" — a tabbed Sankey flow hero (Money Moves lens on the // homepage). Three lenses, ALL traced to the warehouse via GET /api/money-flow: // spending (real money decisions) · grants (990 Schedule I) · economy (real // nonprofit revenue decomposition). No fabricated numbers: a lens with no real // data renders an honest empty state; we never draw an invented diagram. interface FlowMeta { title: string subtitle?: string | null url?: string | null source_label?: string | null } interface FlowNode { name: string /** Per-node drill-down target (grantor or grantee). null = not clickable. */ url?: string | null } interface FlowLink { source: number target: number value: number value_label: string meta: FlowMeta } interface FlowLens { accent: string head_amount: string head_label: string /** Aggregate drill-down for the headline figure (decisions / grants / nonprofits). */ head_url?: string | null count_label: string nodes: FlowNode[] links: FlowLink[] placeholder: boolean } interface MoneyFlowResp { location_label: string lenses: { spending: FlowLens; grants: FlowLens; economy: FlowLens; government: FlowLens } } export interface FollowTheMoneyProps { embedded?: boolean stateCode?: string city?: string /** County name — used by the Government-budget lens to stack county + school district. */ county?: string national?: boolean /** Free-text search; filters spending/contract/grant flows server-side. */ query?: string /** WHEN selector value (month|quarter|year|fiveyear|all|auto) — scopes the spending lens by date. */ window?: string } type LensKey = 'spending' | 'grants' | 'economy' | 'government' const TABS: { key: LensKey; label: string }[] = [ { key: 'government', label: 'Government budget' }, { key: 'spending', label: 'Public spending' }, { key: 'grants', label: 'Grants' }, { key: 'economy', label: 'Nonprofit economy' }, ] // Plain-language explainer shown under the headline for each lens — the 990 // revenue buckets in the economy lens especially aren't self-explanatory. const LENS_BLURB: Record = { government: ( <> The layers of government you fund — city,{' '} county, state, and your{' '} school district — flowing into what they spend it on. Each is shown as your per-resident share (budget ÷ population), so the figures compare honestly and the state doesn’t dwarf the city. Real U.S. Census finances. ), spending: ( <>Real budget decisions local government made — each flow is one vote, sized by the dollars involved. Click a flow for the decision. ), grants: ( <> Grants nonprofits and foundations gave each other, from IRS 990 Schedule I filings. Money flows left (funder) → right (recipient); click a flow for that grant’s details. ), economy: ( <> Where this area’s nonprofits get their money, in the three buckets the IRS 990 form uses:{' '} Contributions & grants (donations plus government and foundation grants), Program service revenue (fees they earn doing their actual work — tuition, hospital care, tickets, memberships), and{' '} Other revenue (investments, rentals, asset sales). It’s a snapshot of total sector revenue drawn as a flow — not funder→recipient transfers. ), } const W = 800 const H = 300 // d3-sankey node/link after layout (geometry attached to copies of our data). type LaidNode = FlowNode & { x0: number; x1: number; y0: number; y1: number; value: number } type LaidLink = Omit & { source: LaidNode target: LaidNode width: number } const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s) interface TipState { x: number y: number meta: FlowMeta valueLabel: string accent: string } export default function FollowTheMoney({ embedded = false, stateCode, city, county, national = false, query, window, }: FollowTheMoneyProps) { const navigate = useNavigate() // Lead with the Government-budget lens (the resident's own tax dollars) when a // location is known; the decision-based lenses are the deeper dive. const [tab, setTab] = useState(stateCode && !national ? 'government' : 'spending') const [tip, setTip] = useState(null) const scopedState = national ? undefined : stateCode || undefined const scopedCity = national ? undefined : city || undefined const scopedCounty = national ? undefined : county || undefined const q = query?.trim() || undefined // 'auto'/'all' impose no date filter server-side; only send a concrete window. const win = window && window !== 'auto' && window !== 'all' ? window : undefined const { data, isLoading, isError } = useQuery({ queryKey: ['money-flow', national, scopedState, scopedCity, scopedCounty, q, win], queryFn: () => api .get('/money-flow', { params: { state: scopedState, city: scopedCity, county: scopedCounty, q, window: win }, }) .then((r) => r.data as MoneyFlowResp), staleTime: 5 * 60 * 1000, }) const lens = data?.lenses[tab] const accent = lens?.accent || '#0d9488' // Compute the Sankey layout for the active lens (only when it has real links). const laid = useMemo(() => { if (!lens || lens.placeholder || lens.links.length === 0) return null try { const layout = d3Sankey() .nodeWidth(11) .nodePadding(22) .extent([ // Wide left gutter (220px) so long left-side names (grantors like // "AUBURN UNIVERSITY FOUNDATION") render right-anchored with room to // breathe — the wider the gutter, the less the labels get clipped. [220, 14], // Symmetric 220px right gutter so the two-line target labels — name + // dollar value — get the same room. Pulling both gutters in also // narrows the middle flow band, trading bar width for text width. [W - 220, H - 14], ]) const graph = layout({ nodes: lens.nodes.map((n) => ({ ...n })) as LaidNode[], links: lens.links.map((l) => ({ ...l })) as unknown as LaidLink[], }) const linkPath = sankeyLinkHorizontal() return { nodes: graph.nodes as LaidNode[], links: (graph.links as LaidLink[]).map((l) => ({ link: l, d: linkPath(l) || '' })), } } catch { return null } }, [lens]) const onSvgLeave = () => setTip(null) const onLinkMove = (e: React.MouseEvent, l: LaidLink) => setTip({ x: e.clientX, y: e.clientY, meta: l.meta, valueLabel: l.value_label, accent }) const onLinkClick = (l: LaidLink) => { if (l.meta.url) navigate(l.meta.url) } const header = (
{!embedded && (

Follow the money

)}

Public money and grants flow from funders into projects, nonprofits, and vendors —{' '} {data?.location_label ? ( {data.location_label} ) : ( 'one flow' )} , three lenses.

) const body = ( <> {header}
{/* tabs */}
{TABS.map((t) => { const on = t.key === tab return ( ) })}
{/* flow header */}
{lens && !lens.placeholder ? ( lens.head_url ? ( ) : ( <> {lens.head_amount} {lens.head_label} ) ) : ( )}
{lens?.count_label}
{/* plain-language explainer for the active lens */} {lens && !lens.placeholder && (

{LENS_BLURB[tab]}

)} {/* flow area */}
{isLoading ? (
) : isError ? (
Couldn’t load the money flow right now.{' '} Please try again.
) : !laid ? (
No {TABS.find((t) => t.key === tab)?.label.toLowerCase()} flows available for this area yet.
) : ( // key={tab} remounts the diagram on lens switch so the open // animation (links draw in left→right, then bars + labels rise) // replays each time. {laid.links.map(({ link, d }, i) => ( onLinkMove(e, link)} onMouseLeave={onSvgLeave} onClick={() => onLinkClick(link)} /> {/* white dash stream — pointer-events pass through to the band */} ))} {laid.nodes.map((n, i) => { const leftSide = n.x0 < W / 2 const isSource = i === 0 && tab !== 'grants' // Right-side (target) nodes still resolve their feeding link for the // hover tooltip + two-line value label. const feed = leftSide ? undefined : lensTargetLink(lens, n) // Drill-down comes from the node's OWN url now (set server-side on // both sides), so left-side grantor nodes are clickable too. const nodeUrl = n.url const clickable = !!nodeUrl const labelX = leftSide ? n.x0 - 8 : n.x1 + 8 const cy = (n.y0 + n.y1) / 2 return ( setTip({ x: e.clientX, y: e.clientY, meta: feed.meta, valueLabel: feed.value_label, accent, }) : undefined } onMouseLeave={feed ? onSvgLeave : undefined} onClick={clickable ? () => navigate(nodeUrl!) : undefined} > {leftSide ? ( {trunc(n.name, 30)} ) : ( // Two lines: name on top, the dollar value beneath in the // lens accent. Splitting them keeps the value on-screen // instead of being clipped off the right edge. {trunc(n.name, 30)} {feed?.value_label || lensValueLabel(lens, n)} )} ) })} )}

Live from the warehouse — spending is real money-flagged decisions, grants are 990 Schedule I flows, and the nonprofit-economy tab is a real decomposition of sector revenue (a snapshot drawn as a flow), not invented funder→grantee edges.

{tip && (
{tip.meta.title}
{tip.meta.subtitle &&
{tip.meta.subtitle}
}
{tip.valueLabel} {tip.meta.source_label ? ` · ${tip.meta.source_label}` : ''}
)} ) if (embedded) { return (
{body}
) } return (
{body}
) } // The link that feeds a right-side (target) node — its meta.url is the // drill-down target and its value_label is the node's pre-formatted amount. function lensTargetLink(lens: FlowLens | undefined, node: LaidNode): FlowLink | undefined { if (!lens) return undefined const idx = lens.nodes.findIndex((n) => n.name === node.name) return lens.links.find((l) => l.target === idx) } // Right-side node value label: reuse the feeding link's pre-formatted // value_label (never re-format numbers on the client). function lensValueLabel(lens: FlowLens | undefined, node: LaidNode): string { return lensTargetLink(lens, node)?.value_label || '' }