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 (
);
}
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) => (
setFilter(type)}
>
{type === 'all' ? 'All nodes' : TYPE_LABELS[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) => (
setSelectedId(node.id)}>
{node.name}
)) : No upstream nodes in current graph. }
Downstream impact
{neighborMap.downstream.length > 0 ? neighborMap.downstream.slice(0, 5).map((node) => (
setSelectedId(node.id)}>
{node.name}
)) : No downstream nodes in current graph. }
>
) : (
Select a node to inspect its risk components and links.
)}
{rankedNodes.slice(0, 6).map((node) => {
const risk = Number(node.attributes?.current_risk || 0);
const active = selectedNode?.id === node.id;
return (
setSelectedId(node.id)}
>
{node.name}
{TYPE_LABELS[node.type] || node.type}
{Math.round(risk)}
);
})}
State exposure
Demand-weighted
{(graphData.state_risk_aggregates || []).map((state) => (
{state.state_name}
{Math.round(state.risk_score)}
))}
);
}