● Zion Boggan
repos/Sightline/frontend/src/app/graph/page.tsx
zionboggan.com ↗
98 lines · tsx
History for this file →
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
}