| 1 | "use client"; |
| 2 | import { useEffect, useMemo, useState } from "react"; |
| 3 | import { api, GraphData, Investigation } from "@/lib/api"; |
| 4 | |
| 5 | const COLORS: Record<string, string> = { |
| 6 | plate: "#4da3ff", camera: "#3fd07f", vehicle: "#ffb454", incident: "#ff5d6c", default: "#8595ab", |
| 7 | }; |
| 8 | |
| 9 | function layout(nodes: GraphData["nodes"], edges: GraphData["edges"], w: number, h: number) { |
| 10 | const pos = nodes.map((_, i) => { |
| 11 | const a = (i / Math.max(1, nodes.length)) * Math.PI * 2; |
| 12 | return { x: w / 2 + Math.cos(a) * 180, y: h / 2 + Math.sin(a) * 140 }; |
| 13 | }); |
| 14 | const idx = new Map(nodes.map((n, i) => [n.id, i])); |
| 15 | for (let iter = 0; iter < 220; iter++) { |
| 16 | for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { |
| 17 | let dx = pos[i].x - pos[j].x, dy = pos[i].y - pos[j].y; |
| 18 | let d2 = dx * dx + dy * dy || 1; |
| 19 | const f = 9000 / d2; |
| 20 | const d = Math.sqrt(d2); |
| 21 | dx /= d; dy /= d; |
| 22 | pos[i].x += dx * f; pos[i].y += dy * f; |
| 23 | pos[j].x -= dx * f; pos[j].y -= dy * f; |
| 24 | } |
| 25 | for (const e of edges) { |
| 26 | const a = idx.get(e.source), b = idx.get(e.target); |
| 27 | if (a == null || b == null) continue; |
| 28 | const dx = pos[b].x - pos[a].x, dy = pos[b].y - pos[a].y; |
| 29 | const d = Math.sqrt(dx * dx + dy * dy) || 1; |
| 30 | const f = (d - 120) * 0.01; |
| 31 | pos[a].x += (dx / d) * f; pos[a].y += (dy / d) * f; |
| 32 | pos[b].x -= (dx / d) * f; pos[b].y -= (dy / d) * f; |
| 33 | } |
| 34 | for (const p of pos) { p.x += (w / 2 - p.x) * 0.002; p.y += (h / 2 - p.y) * 0.002; } |
| 35 | } |
| 36 | return pos; |
| 37 | } |
| 38 | |
| 39 | export default function GraphPage() { |
| 40 | const [invs, setInvs] = useState<Investigation[]>([]); |
| 41 | const [sel, setSel] = useState<string>(""); |
| 42 | const [graph, setGraph] = useState<GraphData | null>(null); |
| 43 | const [err, setErr] = useState<string | null>(null); |
| 44 | |
| 45 | useEffect(() => { |
| 46 | api.investigations().then((i) => { setInvs(i); if (i[0]) setSel(i[0].id); }) |
| 47 | .catch((e) => setErr(e.status === 401 ? "Sign in to view the entity graph." : String(e))); |
| 48 | }, []); |
| 49 | useEffect(() => { if (sel) api.graph(sel).then(setGraph).catch(() => setErr("Graph load failed.")); }, [sel]); |
| 50 | |
| 51 | const W = 820, H = 540; |
| 52 | const pos = useMemo(() => graph ? layout(graph.nodes, graph.edges, W, H) : [], [graph]); |
| 53 | const idx = useMemo(() => new Map((graph?.nodes || []).map((n, i) => [n.id, i])), [graph]); |
| 54 | |
| 55 | return ( |
| 56 | <div> |
| 57 | <h1>Entity Graph</h1> |
| 58 | <p className="muted">Plates, vehicles, cameras and incidents linked by observed sightings and stored relationships.</p> |
| 59 | <div className="row" style={{ margin: "10px 0" }}> |
| 60 | <select value={sel} onChange={(e) => setSel(e.target.value)}> |
| 61 | {invs.map((i) => <option key={i.id} value={i.id}>{i.title}</option>)} |
| 62 | </select> |
| 63 | {graph && <span className="muted">{graph.nodes.length} nodes · {graph.edges.length} edges</span>} |
| 64 | </div> |
| 65 | {err && <div className="badge danger">{err}</div>} |
| 66 | |
| 67 | <div className="card" style={{ padding: 0, overflow: "hidden" }}> |
| 68 | {graph && ( |
| 69 | <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", background: "var(--panel)" }}> |
| 70 | {graph.edges.map((e, i) => { |
| 71 | const a = idx.get(e.source), b = idx.get(e.target); |
| 72 | if (a == null || b == null) return null; |
| 73 | return <line key={i} x1={pos[a].x} y1={pos[a].y} x2={pos[b].x} y2={pos[b].y} |
| 74 | stroke="var(--border)" strokeWidth={1.2} />; |
| 75 | })} |
| 76 | {graph.nodes.map((n, i) => ( |
| 77 | <g key={n.id} transform={`translate(${pos[i].x},${pos[i].y})`}> |
| 78 | <circle r={n.type === "plate" ? 11 : 8} fill={COLORS[n.type] || COLORS.default} |
| 79 | stroke="#04111f" strokeWidth={1.5} /> |
| 80 | <text x={13} y={4} fontSize={11} fill="var(--text)">{n.label}</text> |
| 81 | </g> |
| 82 | ))} |
| 83 | </svg> |
| 84 | )} |
| 85 | {!graph && !err && <div className="muted" style={{ padding: 20 }}>Loading graph…</div>} |
| 86 | </div> |
| 87 | |
| 88 | <div className="row" style={{ marginTop: 10, gap: 16 }}> |
| 89 | {Object.entries(COLORS).filter(([k]) => k !== "default").map(([k, c]) => ( |
| 90 | <span key={k} className="row" style={{ gap: 6 }}> |
| 91 | <span style={{ width: 12, height: 12, borderRadius: 6, background: c, display: "inline-block" }} /> |
| 92 | <span className="muted">{k}</span> |
| 93 | </span> |
| 94 | ))} |
| 95 | </div> |
| 96 | </div> |
| 97 | ); |
| 98 | } |