@dhruviii/attack-path 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@dhruviii/attack-path",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "dist/Attack-path.umd.cjs",
6
+ "module": "dist/Attack-path.js",
7
+ "types": "dist/Attack-path.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/Attack-path.js",
11
+ "require": "./dist/Attack-path.cjs",
12
+ "types": "./dist/Attack-path.d.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "dev": "vite",
17
+ "build": "tsc -b && vite build",
18
+ "lint": "eslint .",
19
+ "preview": "vite preview",
20
+ "storybook": "storybook dev -p 6006",
21
+ "build-storybook": "storybook build"
22
+ },
23
+ "peerDependencies": {
24
+ "@tailwindcss/vite": "^4.2.1",
25
+ "@types/three": "^0.185.1",
26
+ "framer-motion": "^12.35.0",
27
+ "lucide-react": "^0.577.0",
28
+ "react-router-dom": "^7.13.1",
29
+ "tailwindcss": "^4.2.1",
30
+ "three": "^0.185.1"
31
+ },
32
+ "devDependencies": {
33
+ "@eslint/js": "^9.39.1",
34
+ "@types/node": "^24.10.1",
35
+ "@types/react": "^19.2.7",
36
+ "@types/react-dom": "^19.2.3",
37
+ "@vitejs/plugin-react": "^5.1.1",
38
+ "eslint": "^9.39.1",
39
+ "eslint-plugin-react-hooks": "^7.0.1",
40
+ "eslint-plugin-react-refresh": "^0.4.24",
41
+ "globals": "^16.5.0",
42
+ "typescript": "~5.9.3",
43
+ "typescript-eslint": "^8.48.0",
44
+ "vite": "^7.3.1",
45
+ "vite-plugin-dts": "^5.0.3",
46
+ "storybook": "^10.5.3",
47
+ "@storybook/react-vite": "^10.5.3",
48
+ "@chromatic-com/storybook": "^5.2.1",
49
+ "@storybook/addon-vitest": "^10.5.3",
50
+ "@storybook/addon-a11y": "^10.5.3",
51
+ "@storybook/addon-docs": "^10.5.3",
52
+ "@storybook/addon-mcp": "^0.7.0",
53
+ "eslint-plugin-storybook": "^10.5.3",
54
+ "vitest": "^4.1.10",
55
+ "playwright": "^1.61.1",
56
+ "@vitest/browser-playwright": "^4.1.10",
57
+ "@vitest/coverage-v8": "^4.1.10"
58
+ }
59
+ }
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="8" fill="#06080c"/>
3
+ <circle cx="8" cy="16" r="3" fill="#5b7cfa"/>
4
+ <circle cx="22" cy="9" r="2.6" fill="#ff5d68"/>
5
+ <circle cx="22" cy="16" r="2.6" fill="#ffc043"/>
6
+ <circle cx="22" cy="23" r="2.6" fill="#46a6ff"/>
7
+ <path d="M11 15 L19 9.5 M11 16 L19 16 M11 17 L19 22.5" stroke="#2a3646" stroke-width="1.4" fill="none"/>
8
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
package/src/App.css ADDED
File without changes
package/src/App.tsx ADDED
@@ -0,0 +1,9 @@
1
+ import Attack_path from "./Attack-path";
2
+
3
+ export const App = () => {
4
+ return (
5
+ <Attack_path />
6
+ )
7
+ }
8
+
9
+ export default App
@@ -0,0 +1,195 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { GitBranch, Maximize2, Minus, Plus, Share2, ShieldCheck } from "lucide-react";
3
+ import data from "./data/data.json";
4
+ import {
5
+ loadSurfaceFromJSON, graphLayout, pathLayout, severityMeta, subtreeOf, withPositions,
6
+ type Severity,
7
+ } from "./lib/surface";
8
+ import { usePanZoom } from "./hooks/usePanZoom";
9
+ import { GraphCanvas } from "./components/GraphCanvas";
10
+ import { Minimap } from "./components/Minimap";
11
+ import { DomainSidebar } from "./components/DomainSidebar";
12
+ import { Inspector } from "./components/Inspector";
13
+
14
+ const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "info"];
15
+ const NAV = ["Dashboard", "Attack Surface", "Assets", "Findings"];
16
+
17
+ export default function Attack_path() {
18
+ const base = useMemo(() => loadSurfaceFromJSON(data), []);
19
+ const [layout, setLayout] = useState<"path" | "graph">("graph");
20
+ const positions = useMemo(() => ({ path: pathLayout(base), graph: graphLayout(base) }), [base]);
21
+ const surface = useMemo(() => withPositions(base, layout === "graph" ? positions.graph : positions.path), [base, layout, positions]);
22
+
23
+ const [selected, setSelected] = useState<string | null>(null);
24
+ const [hovered, setHovered] = useState<string | null>(null);
25
+ const [sevFilter, setSevFilter] = useState<Set<Severity>>(new Set());
26
+ const [activeNav, setActiveNav] = useState<string | null>(null);
27
+
28
+ const pz = usePanZoom();
29
+ const [viewport, setViewport] = useState({ w: 1000, h: 700 });
30
+
31
+ useEffect(() => {
32
+ const el = pz.ref.current;
33
+ if (!el) return;
34
+ const ro = new ResizeObserver(() => setViewport({ w: el.clientWidth, h: el.clientHeight }));
35
+ ro.observe(el);
36
+ setViewport({ w: el.clientWidth, h: el.clientHeight });
37
+ const id = setTimeout(() => pz.fitTo(surface.bounds), 60);
38
+ return () => { ro.disconnect(); clearTimeout(id); };
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ const t = setTimeout(() => pz.fitTo(surface.bounds), 40);
44
+ return () => clearTimeout(t);
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [layout]);
47
+
48
+ const selectedNode = selected ? surface.nodeMap[selected] : null;
49
+
50
+ // ---- node filtering ----
51
+ const highlight = useMemo(() => {
52
+ // 1) selection → highlight that node's subtree (domain↔asset↔finding chain)
53
+ if (selected) return subtreeOf(selected, surface);
54
+ // 2) severity filter → keep findings of chosen severities + their asset+domain
55
+ if (sevFilter.size > 0) {
56
+ const s = new Set<string>();
57
+ surface.nodes.forEach((n) => {
58
+ if (n.type === "finding" && sevFilter.has(n.severity)) {
59
+ s.add(n.id);
60
+ if (n.parentAsset) s.add(n.parentAsset);
61
+ if (n.parentDomain) s.add(n.parentDomain);
62
+ }
63
+ });
64
+ return s;
65
+ }
66
+ return null;
67
+ }, [selected, sevFilter, surface]);
68
+
69
+ const handleSelect = (id: string | null) => {
70
+ setSelected(id);
71
+ if (id) { const n = surface.nodeMap[id]; if (n) pz.centerOn(n.x, n.y); }
72
+ };
73
+
74
+ const toggleSev = (s: Severity) => setSevFilter((prev) => {
75
+ const n = new Set(prev); n.has(s) ? n.delete(s) : n.add(s); return n;
76
+ });
77
+
78
+ return (
79
+ <div className="flex h-screen flex-col overflow-hidden bg-void text-ink">
80
+ {/* Top nav */}
81
+ <header className="flex h-16 shrink-0 items-center justify-between border-b border-line bg-surface px-6">
82
+ <div className="flex items-center gap-6">
83
+ <div className="flex items-center gap-2.5">
84
+ <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand to-violet shadow-card">
85
+ <ShieldCheck className="h-5 w-5 text-white" strokeWidth={2.2} />
86
+ </div>
87
+ <div>
88
+ <div className="flex items-center gap-2">
89
+ <span className="text-[15px] font-extrabold tracking-tight text-ink">SENTINEL</span>
90
+ <span className="rounded-md bg-brand-soft px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-brand">Attack Surface</span>
91
+ </div>
92
+ <div className="text-[10px] font-medium uppercase tracking-[0.14em] text-faint">Vulnerability Graph</div>
93
+ </div>
94
+ </div>
95
+ <nav className="hidden items-center gap-1 md:flex">
96
+ {NAV.map((t) => (
97
+ <button
98
+ key={t}
99
+ onClick={() => setActiveNav(t)}
100
+ className={`rounded-lg px-3.5 py-2 text-[13px] font-semibold transition-colors ${
101
+ activeNav === t ? "bg-brand-soft text-brand" : "text-muted hover:bg-panel-2 hover:text-ink"
102
+ }`}
103
+ >
104
+ {t}
105
+ </button>
106
+ ))}
107
+ </nav>
108
+ </div>
109
+ <div className="flex items-center gap-3">
110
+ <div className="flex items-center gap-1.5 rounded-lg border border-mint/30 bg-mint/10 px-3 py-2">
111
+ <span className="relative flex h-1.5 w-1.5">
112
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-mint opacity-75" />
113
+ <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-mint" />
114
+ </span>
115
+ <span className="text-[11px] font-bold uppercase tracking-wider text-mint">Live</span>
116
+ </div>
117
+ <div className="h-9 w-9 rounded-full bg-gradient-to-br from-brand to-azure ring-2 ring-white/10 shadow-card" />
118
+ </div>
119
+ </header>
120
+
121
+ <div className="flex min-h-0 flex-1">
122
+ <DomainSidebar surface={surface} selected={selected} onSelect={handleSelect} />
123
+
124
+ <main className="relative flex min-w-0 flex-1 flex-col">
125
+ {/* toolbar */}
126
+ <div className="flex h-14 shrink-0 items-center gap-3 border-b border-line bg-surface px-5">
127
+ {/* view toggle */}
128
+ <div className="flex items-center gap-0.5 rounded-xl border border-line bg-panel p-1">
129
+ {([{ key: "graph", label: "Graph", icon: <Share2 className="h-3.5 w-3.5" /> }, { key: "path", label: "Path", icon: <GitBranch className="h-3.5 w-3.5" /> }] as const).map((v) => (
130
+ <button key={v.key} onClick={() => setLayout(v.key)}
131
+ className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-semibold transition-colors ${layout === v.key ? "bg-brand text-white shadow-card" : "text-muted hover:text-ink"}`}>
132
+ {v.icon}{v.label}
133
+ </button>
134
+ ))}
135
+ </div>
136
+
137
+ {/* severity filter */}
138
+ <div className="ml-auto flex items-center gap-1 rounded-xl border border-line bg-panel p-1">
139
+ {SEVERITIES.map((s) => {
140
+ const active = sevFilter.has(s);
141
+ return (
142
+ <button key={s} onClick={() => toggleSev(s)}
143
+ className={`flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[12px] font-semibold transition-colors ${active ? "bg-panel-2 text-ink" : "text-muted hover:text-ink"}`}
144
+ style={active ? { boxShadow: `inset 0 -2px 0 ${severityMeta[s].css}` } : undefined}>
145
+ <span className="h-2 w-2 rounded-full" style={{ background: severityMeta[s].css }} />
146
+ <span className="hidden lg:inline">{severityMeta[s].label}</span>
147
+ <span className="font-mono text-faint tnum">{base.stats.bySeverity[s]}</span>
148
+ </button>
149
+ );
150
+ })}
151
+ {sevFilter.size > 0 && <button onClick={() => setSevFilter(new Set())} className="px-2 font-mono text-[10px] text-brand hover:underline">clear</button>}
152
+ </div>
153
+ </div>
154
+
155
+ {/* canvas */}
156
+ <div ref={pz.ref} onPointerDown={pz.onPointerDown} onPointerMove={pz.onPointerMove} onPointerUp={pz.onPointerUp} onPointerLeave={pz.onPointerUp}
157
+ className="relative min-h-0 flex-1 overflow-hidden dot-bg" style={{ cursor: pz.panning ? "grabbing" : "grab" }}>
158
+ <GraphCanvas surface={surface} transform={pz.t} layout={layout} hovered={hovered} selected={selected}
159
+ highlight={highlight} onHover={setHovered} onSelect={handleSelect} />
160
+
161
+ {/* legend */}
162
+ <div className="pointer-events-none absolute left-5 top-5 flex flex-col gap-2 rounded-2xl border border-line bg-surface/90 px-4 py-3.5 shadow-card backdrop-blur">
163
+ <span className="mb-0.5 text-[9px] font-semibold uppercase tracking-wider text-faint">Severity</span>
164
+ {SEVERITIES.map((s) => (
165
+ <div key={s} className="flex items-center gap-2">
166
+ <span className="h-2.5 w-2.5 rounded-full" style={{ background: severityMeta[s].css }} />
167
+ <span className="text-[11px] font-medium text-muted">{severityMeta[s].label}</span>
168
+ <span className="ml-auto font-mono text-[10px] text-faint tnum">{base.stats.bySeverity[s]}</span>
169
+ </div>
170
+ ))}
171
+ </div>
172
+
173
+ {/* zoom controls */}
174
+ <div className="absolute bottom-5 left-5 flex flex-col gap-1 rounded-2xl border border-line bg-surface/90 p-1.5 shadow-card backdrop-blur">
175
+ <Ctrl onClick={() => pz.zoomBy(1.25)}><Plus className="h-4 w-4" /></Ctrl>
176
+ <div className="py-0.5 text-center font-mono text-[10px] font-medium text-muted tnum">{Math.round(pz.t.k * 100)}%</div>
177
+ <Ctrl onClick={() => pz.zoomBy(1 / 1.25)}><Minus className="h-4 w-4" /></Ctrl>
178
+ <div className="my-0.5 h-px bg-line" />
179
+ <Ctrl onClick={() => pz.fitTo(surface.bounds)}><Maximize2 className="h-4 w-4" /></Ctrl>
180
+ </div>
181
+
182
+ {/* minimap */}
183
+ <div className="absolute bottom-5 right-5"><Minimap surface={surface} transform={pz.t} viewport={viewport} /></div>
184
+ </div>
185
+ </main>
186
+
187
+ <Inspector surface={surface} node={selectedNode} onSelect={handleSelect} onClose={() => setSelected(null)} />
188
+ </div>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function Ctrl({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
194
+ return <button onClick={onClick} className="flex h-8 w-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-panel-2 hover:text-ink">{children}</button>;
195
+ }
@@ -0,0 +1,154 @@
1
+ import { useMemo, useState } from "react";
2
+ import { ChevronRight, Globe, Search, X } from "lucide-react";
3
+ import type { Domain, Severity, Surface } from "../lib/surface";
4
+ import { severityMeta } from "../lib/surface";
5
+
6
+ const SEV: Severity[] = ["critical", "high", "medium", "low", "info"];
7
+
8
+ export function DomainSidebar({
9
+ surface,
10
+ selected,
11
+ onSelect,
12
+ }: {
13
+ surface: Surface;
14
+ selected: string | null;
15
+ onSelect: (id: string) => void;
16
+ }) {
17
+ const [q, setQ] = useState("");
18
+ const query = q.trim().toLowerCase();
19
+
20
+ const domains = useMemo(() => {
21
+ if (!query) return surface.domains;
22
+ return surface.domains.filter(
23
+ (d) =>
24
+ d.name.toLowerCase().includes(query) ||
25
+ d.fullName.toLowerCase().includes(query) ||
26
+ d.url.includes(query) ||
27
+ d.assetIds.some((aid) => surface.nodeMap[aid]?.host?.toLowerCase().includes(query))
28
+ );
29
+ }, [query, surface]);
30
+
31
+ const isDomainActive = (d: Domain) => Boolean(selected && selected === d.id);
32
+
33
+ return (
34
+ <aside className="flex w-[320px] shrink-0 flex-col border-r border-line bg-surface select-none">
35
+ <div className="flex h-14 items-center gap-2.5 border-b border-line px-5">
36
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-soft">
37
+ <Globe className="h-4 w-4 text-brand" strokeWidth={2.2} />
38
+ </div>
39
+ <span className="text-[13px] font-bold tracking-tight text-ink">Domains</span>
40
+ <span className="ml-auto rounded-full bg-panel-2 px-2 py-0.5 font-mono text-[11px] font-semibold text-muted tnum">
41
+ {surface.domains.length}
42
+ </span>
43
+ </div>
44
+
45
+ {/* search bar */}
46
+ <div className="border-b border-line p-3">
47
+ <div className="relative">
48
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-faint" />
49
+ <input
50
+ value={q}
51
+ onChange={(e) => setQ(e.target.value)}
52
+ placeholder="Search domains, hosts…"
53
+ className="h-9 w-full rounded-xl border border-line bg-panel pl-9 pr-8 text-[13px] text-ink placeholder:text-faint focus:border-brand/40 focus:outline-none focus:ring-2 focus:ring-brand/15"
54
+ />
55
+ {q && (
56
+ <button onClick={() => setQ("")} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-faint hover:text-ink">
57
+ <X className="h-4 w-4" />
58
+ </button>
59
+ )}
60
+ </div>
61
+ </div>
62
+
63
+ {/* summary */}
64
+ <div className="flex items-center gap-3 border-b border-line px-5 py-2.5">
65
+ <Metric label="Domains" value={surface.stats.domains} />
66
+ <Metric label="Assets" value={surface.stats.assets} />
67
+ <Metric label="Findings" value={surface.stats.findings} />
68
+ </div>
69
+
70
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
71
+ {domains.map((d) => (
72
+ <DomainRow
73
+ key={d.id}
74
+ d={d}
75
+ active={isDomainActive(d)}
76
+ onSelect={onSelect}
77
+ />
78
+ ))}
79
+ {domains.length === 0 && (
80
+ <div className="py-10 text-center font-mono text-[11px] text-faint">No domains match</div>
81
+ )}
82
+ </div>
83
+ </aside>
84
+ );
85
+ }
86
+
87
+ function DomainRow({
88
+ d,
89
+ active,
90
+ onSelect,
91
+ }: {
92
+ d: Domain;
93
+ active: boolean;
94
+ onSelect: (id: string) => void;
95
+ }) {
96
+ const worst =
97
+ d.tally.critical > 0
98
+ ? "critical"
99
+ : d.tally.high > 0
100
+ ? "high"
101
+ : d.tally.medium > 0
102
+ ? "medium"
103
+ : d.tally.low > 0
104
+ ? "low"
105
+ : "info";
106
+
107
+ return (
108
+ <button
109
+ onClick={() => onSelect(d.id)}
110
+ className={`group mb-2 flex w-full items-center gap-3 rounded-xl border p-3 text-left transition-all ${active
111
+ ? "border-brand/50 bg-brand-soft/40 shadow-card"
112
+ : "border-line bg-panel hover:border-brand/40 hover:bg-brand-soft/30"
113
+ }`}
114
+ >
115
+ <div
116
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-transform group-hover:scale-105"
117
+ style={{ background: `${severityMeta[worst].css}1f` }}
118
+ >
119
+ <Globe className="h-4 w-4" style={{ color: severityMeta[worst].css }} />
120
+ </div>
121
+ <div className="min-w-0 flex-1">
122
+ <div
123
+ className={`truncate text-[13px] font-semibold transition-colors ${active ? "text-brand" : "text-ink group-hover:text-brand"
124
+ }`}
125
+ >
126
+ {d.fullName}
127
+ </div>
128
+ <div className="truncate font-mono text-[10.5px] text-faint">{d.url}</div>
129
+ </div>
130
+ <div className="flex flex-col items-end gap-1">
131
+ <span
132
+ className={`font-mono text-[11px] font-semibold tnum transition-colors ${active ? "text-brand" : "text-muted"
133
+ }`}
134
+ >
135
+ {d.assetIds.length}a
136
+ </span>
137
+ <span className="font-mono text-[10px] text-faint tnum">{d.findingIds.length}f</span>
138
+ </div>
139
+ <ChevronRight
140
+ className={`h-4 w-4 shrink-0 transition-colors ${active ? "text-brand" : "text-faint group-hover:text-brand"
141
+ }`}
142
+ />
143
+ </button>
144
+ );
145
+ }
146
+
147
+ function Metric({ label, value }: { label: string; value: number }) {
148
+ return (
149
+ <div className="flex-1">
150
+ <div className="font-mono text-[14px] font-bold text-ink tnum">{value}</div>
151
+ <div className="font-mono text-[8px] uppercase tracking-wider text-faint">{label}</div>
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,135 @@
1
+ import type { Surface, SLink, SNode } from "../lib/surface";
2
+ import { severityMeta } from "../lib/surface";
3
+ import type { Transform } from "../hooks/usePanZoom";
4
+ import { NodeGlyph } from "./NodeGlyph";
5
+
6
+ const RADIUS: Record<string, number> = { domain: 24, asset: 18, finding: 13 };
7
+
8
+ function edgeD(a: SNode, b: SNode, layout: "path" | "graph") {
9
+ if (layout === "path") {
10
+ const dx = b.x - a.x;
11
+ const c = Math.max(40, Math.abs(dx) * 0.5);
12
+ return `M ${a.x} ${a.y} C ${a.x + c} ${a.y}, ${b.x - c} ${b.y}, ${b.x} ${b.y}`;
13
+ }
14
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
15
+ const dx = b.x - a.x, dy = b.y - a.y;
16
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
17
+ const bow = Math.min(len * 0.12, 40);
18
+ return `M ${a.x} ${a.y} Q ${mx + (-dy / len) * bow} ${my + (dx / len) * bow}, ${b.x} ${b.y}`;
19
+ }
20
+
21
+ interface Props {
22
+ surface: Surface;
23
+ transform: Transform;
24
+ layout: "path" | "graph";
25
+ hovered: string | null;
26
+ selected: string | null;
27
+ highlight: Set<string> | null; // node ids to keep bright (rest dim)
28
+ onHover: (id: string | null) => void;
29
+ onSelect: (id: string) => void;
30
+ }
31
+
32
+ export function GraphCanvas({ surface, transform, layout, hovered, selected, highlight, onHover, onSelect }: Props) {
33
+ const focus = hovered ?? selected;
34
+ const showLabels = transform.k > 0.5;
35
+
36
+ const edgeActive = (l: SLink) => {
37
+ if (highlight) return highlight.has(l.from) && highlight.has(l.to);
38
+ if (focus) return l.from === focus || l.to === focus;
39
+ return true;
40
+ };
41
+
42
+ return (
43
+ <svg className="absolute inset-0 h-full w-full" style={{ touchAction: "none" }}>
44
+ <defs>
45
+ <marker id="arr" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
46
+ <path d="M0 0 L10 5 L0 10 z" fill="#3a4a5e" />
47
+ </marker>
48
+ <filter id="ng" x="-80%" y="-80%" width="260%" height="260%">
49
+ <feGaussianBlur stdDeviation="4" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
50
+ </filter>
51
+ </defs>
52
+
53
+ <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
54
+ {/* column headers in path layout */}
55
+ {layout === "path" && <PathHeaders surface={surface} />}
56
+
57
+ <g>
58
+ {surface.links.map((l) => {
59
+ const a = surface.nodeMap[l.from], b = surface.nodeMap[l.to];
60
+ if (!a || !b) return null;
61
+ const active = edgeActive(l);
62
+ const color = l.severity === "info" ? "#2a3646" : severityMeta[l.severity].css;
63
+ return (
64
+ <path key={l.id} d={edgeD(a, b, layout)} fill="none"
65
+ stroke={active ? color : "#232d3c"} strokeWidth={active ? 1.6 : 1} strokeLinecap="round"
66
+ markerEnd="url(#arr)" opacity={highlight && !active ? 0.12 : active ? 0.6 : 0.3}
67
+ style={{ transition: "opacity .18s" }} />
68
+ );
69
+ })}
70
+ </g>
71
+
72
+ <g>
73
+ {surface.nodes.map((n) => (
74
+ <Node key={n.id} n={n} selected={selected === n.id} hovered={hovered === n.id}
75
+ dim={highlight ? !highlight.has(n.id) : false} showLabel={showLabels}
76
+ onHover={onHover} onSelect={onSelect} />
77
+ ))}
78
+ </g>
79
+ </g>
80
+ </svg>
81
+ );
82
+ }
83
+
84
+ function PathHeaders({ surface }: { surface: Surface }) {
85
+ const cols = [
86
+ { label: "DOMAINS", x: surface.nodes.find((n) => n.type === "domain")?.x ?? 0 },
87
+ { label: "ASSETS", x: surface.nodes.find((n) => n.type === "asset")?.x ?? 380 },
88
+ { label: "FINDINGS", x: surface.nodes.find((n) => n.type === "finding")?.x ?? 760 },
89
+ ];
90
+ const topY = surface.bounds.minY - 46;
91
+ return (
92
+ <g>
93
+ {cols.map((c) => (
94
+ <text key={c.label} x={c.x} y={topY} textAnchor="middle"
95
+ style={{ font: "600 12px 'Plus Jakarta Sans', sans-serif", letterSpacing: "0.12em" }} fill="#4a5a6e">{c.label}</text>
96
+ ))}
97
+ </g>
98
+ );
99
+ }
100
+
101
+ function Node({ n, selected, hovered, dim, showLabel, onHover, onSelect }: {
102
+ n: SNode; selected: boolean; hovered: boolean; dim: boolean; showLabel: boolean;
103
+ onHover: (id: string | null) => void; onSelect: (id: string) => void;
104
+ }) {
105
+ // const meta = severityMeta[n.severity];
106
+ const meta = severityMeta[n.severity] ?? severityMeta.info;
107
+ const R = RADIUS[n.type];
108
+ const emph = selected || hovered;
109
+ const color = n.type === "domain" ? "#5b7cfa" : meta.css;
110
+
111
+ return (
112
+ <g data-node transform={`translate(${n.x}, ${n.y})`}
113
+ onMouseEnter={() => onHover(n.id)} onMouseLeave={() => onHover(null)}
114
+ onClick={(e) => { e.stopPropagation(); onSelect(n.id); }} style={{ cursor: "pointer" }}>
115
+ <g style={{ opacity: dim ? 0.24 : 1, transition: "opacity .18s" }}>
116
+ {emph && <circle r={R + 6} fill={n.type === "domain" ? "#16203a" : meta.soft} stroke={color} strokeWidth={1.4} opacity={0.9} />}
117
+ <circle r={R} fill="#0c1017" stroke={color} strokeWidth={emph ? 2.6 : 2} filter={emph ? "url(#ng)" : undefined} />
118
+ <circle r={R - 3} fill={n.type === "domain" ? "#16203a" : meta.soft} />
119
+ <foreignObject x={-R} y={-R} width={R * 2} height={R * 2}>
120
+ <div className="flex h-full w-full items-center justify-center" style={{ color }}>
121
+ <NodeGlyph type={n.type} className={n.type === "domain" ? "h-5 w-5" : n.type === "asset" ? "h-[17px] w-[17px]" : "h-3.5 w-3.5"} />
122
+ </div>
123
+ </foreignObject>
124
+ {showLabel && (
125
+ <text textAnchor="middle" y={R + 14} style={{ font: `${n.type === "finding" ? "500 9.5px" : "600 11px"} 'IBM Plex Mono', monospace` }}
126
+ fill={dim ? "#4a5a6e" : n.type === "finding" ? meta.css : "#c2cbd8"}>
127
+ {truncate(n.type === "domain" ? n.domainName ?? n.label : n.label, n.type === "finding" ? 20 : 26)}
128
+ </text>
129
+ )}
130
+ </g>
131
+ </g>
132
+ );
133
+ }
134
+
135
+ function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n - 1) + "…" : s; }