open-navigator / web_app /src /components /FollowTheMoney.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
19.8 kB
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&rsquo;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&nbsp;I filings. Money flows
left (funder) → right (recipient); click a flow for that grant&rsquo;s details.
</>
),
economy: (
<>
Where this area&rsquo;s nonprofits get their money, in the three buckets the IRS&nbsp;990 form uses:{' '}
<b className="font-semibold text-gray-700">Contributions &amp; 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&rsquo;s a
snapshot of total sector revenue drawn as a flow — not funder&rarr;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">
&rarr;
</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&rsquo;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&rarr;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 || ''
}