import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { api } from '../api/client'; import { MOCK_GRAPH } from '../lib/mockData'; import Spinner from '../components/ui/Spinner'; const NODE_COLORS = { province: '#f59e0b', input: '#8b5cf6', ksm: '#8b5cf6', api: '#10b981', drug: '#3b82f6', }; const TYPE_RADIUS = { province: 12, input: 9, ksm: 8, api: 10, drug: 9, }; const TYPE_LABELS = { province: 'Province', input: 'Input', ksm: 'KSM', api: 'API', drug: 'Drug', }; const FILTERS = ['all', 'province', 'api', 'drug', 'input']; function riskColor(risk) { const value = Number(risk || 0); if (value >= 80) return '#f43f5e'; if (value >= 60) return '#f59e0b'; if (value >= 35) return '#4f9cf9'; return '#10b981'; } function riskBand(risk) { const value = Number(risk || 0); if (value >= 80) return 'Critical'; if (value >= 60) return 'High'; if (value >= 35) return 'Elevated'; return 'Contained'; } function formatCompact(value) { const num = Number(value || 0); if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; if (num >= 1_000) return `${(num / 1_000).toFixed(1)}k`; return `${Math.round(num)}`; } function useSize(ref) { const [size, setSize] = useState({ w: 1040, h: 660 }); useEffect(() => { if (!ref.current) return undefined; const observer = new ResizeObserver(([entry]) => { setSize({ w: Math.max(640, Math.round(entry.contentRect.width)), h: Math.max(560, Math.round(entry.contentRect.height)), }); }); observer.observe(ref.current); return () => observer.disconnect(); }, [ref]); return size; } function buildLayout(nodes, width, height) { const positions = {}; const buckets = { 0: [], 1: [], 2: [] }; const columnForType = { province: 0, input: 0, ksm: 0, api: 1, drug: 2 }; const sorted = [...nodes].sort((a, b) => { const colDiff = (columnForType[a.type] ?? 1) - (columnForType[b.type] ?? 1); if (colDiff !== 0) return colDiff; return Number(b.attributes?.current_risk || 0) - Number(a.attributes?.current_risk || 0); }); sorted.forEach((node) => { const column = columnForType[node.type] ?? 1; buckets[column].push(node); }); const paddingX = 84; const paddingY = 80; const columns = [ paddingX + (width - paddingX * 2) * 0.12, paddingX + (width - paddingX * 2) * 0.48, paddingX + (width - paddingX * 2) * 0.84, ]; [0, 1, 2].forEach((column) => { const nodesInColumn = buckets[column]; const count = nodesInColumn.length; const step = count > 1 ? (height - paddingY * 2) / (count - 1) : 0; nodesInColumn.forEach((node, index) => { const baseY = count === 1 ? height / 2 : paddingY + step * index; const wave = ((index % 2 === 0 ? 1 : -1) * Math.min(20, Number(node.attributes?.current_risk || 0) / 7)); positions[node.id] = { x: columns[column] + (column === 1 ? wave * 0.18 : 0), y: baseY + (column === 0 ? wave : column === 2 ? -wave * 0.35 : wave * 0.12), }; }); }); return positions; } function buildPath(source, target) { const distance = Math.abs(target.x - source.x); const curve = Math.max(46, distance * 0.34); const midX = (source.x + target.x) / 2; return `M ${source.x} ${source.y} C ${midX - curve} ${source.y}, ${midX + curve} ${target.y}, ${target.x} ${target.y}`; } function SummaryMetric({ label, value, sub, tone = 'var(--text)' }) { return (

{value}

{label}

{sub}

); } export default function Graph() { const [graphData, setGraphData] = useState(null); const [engines, setEngines] = useState(null); const [health, setHealth] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState('all'); const [selectedId, setSelectedId] = useState(null); const stageRef = useRef(null); const { w, h } = useSize(stageRef); const [rippleNode, setRippleNode] = useState(null); const [rippleTime, setRippleTime] = useState(0); const animRef = useRef(0); useEffect(() => { setLoading(true); Promise.all([ api.getGraph().catch(() => MOCK_GRAPH), api.getEngineStatus().catch(() => null), api.health().catch(() => null), ]) .then(([graph, engineStatus, healthStatus]) => { setGraphData(graph); setEngines(engineStatus); setHealth(healthStatus); }) .catch(() => { setGraphData(MOCK_GRAPH); }) .finally(() => setLoading(false)); }, []); // Particle animation timer useEffect(() => { let id; const tick = () => { animRef.current = (animRef.current + 0.005) % 1; id = requestAnimationFrame(tick); }; id = requestAnimationFrame(tick); return () => cancelAnimationFrame(id); }, []); const triggerRipple = useCallback((nodeId) => { setRippleNode(nodeId); setRippleTime(Date.now()); setTimeout(() => setRippleNode(null), 2000); }, []); const allNodes = useMemo(() => graphData?.nodes || [], [graphData]); const allEdges = useMemo(() => graphData?.edges || [], [graphData]); const rankedNodes = useMemo( () => [...allNodes].sort((a, b) => Number(b.attributes?.current_risk || 0) - Number(a.attributes?.current_risk || 0)), [allNodes], ); useEffect(() => { if (!selectedId && rankedNodes.length) { setSelectedId(rankedNodes[0].id); } }, [rankedNodes, selectedId]); const selectedNode = useMemo( () => allNodes.find((node) => node.id === selectedId) || rankedNodes[0] || null, [allNodes, rankedNodes, selectedId], ); const neighborMap = useMemo(() => { const upstream = []; const downstream = []; const connectedIds = new Set([selectedNode?.id].filter(Boolean)); if (!selectedNode) { return { upstream, downstream, connectedIds }; } for (const edge of allEdges) { if (edge.target === selectedNode.id) { const sourceNode = allNodes.find((node) => node.id === edge.source); if (sourceNode) upstream.push(sourceNode); connectedIds.add(edge.source); } if (edge.source === selectedNode.id) { const targetNode = allNodes.find((node) => node.id === edge.target); if (targetNode) downstream.push(targetNode); connectedIds.add(edge.target); } } return { upstream, downstream, connectedIds, }; }, [allEdges, allNodes, selectedNode]); const visibleNodes = useMemo(() => { if (filter === 'all') return allNodes; const filtered = allNodes.filter((node) => node.type === filter || neighborMap.connectedIds.has(node.id)); if (selectedNode && !filtered.find((node) => node.id === selectedNode.id)) { return [selectedNode, ...filtered]; } return filtered; }, [allNodes, filter, neighborMap.connectedIds, selectedNode]); const visibleIds = useMemo(() => new Set(visibleNodes.map((node) => node.id)), [visibleNodes]); const visibleEdges = useMemo( () => allEdges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)), [allEdges, visibleIds], ); const positions = useMemo(() => buildLayout(visibleNodes, w, h), [visibleNodes, w, h]); const feedLabel = health?.shock_feed_mode === 'hybrid_demo_live' ? 'Hybrid feed' : health?.shock_feed_mode === 'demo' ? 'Curated scenarios' : 'Live feed'; const activeMode = engines?.engine_2?.active_mode || 'pagerank'; const communitiesDetected = engines?.engine_2?.communities_detected ?? graphData?.state_risk_aggregates?.length ?? 0; if (loading) { return (
); } if (error) { return (

Graph view failed to load.

{error}

); } if (!graphData) return null; const selectedRisk = Number(selectedNode?.attributes?.current_risk || 0); const selectedColor = selectedNode ? riskColor(selectedRisk) : 'var(--primary)'; const highRiskCount = allNodes.filter((node) => Number(node.attributes?.current_risk || 0) >= 70).length; return (

Propagation Graph

{activeMode} {feedLabel} {engines?.engine_2?.community_detection_method || 'louvain'}

Explore how source provinces, APIs, and downstream drugs connect. Selecting a node lights up its active dependency corridor, reveals why its risk is elevated, and surfaces the state exposure it contributes to.

{FILTERS.map((type) => ( ))}

Animated paths show the currently selected dependency corridor.

Source provinces and inputs APIs and intermediates Indian demand pressure
{visibleEdges.map((edge) => { const source = positions[edge.source]; const target = positions[edge.target]; if (!source || !target) return null; const selected = selectedNode && (edge.source === selectedNode.id || edge.target === selectedNode.id); const path = buildPath(source, target); const weight = Math.max(1, Math.min(3.2, Number(edge.weight || 1))); const isRippling = rippleNode && (edge.source === rippleNode || edge.target === rippleNode); return ( {(selected || isRippling) && ( <> {/* Animated particle dot */} )} ); })} {visibleNodes.map((node) => { const position = positions[node.id]; if (!position) return null; const risk = Number(node.attributes?.current_risk || 0); const fill = risk > 0 ? riskColor(risk) : NODE_COLORS[node.type] || 'var(--primary)'; const radius = TYPE_RADIUS[node.type] || 8; const selected = selectedNode?.id === node.id; const connected = neighborMap.connectedIds.has(node.id); return ( setSelectedId(node.id)} className="graph-node" > {(selected || risk >= 70 || node.id === rippleNode) && ( <> {node.id === rippleNode && ( <> )} )} = 70 ? 'graph-node-pulse' : undefined} /> {(node.name || node.id).slice(0, 26)} {node.type === 'province' && risk >= 60 && ( { e.stopPropagation(); triggerRipple(node.id); }} > ⚡ TRIGGER )} ); })}
{Object.entries(TYPE_LABELS).map(([type, label]) => ( {label} ))}
{health?.live_shocks || 0} live shocks | {health?.demo_scenarios || 0} curated scenarios

Selected node

{selectedNode?.name || 'None selected'}

{selectedNode && ( {TYPE_LABELS[selectedNode.type] || selectedNode.type} )}
{selectedNode ? ( <>
Current risk {Math.round(selectedRisk)} / 100

{riskBand(selectedRisk)} modeled pressure in the current propagation graph.

Buffer

{selectedNode.attributes?.buffer_days ?? '-'}d

Substitutability

{selectedNode.attributes?.substitutability != null ? `${Math.round(Number(selectedNode.attributes.substitutability) * 100)}%` : '-'}

Population / demand

{selectedNode.attributes?.patient_population_estimate != null ? formatCompact(selectedNode.attributes.patient_population_estimate) : selectedNode.attributes?.monthly_import_tonnes != null ? `${selectedNode.attributes.monthly_import_tonnes} t` : '-'}

Priority

{selectedNode.attributes?.nlem_tier || selectedNode.attributes?.strategic_priority || '-'}

Upstream dependencies

{neighborMap.upstream.length > 0 ? neighborMap.upstream.slice(0, 5).map((node) => ( )) : No upstream nodes in current graph.}

Downstream impact

{neighborMap.downstream.length > 0 ? neighborMap.downstream.slice(0, 5).map((node) => ( )) : No downstream nodes in current graph.}
) : (

Select a node to inspect its risk components and links.

)}

Highest risk nodes

Top 6
{rankedNodes.slice(0, 6).map((node) => { const risk = Number(node.attributes?.current_risk || 0); const active = selectedNode?.id === node.id; return ( ); })}

State exposure

Demand-weighted
{(graphData.state_risk_aggregates || []).map((state) => (
{state.state_name} {Math.round(state.risk_score)}
))}
); }