'use client'; import { useState, useCallback, useRef, useEffect, memo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import dynamic from 'next/dynamic'; import { X, Maximize2, Minimize2, Loader2, AlertTriangle, Plane, Ship, Building2, User, Globe, Newspaper, ShieldAlert, RefreshCw, Network, Wifi } from 'lucide-react'; const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }); // ── TYPES ── interface EntityNode { id: string; label: string; type: 'aircraft' | 'vessel' | 'company' | 'person' | 'country' | 'event' | 'sanction' | 'ip'; properties?: Record; x?: number; y?: number; } interface EntityLink { source: string | EntityNode; target: string | EntityNode; label: string; } interface GraphData { nodes: EntityNode[]; links: EntityLink[]; } // ── PALETTE ── const TYPE_COLORS: Record = { aircraft: '#00E5FF', vessel: '#00BCD4', company: '#D4AF37', person: '#B388FF', country: '#76FF03', event: '#FF9500', sanction: '#FF1744', ip: '#FF6D00', }; const TYPE_ICONS: Record = { aircraft: Plane, vessel: Ship, company: Building2, person: User, country: Globe, event: Newspaper, sanction: ShieldAlert, ip: Wifi, }; // ── PROPS ── interface Props { entity: { type: string; id: string; label?: string; properties?: Record } | null; onClose: () => void; } function EntityGraphPanel({ entity, onClose }: Props) { const [graphData, setGraphData] = useState({ nodes: [], links: [] }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [expanded, setExpanded] = useState(false); const [expandedIds, setExpandedIds] = useState>(new Set()); const graphRef = useRef(null); const containerRef = useRef(null); const mergeGraph = useCallback((existing: GraphData, incoming: GraphData): GraphData => { const nodeMap = new Map(); for (const n of existing.nodes) nodeMap.set(n.id, n); for (const n of incoming.nodes) if (!nodeMap.has(n.id)) nodeMap.set(n.id, n); const linkSet = new Set(existing.links.map(l => { const s = typeof l.source === 'string' ? l.source : l.source.id; const t = typeof l.target === 'string' ? l.target : l.target.id; return `${s}→${t}→${l.label}`; })); const merged = [...existing.links]; for (const l of incoming.links) { const s = typeof l.source === 'string' ? l.source : l.source.id; const t = typeof l.target === 'string' ? l.target : l.target.id; const k = `${s}→${t}→${l.label}`; if (!linkSet.has(k)) { linkSet.add(k); merged.push(l); } } return { nodes: Array.from(nodeMap.values()), links: merged }; }, []); const expandEntity = useCallback(async (type: string, id: string, properties?: Record) => { const key = `${type}:${id}`; if (expandedIds.has(key)) return; setLoading(true); setError(null); try { const params = new URLSearchParams({ type, id }); // Forward extra properties for aircraft/vessel resolution if (properties?.registration) params.set('registration', properties.registration); if (properties?.model) params.set('model', properties.model); if (properties?.icao24) params.set('icao24', properties.icao24); const res = await fetch(`/api/entity/expand?${params}`, { cache: 'no-store' }); if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); } const data = await res.json(); setGraphData(prev => mergeGraph(prev, { nodes: data.nodes || [], links: data.links || [] })); setExpandedIds(prev => new Set([...prev, key])); } catch (e) { setError(e instanceof Error ? e.message : 'Expansion failed'); } finally { setLoading(false); } }, [expandedIds, mergeGraph]); useEffect(() => { if (!entity) return; const root: EntityNode = { id: `${entity.type}:${entity.id}`, label: entity.label || entity.id, type: entity.type as EntityNode['type'], properties: entity.properties, }; setGraphData({ nodes: [root], links: [] }); setExpandedIds(new Set()); setSelectedNode(root); setError(null); expandEntity(entity.type, entity.id, entity.properties); }, [entity]); // eslint-disable-line react-hooks/exhaustive-deps const handleNodeClick = useCallback((node: any) => { const n = node as EntityNode; setSelectedNode(n); const rawId = n.id.includes(':') ? n.id.split(':').slice(1).join(':') : n.id; if (!expandedIds.has(`${n.type}:${rawId}`)) expandEntity(n.type, rawId); }, [expandedIds, expandEntity]); const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const n = node as EntityNode; const isSelected = n === selectedNode; const color = TYPE_COLORS[n.type] || '#888'; const size = isSelected ? 5 : 3.5; // Clean, precise circle ctx.beginPath(); ctx.arc(n.x!, n.y!, size, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = isSelected ? '#fff' : 'rgba(0,0,0,0.8)'; ctx.lineWidth = 1; ctx.stroke(); // Subtle target bracket for selected node (static, no pulsing) if (isSelected) { const bSize = size + 4; const bLen = 3; ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath(); // TL ctx.moveTo(n.x! - bSize, n.y! - bSize + bLen); ctx.lineTo(n.x! - bSize, n.y! - bSize); ctx.lineTo(n.x! - bSize + bLen, n.y! - bSize); // TR ctx.moveTo(n.x! + bSize - bLen, n.y! - bSize); ctx.lineTo(n.x! + bSize, n.y! - bSize); ctx.lineTo(n.x! + bSize, n.y! - bSize + bLen); // BL ctx.moveTo(n.x! - bSize, n.y! + bSize - bLen); ctx.lineTo(n.x! - bSize, n.y! + bSize); ctx.lineTo(n.x! - bSize + bLen, n.y! + bSize); // BR ctx.moveTo(n.x! + bSize - bLen, n.y! + bSize); ctx.lineTo(n.x! + bSize, n.y! + bSize); ctx.lineTo(n.x! + bSize, n.y! + bSize - bLen); ctx.stroke(); // Faint outer ring ctx.beginPath(); ctx.arc(n.x!, n.y!, bSize + 2, 0, 2*Math.PI); ctx.strokeStyle = `${color}30`; ctx.lineWidth = 1; ctx.stroke(); } // Clean label rendering const fontSize = Math.max(10 / globalScale, 3); if (fontSize > 3.5 || isSelected) { ctx.font = `${isSelected ? 'bold ' : ''}${fontSize}px 'JetBrains Mono', monospace`; ctx.fillStyle = isSelected ? '#fff' : `${color}cc`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; // Black background for text readability const label = n.label.length > 22 ? n.label.slice(0, 20) + '…' : n.label; const textWidth = ctx.measureText(label).width; ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(n.x! - textWidth/2 - 2, n.y! + size + 3, textWidth + 4, fontSize + 2); ctx.fillStyle = isSelected ? '#fff' : color; ctx.fillText(label, n.x!, n.y! + size + 4); } }, [selectedNode]); const paintLink = useCallback((link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const { source: s, target: t } = link; if (!s.x || !t.x) return; ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(t.x, t.y); // Smooth, thin, non-dashed lines ctx.strokeStyle = 'rgba(212,175,55,0.15)'; // faint gold ctx.lineWidth = Math.max(0.5, 1 / globalScale); ctx.stroke(); const fs = Math.max(8 / globalScale, 2); if (fs > 3) { ctx.font = `${fs}px 'JetBrains Mono', monospace`; ctx.fillStyle = 'rgba(212,175,55,0.4)'; ctx.textAlign = 'center'; ctx.fillText(link.label || '', (s.x + t.x) / 2, (s.y + t.y) / 2); } }, []); // Removed early return to allow rendering empty panel return (
{/* HEADER */}
[ OSIRIS // ENTITY INTEL ] {loading && }
{/* ROOT LABEL */} {entity ? (
{(() => { const I = TYPE_ICONS[entity.type] || Globe; return ; })()} {entity.label || entity.id} {graphData.nodes.length} NODES // {graphData.links.length} LINKS
) : (
[ AWAITING TARGET LOCK ]
)} {/* ERROR */} {error && (
[ ERR: {error} ]
)} {/* GRAPH */}
{graphData.nodes.length > 0 && ( 'rgba(212,175,55,0.6)'} /> )} {graphData.nodes.length === 0 && !loading && (
No graph data yet
)}
{/* SELECTED NODE */} {selectedNode && (
{(() => { const I = TYPE_ICONS[selectedNode.type] || Globe; return ; })()} {selectedNode.label}
[{selectedNode.type.toUpperCase()}]
{selectedNode.properties && Object.keys(selectedNode.properties).length > 0 && (
{Object.entries(selectedNode.properties).map(([k, v], i) => (
{k.replace(/_/g, ' ')}
{typeof v === 'boolean' ? (v ? 'YES' : 'NO') : String(v || '—')}
))}
)} {!expandedIds.has(`${selectedNode.type}:${selectedNode.id.includes(':') ? selectedNode.id.split(':').slice(1).join(':') : selectedNode.id}`) && ( )} )} {/* LEGEND */}
{Object.entries(TYPE_COLORS).map(([t, c]) => (
{t}
))}
); } export default memo(EntityGraphPanel);