import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Network, RefreshCw, ChevronDown, ChevronUp, AlertCircle, Sparkles, ZoomIn, ZoomOut, Maximize2, Info, TrendingUp, GitBranch, AlertTriangle, Loader2, } from 'lucide-react'; import { API_BASE_URL } from '../config/api'; import './HealthGraph.css'; // Types matching backend schemas interface GraphNode { id: string; name: string; type: string; properties: Record; } interface GraphRelationship { source: string; relation: string; target: string; properties: Record; } interface GraphDataResponse { nodes: GraphNode[]; relationships: GraphRelationship[]; total_nodes: number; total_relationships: number; available: boolean; message: string | null; } interface InsightResponse { insight_type: string; content: string; sources: string[]; available: boolean; message: string | null; } type InsightType = 'temporal' | 'relationships' | 'contradictions'; const INSIGHT_BUTTONS: { type: InsightType; icon: typeof TrendingUp; label: string; description: string }[] = [ { type: 'temporal', icon: TrendingUp, label: 'Timeline Analysis', description: 'How have my metrics changed?' }, { type: 'relationships', icon: GitBranch, label: 'Health Connections', description: 'What\'s connected in my health data?' }, { type: 'contradictions', icon: AlertTriangle, label: 'Data Conflicts', description: 'Any inconsistencies in my records?' }, ]; interface HealthGraphProps { authToken?: string; apiBaseUrl?: string; defaultCollapsed?: boolean; } // Node type colors - using dashboard theme palette const nodeColors: Record = { condition: '#c85a54', // dash-danger (red) metric: '#7a9cc6', // dash-info (blue) medication: '#6b9175', // dash-accent (green) recommendation: '#d9a962', // dash-warning (amber) user: '#4a7c59', // dash-accent-dark (dark green) entity: '#999999', // dash-text-muted (gray) }; const normalizeNodeId = (value: string): string => value.trim().toLowerCase().replace(/\s+/g, '_'); const HealthGraph: React.FC = ({ authToken, apiBaseUrl = API_BASE_URL, defaultCollapsed = true, }) => { const [graphData, setGraphData] = useState(null); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [syncNotice, setSyncNotice] = useState(null); const [collapsed, setCollapsed] = useState(defaultCollapsed); const [available, setAvailable] = useState(true); const [zoom, setZoom] = useState(1); const [hoveredNode, setHoveredNode] = useState(null); const svgRef = useRef(null); // Insights state const [insightLoading, setInsightLoading] = useState(false); const [insightContent, setInsightContent] = useState(null); const [insightSources, setInsightSources] = useState([]); const [activeInsightType, setActiveInsightType] = useState(null); const [insightError, setInsightError] = useState(null); const fetchGraphData = useCallback(async () => { if (!authToken) { setError('Authentication required'); return; } setLoading(true); setError(null); try { const response = await fetch(`${apiBaseUrl}/api/graph/relationships?limit=30`, { headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Failed to fetch graph data: ${response.status}`); } const data: GraphDataResponse = await response.json(); setGraphData(data); setAvailable(data.available); if (data.message) { setError(data.message); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load graph data'); } finally { setLoading(false); } }, [authToken, apiBaseUrl]); // Generate AI insight from graph data const generateInsight = useCallback(async (insightType: InsightType) => { if (!authToken) { setInsightError('Authentication required'); return; } setInsightLoading(true); setInsightError(null); setInsightContent(null); setActiveInsightType(insightType); try { const response = await fetch(`${apiBaseUrl}/api/graph/insights`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ insight_type: insightType, context_limit: 10 }), }); if (!response.ok) { throw new Error(`Failed to generate insight: ${response.status}`); } const data: InsightResponse = await response.json(); if (!data.available) { throw new Error(data.message || 'Service not available'); } if (data.message && !data.content) { throw new Error(data.message); } if (!data.content || data.content.trim() === '') { setInsightError('No insight generated. Try adding more health data.'); return; } setInsightContent(data.content); setInsightSources(data.sources || []); } catch (err) { setInsightError(err instanceof Error ? err.message : 'Failed to generate insight'); } finally { setInsightLoading(false); } }, [authToken, apiBaseUrl]); const syncProfileToGraph = useCallback(async () => { if (!authToken) { setError('Authentication required'); return; } setSyncing(true); setError(null); setSyncNotice(null); try { const response = await fetch(`${apiBaseUrl}/api/profile/sync-to-memory`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Profile sync failed: ${response.status}`); } const payload = await response.json().catch(() => null); if (payload?.success === false) { throw new Error(payload?.message || 'Profile sync failed'); } const syncedFacts = payload?.synced?.facts_synced; if (typeof syncedFacts === 'number') { setSyncNotice(`Synced ${syncedFacts} facts from your profile. Refreshing graph...`); } else { setSyncNotice('Profile sync completed. Refreshing graph...'); } await fetchGraphData(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to sync profile data'); } finally { setSyncing(false); } }, [authToken, apiBaseUrl, fetchGraphData]); useEffect(() => { if (!collapsed && authToken) { fetchGraphData(); } }, [collapsed, authToken, fetchGraphData]); // Simple force-directed layout calculation (simplified) const calculateLayout = useCallback((nodes: GraphNode[]) => { const width = 600; const height = 400; const centerX = width / 2; const centerY = height / 2; // Position nodes in a circle or hierarchical layout const nodePositions: Record = {}; const nodeCount = nodes.length; if (nodeCount === 0) return nodePositions; // Group nodes by type const nodesByType: Record = {}; nodes.forEach(node => { if (!nodesByType[node.type]) { nodesByType[node.type] = []; } nodesByType[node.type].push(node); }); // Position nodes by type in concentric circles const types = Object.keys(nodesByType); let currentRadius = 80; types.forEach((type, typeIndex) => { const typeNodes = nodesByType[type]; const angleStep = (2 * Math.PI) / typeNodes.length; const offsetAngle = (typeIndex * Math.PI) / types.length; typeNodes.forEach((node, nodeIndex) => { const angle = offsetAngle + nodeIndex * angleStep; nodePositions[node.id] = { x: centerX + currentRadius * Math.cos(angle), y: centerY + currentRadius * Math.sin(angle), }; }); currentRadius += 60; }); return nodePositions; }, []); const nodePositions = useMemo( () => (graphData ? calculateLayout(graphData.nodes) : {}), [graphData, calculateLayout] ); const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.2, 2)); const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.2, 0.5)); const handleResetZoom = () => setZoom(1); return (
setCollapsed(!collapsed)} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setCollapsed(!collapsed)} >

Health Knowledge Graph

{available ? ( <>{graphData?.total_nodes || 0} nodes ) : ( <> Offline )}
{collapsed ? : }
{!collapsed && ( {!available ? (

Knowledge graph service is not available

Neo4j connection is required for relationship visualization.
) : loading ? (

Loading health relationships...

) : !graphData || graphData.nodes.length === 0 ? (

No graph data for your account yet

This graph is user-scoped. Sync your profile data or upload reports to generate relationships. {syncNotice && ( {syncNotice} )}
) : ( <>
{Math.round(zoom * 100)}%
{syncNotice && (
{syncNotice}
)} {/* AI Insights Panel */}
AI-Powered Insights
{INSIGHT_BUTTONS.map(({ type, icon: Icon, label, description }) => ( ))}
{(insightContent || insightLoading || insightError) && ( {insightLoading ? (
Analyzing your health data...
) : insightError ? (
{insightError}
) : insightContent && ( <>
{insightContent.split('\n').filter(line => line.trim()).map((line, i) => (

{line}

))}
{insightSources.length > 0 && (
{insightSources.length} facts used
    {insightSources.map((source, i) => (
  • {source}
  • ))}
)} )}
)}
{error && (
{error}
)}
{/* Relationship lines */} {graphData.relationships.map((rel, index) => { const sourcePos = nodePositions[normalizeNodeId(rel.source)]; const targetPos = nodePositions[normalizeNodeId(rel.target)]; if (!sourcePos || !targetPos) return null; return ( {rel.relation} ); })} {/* Nodes */} {graphData.nodes.map((node) => { const pos = nodePositions[node.id]; if (!pos) return null; const isHovered = hoveredNode?.id === node.id; return ( setHoveredNode(node)} onMouseLeave={() => setHoveredNode(null)} > {node.name.length > 14 ? node.name.slice(0, 12) + '...' : node.name} ); })} {/* Tooltip */} {hoveredNode && (
{hoveredNode.name} {hoveredNode.type}
)}
{/* Legend */}
{Object.entries(nodeColors).slice(0, 5).map(([type, color]) => (
{type}
))}
Showing {graphData.total_nodes} entities and {graphData.total_relationships} relationships
)}
)}
); }; export default HealthGraph;