Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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<LensKey, React.ReactNode> = { | |
| government: ( | |
| <> | |
| The layers of government you fund — <b className="font-semibold text-gray-700">city</b>,{' '} | |
| <b className="font-semibold text-gray-700">county</b>, <b className="font-semibold text-gray-700">state</b>, and your{' '} | |
| <b className="font-semibold text-gray-700">school district</b> — flowing into what they spend it on. Each is | |
| shown as <b className="font-semibold text-gray-700">your per-resident share</b> (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:{' '} | |
| <b className="font-semibold text-gray-700">Contributions & grants</b> (donations plus government and | |
| foundation grants), <b className="font-semibold text-gray-700">Program service revenue</b> (fees they earn | |
| doing their actual work — tuition, hospital care, tickets, memberships), and{' '} | |
| <b className="font-semibold text-gray-700">Other revenue</b> (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<FlowLink, 'source' | 'target'> & { | |
| 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<LensKey>(stateCode && !national ? 'government' : 'spending') | |
| const [tip, setTip] = useState<TipState | null>(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<LaidNode, LaidLink>() | |
| .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<LaidNode, LaidLink>() | |
| 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 = ( | |
| <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> | |
| <div> | |
| {!embedded && ( | |
| <h2 className="text-3xl font-bold text-[#0f2b2b] md:text-4xl" style={{ fontFamily: "'Fraunces', serif" }}> | |
| Follow the money | |
| </h2> | |
| )} | |
| <p className={`max-w-2xl text-sm text-gray-500 ${embedded ? '' : 'mt-2 md:text-base'}`}> | |
| Public money and grants flow from funders into projects, nonprofits, and vendors —{' '} | |
| {data?.location_label ? ( | |
| <span className="font-medium text-gray-700">{data.location_label}</span> | |
| ) : ( | |
| 'one flow' | |
| )} | |
| , three lenses. | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| const body = ( | |
| <> | |
| {header} | |
| <div className="overflow-hidden rounded-2xl border border-gray-200 bg-white"> | |
| <div className="h-1 w-full transition-colors" style={{ background: accent }} /> | |
| {/* tabs */} | |
| <div className="flex gap-1 border-b border-gray-200 px-4 pt-3"> | |
| {TABS.map((t) => { | |
| const on = t.key === tab | |
| return ( | |
| <button | |
| key={t.key} | |
| type="button" | |
| onClick={() => setTab(t.key)} | |
| className={`-mb-px border-b-2 px-3 pb-3 pt-2 text-sm font-semibold transition-colors ${ | |
| on ? 'text-[#0f2b2b]' : 'border-transparent text-gray-400 hover:text-gray-700' | |
| }`} | |
| style={on ? { borderColor: accent } : undefined} | |
| > | |
| {t.label} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| {/* flow header */} | |
| <div className="flex items-baseline justify-between gap-3 px-5 pb-1 pt-3"> | |
| <div className="text-[13px] text-gray-500"> | |
| {lens && !lens.placeholder ? ( | |
| lens.head_url ? ( | |
| <button | |
| type="button" | |
| onClick={() => navigate(lens.head_url!)} | |
| title="See the full breakdown" | |
| className="group inline-flex items-baseline gap-1 text-left text-gray-500 transition-colors hover:text-gray-800" | |
| > | |
| <b className="text-[15px] font-bold text-[#0f2b2b] group-hover:underline">{lens.head_amount}</b> | |
| <span>{lens.head_label}</span> | |
| <span aria-hidden className="text-gray-400 transition-transform group-hover:translate-x-0.5"> | |
| → | |
| </span> | |
| </button> | |
| ) : ( | |
| <> | |
| <b className="text-[15px] font-bold text-[#0f2b2b]">{lens.head_amount}</b> {lens.head_label} | |
| </> | |
| ) | |
| ) : ( | |
| <span className="text-gray-400">—</span> | |
| )} | |
| </div> | |
| <div className="font-mono text-[11.5px] text-gray-400">{lens?.count_label}</div> | |
| </div> | |
| {/* plain-language explainer for the active lens */} | |
| {lens && !lens.placeholder && ( | |
| <p className="px-5 pb-1 pt-0.5 text-[12px] leading-relaxed text-gray-500">{LENS_BLURB[tab]}</p> | |
| )} | |
| {/* flow area */} | |
| <div className="px-3 pb-4 pt-1"> | |
| {isLoading ? ( | |
| <div className="h-[260px] animate-pulse rounded-xl bg-gray-50" /> | |
| ) : isError ? ( | |
| <div className="flex h-[200px] items-center justify-center rounded-xl border border-dashed border-gray-200 px-6 text-center text-sm text-gray-400"> | |
| Couldn’t load the money flow right now.{' '} | |
| <b className="ml-1 text-gray-600">Please try again.</b> | |
| </div> | |
| ) : !laid ? ( | |
| <div className="flex h-[200px] items-center justify-center rounded-xl border border-dashed border-gray-200 px-6 text-center text-sm text-gray-400"> | |
| No {TABS.find((t) => t.key === tab)?.label.toLowerCase()} flows available for this area yet. | |
| </div> | |
| ) : ( | |
| // key={tab} remounts the diagram on lens switch so the open | |
| // animation (links draw in left→right, then bars + labels rise) | |
| // replays each time. | |
| <svg key={tab} viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Money flow Sankey diagram"> | |
| <style>{` | |
| @keyframes ftm-draw { to { stroke-dashoffset: 0; } } | |
| @keyframes ftm-rise { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: none; } } | |
| @keyframes ftm-flow { to { stroke-dashoffset: -260; } } | |
| @keyframes ftm-fadein { to { opacity: 1; } } | |
| .ftm-link { stroke-dasharray: 1; stroke-dashoffset: 1; animation: ftm-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards; } | |
| .ftm-node { opacity: 0; animation: ftm-rise 0.5s ease-out forwards; } | |
| /* Perpetual "money in motion" dashes streaming along each flow, | |
| layered over the static band once it has drawn in. */ | |
| .ftm-flow { opacity: 0; animation: ftm-fadein 0.4s ease-out forwards, ftm-flow 3.5s linear infinite; } | |
| @media (prefers-reduced-motion: reduce) { | |
| .ftm-link { animation: none; stroke-dashoffset: 0; } | |
| .ftm-node { animation: none; opacity: 1; transform: none; } | |
| .ftm-flow { display: none; } | |
| } | |
| `}</style> | |
| {laid.links.map(({ link, d }, i) => ( | |
| <g key={i}> | |
| <path | |
| className="ftm-link" | |
| pathLength={1} | |
| d={d} | |
| fill="none" | |
| stroke={accent} | |
| strokeOpacity={0.32} | |
| strokeWidth={Math.max(2, link.width)} | |
| style={{ cursor: link.meta.url ? 'pointer' : 'default', animationDelay: `${i * 70}ms` }} | |
| onMouseMove={(e) => onLinkMove(e, link)} | |
| onMouseLeave={onSvgLeave} | |
| onClick={() => onLinkClick(link)} | |
| /> | |
| {/* white dash stream — pointer-events pass through to the band */} | |
| <path | |
| className="ftm-flow" | |
| d={d} | |
| fill="none" | |
| stroke="#fff" | |
| strokeWidth={2} | |
| strokeOpacity={0.55} | |
| strokeDasharray="2 11" | |
| pointerEvents="none" | |
| style={{ animationDelay: `${i * 70 + 650}ms, ${i * 70 + 650}ms` }} | |
| /> | |
| </g> | |
| ))} | |
| {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 ( | |
| <g | |
| key={i} | |
| className="ftm-node" | |
| style={{ cursor: clickable ? 'pointer' : 'default', animationDelay: `${260 + i * 55}ms` }} | |
| onMouseMove={ | |
| feed | |
| ? (e) => | |
| 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} | |
| > | |
| <rect | |
| x={n.x0} | |
| y={n.y0} | |
| width={n.x1 - n.x0} | |
| height={Math.max(2, n.y1 - n.y0)} | |
| rx={2} | |
| fill={isSource ? '#44403c' : tab === 'economy' ? '#a78bfa' : '#78716c'} | |
| /> | |
| {leftSide ? ( | |
| <text | |
| x={labelX} | |
| y={cy} | |
| dy="0.32em" | |
| textAnchor="end" | |
| fontSize={12} | |
| fill="#44403c" | |
| style={{ fontFamily: "'DM Sans', sans-serif" }} | |
| > | |
| {trunc(n.name, 30)} | |
| </text> | |
| ) : ( | |
| // 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. | |
| <text x={labelX} y={cy} textAnchor="start" style={{ fontFamily: "'DM Sans', sans-serif" }}> | |
| <tspan x={labelX} dy="-0.25em" fontSize={12} fontWeight={600} fill="#44403c"> | |
| {trunc(n.name, 30)} | |
| </tspan> | |
| <tspan | |
| x={labelX} | |
| dy="1.25em" | |
| fontSize={11} | |
| fontWeight={700} | |
| fill={accent} | |
| style={{ fontFamily: "'DM Mono', ui-monospace, monospace" }} | |
| > | |
| {feed?.value_label || lensValueLabel(lens, n)} | |
| </tspan> | |
| </text> | |
| )} | |
| </g> | |
| ) | |
| })} | |
| </svg> | |
| )} | |
| </div> | |
| </div> | |
| <p className="mt-3 text-[12.5px] leading-relaxed text-gray-500"> | |
| 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. | |
| </p> | |
| {tip && ( | |
| <div | |
| className="pointer-events-none fixed z-20 max-w-[260px] rounded-lg bg-[#1c1917] px-3 py-2 text-xs leading-snug text-white shadow-lg" | |
| style={{ left: tip.x + 14, top: tip.y + 14 }} | |
| > | |
| <div className="font-semibold">{tip.meta.title}</div> | |
| {tip.meta.subtitle && <div className="mt-0.5 text-[11px] text-stone-300">{tip.meta.subtitle}</div>} | |
| <div className="mt-1 flex items-center gap-1.5 font-mono text-[10.5px]"> | |
| <span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: tip.accent }} /> | |
| {tip.valueLabel} | |
| {tip.meta.source_label ? ` · ${tip.meta.source_label}` : ''} | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ) | |
| if (embedded) { | |
| return ( | |
| <div id="follow-the-money" className="scroll-mt-4"> | |
| {body} | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <section id="follow-the-money" className="bg-white px-4 py-16"> | |
| <div className="mx-auto max-w-4xl">{body}</div> | |
| </section> | |
| ) | |
| } | |
| // 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 || '' | |
| } | |