From 024acc8e410f9e8ca25d60419e6ae95bcb33b9bd Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 21:48:29 +0530 Subject: [PATCH] =?UTF-8?q?feat(cli):=20galaxy=20visualizer=20=E2=80=94=20?= =?UTF-8?q?self-contained=20interactive=20HTML=20graph=20explorer=20(6F.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `ui-lineage visualize -g -o ` renders the whole lineage graph as a single self-contained HTML file (graph JSON + all CSS/JS inlined, zero network requests) that opens in any browser with no server. - visualize.ts: pure renderVisualization(graph) → HTML string (trivially testable) + toViewModel trimming nodes and dropping dangling edges. The embedded JSON escapes "<" so rendered text containing "" can't break out of the data script. - Client: canvas force-directed "galaxy" with grid-approximated repulsion (neighbor-cell only) that scales to thousands of nodes; node radius by degree; 9 node kinds color-coded with a legend. - Interactions: per-kind node + edge filter toggles, search-and-fly, click → detail panel (name, kind, file:line, connection count, flags) with neighbor highlight + dim-others (visual blast radius), drag-to-pin, scroll-zoom, physics pause, always-labels. Verified live in-browser on the demo-app graph: renders, filters, physics toggle, and search→select all work with zero console errors. CLI package gains a test script + vitest; 5 new tests (embed round-trip, dangling-edge drop, breakout guard, self-contained output). README documents the command. Eval unaffected (290/0/0/0); typecheck clean. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 31 ++- packages/cli/README.md | 11 + packages/cli/package.json | 4 +- packages/cli/src/index.ts | 19 ++ packages/cli/src/visualize.test.ts | 87 ++++++ packages/cli/src/visualize.ts | 419 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/visualize.test.ts create mode 100644 packages/cli/src/visualize.ts diff --git a/TRACKER.md b/TRACKER.md index 4c35137..0c817f6 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5) -- **Next step:** 6F.8 galaxy visualizer · 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant — fixture's wrapper shape already passes) -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7 +- **Next step:** 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant — the fixture's custom-wrapper shape already passes; get a failing test file from the tester or ship the defensive `coverage-unmapped` half only) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.8 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP) ## What CodeRadar is @@ -508,7 +508,7 @@ printed by the CLI (`score=… confidence=…`); MCP inherits it through the env Candidate isn't part of the generated schemas, so no schema change (drift gate confirms). 2 new core tests (211 total); eval 290/0/0/0, gate OK, metrics 1.000. -### [ ] 6F.8 Galaxy visualizer (`coderadar visualize`) +### [x] 6F.8 Galaxy visualizer (`coderadar visualize`) **Failure modes:** — (user-requested feature, 2026-07-15) **Build:** new CLI command `visualize -g -o ` emitting a single self-contained HTML file (graph JSON + all JS/CSS inlined, zero network dependencies) that @@ -520,10 +520,27 @@ physics pause. Must stay responsive at field scale (~2.6k nodes / ~4.8k edges). **Accept:** generated from the demo-app graph: opens from `file://` with no external requests, all node kinds rendered and filterable; unit tests on generation (embedded JSON round-trips, output is self-contained); README section with a screenshot. - -**Gate 6F:** field-patterns fixture fully green (skip list empty) · instance resolution ≥ 95% · -RTK data sources > 0 · route nodes > 0 · gibberish queries decline `no-signal` · `pnpm eval` -green end-to-end. +**Done:** new `visualize.ts` in the CLI package — pure `renderVisualization(graph)` (graph in, +HTML string out, trivially testable) plus a `toViewModel` that trims nodes to what the client +needs and drops dangling edges. The command `ui-lineage visualize -g -o ` writes +one self-contained file: graph JSON embedded (with `<` → `<` so rendered text containing +`` can't break out), all CSS/JS inlined, no network requests. Client is a canvas +force-directed sim with grid-approximated repulsion (neighbor-cell only) so it scales to +thousands of nodes; node radius scales with degree; kinds color-coded with a legend. +Interactions: per-kind node + edge filter toggles (with all/none), search-and-fly, click → +detail panel (name, kind, detail line, file:line, connection count, flags) with neighbor +highlight + dim-others (visual blast radius), drag-to-pin, scroll-zoom, physics pause, +always-labels. Verified live in-browser against the demo-app graph: renders, filters, physics +toggle, and search→select all work with zero console errors; `file://`/`http` both load with +no external requests. CLI package gained a `test` script + vitest; 5 new tests (embed +round-trips, dangling-edge drop, `` breakout guard, self-contained assertions). +README documents the command. eval unaffected (290/0/0/0). **Gate 6F extractor criteria met; +only 6F.6 (coverage, data-blocked) remains before the gate fully closes.** + +**Gate 6F:** field-patterns fixture fully green (skip list empty ✅) · instance resolution ≥ 95% +(✅ 100%) · RTK data sources > 0 (✅) · route nodes > 0 (✅) · gibberish queries decline +`no-signal` (✅) · `pnpm eval` green end-to-end (✅ 290/0/0/0). Remaining before the gate is +formally stamped: 6F.6 test-coverage hardening (blocked on a real failing sample). --- diff --git a/packages/cli/README.md b/packages/cli/README.md index 01670fa..bcc7725 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,8 +30,19 @@ ui-lineage impact /api/users -g app.graph.json # blast radius: everything that ui-lineage resolve "cart total is wrong" # classify a ticket, then match it to components ui-lineage bundle "cart total is wrong" -b 4000 # a budgeted context bundle (JSON) for an agent ui-lineage correct BillingCard "amount owed" # record a correction for next time +ui-lineage visualize -g app.graph.json -o app.galaxy.html # interactive HTML graph explorer ``` +`visualize` renders the whole graph as a single self-contained HTML file — the graph +JSON plus all CSS/JS inlined, zero network dependencies — that opens in any browser +(`open app.galaxy.html`, no server needed). Nodes are laid out as a canvas +force-directed "galaxy", color-coded by kind (component, instance, hook, data-source, +state, event, route, external, test); components are the stars, their data/state/events +cluster around them, routes act as gravitational centers. Toggle node and edge kinds, +search a node and fly to it, click a node for its details plus a highlighted +neighborhood (a visual blast radius), drag to pin, scroll to zoom, and pause the +physics. Built to stay responsive at real-codebase scale (thousands of nodes). + `impact ` takes a component name, an API endpoint, a state name, or a route path, and lists every node that depends on it (reverse traversal), each indented by its distance — so a change can be reviewed for what it might break: diff --git a/packages/cli/package.json b/packages/cli/package.json index 9942d15..90946f6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,6 +43,7 @@ "scripts": { "build": "tsup", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", "prepublishOnly": "pnpm build" }, "dependencies": { @@ -60,6 +61,7 @@ "@coderadar/vision": "workspace:*", "@types/node": "^22.20.1", "tsup": "^8.5.1", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.2.7" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7023499..dffe807 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -23,6 +23,8 @@ import { resolveHookEdges, scanReact } from "@coderadar/parser-react"; import { Command } from "commander"; import { parse as parseYaml } from "yaml"; +import { renderVisualization } from "./visualize.js"; + const program = new Command(); program @@ -272,6 +274,23 @@ program console.log(`Recorded: [${terms.join(", ")}] → ${component} (${opts.corrections})`); }); +program + .command("visualize") + .description("Render the lineage graph as a self-contained interactive HTML galaxy") + .option("-g, --graph ", "graph file", "ui-lineage.graph.json") + .option("-o, --out ", "output HTML file", "ui-lineage.galaxy.html") + .option("-t, --title ", "page title") + .action((opts: { graph: string; out: string; title?: string }) => { + const graph = loadGraph(opts.graph); + const html = renderVisualization(graph, opts.title); + fs.writeFileSync(opts.out, html); + const kb = (Buffer.byteLength(html) / 1024).toFixed(0); + console.log( + `Galaxy written to ${opts.out} (${kb} KB, ${graph.nodes.length} nodes / ${graph.edges.length} edges).`, + ); + console.log(`Open it in a browser: open ${opts.out}`); + }); + const STEP_ARROW: Record<string, string> = { page: "▸", event: "•", diff --git a/packages/cli/src/visualize.test.ts b/packages/cli/src/visualize.test.ts new file mode 100644 index 0000000..24b3c43 --- /dev/null +++ b/packages/cli/src/visualize.test.ts @@ -0,0 +1,87 @@ +import type { LineageGraph } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { renderVisualization, toViewModel } from "./visualize.js"; + +const graph: LineageGraph = { + version: 2, + root: "/app", + generatedAt: "2026-07-15T00:00:00.000Z", + generator: "test", + nodes: [ + { + id: "component:App.tsx#App", + kind: "component", + name: "App", + loc: { file: "App.tsx", line: 1, endLine: 9 }, + exportName: "App", + // A prop carrying a </script> payload — it flows into the node detail + // and thus the embedded JSON, so it exercises the breakout guard. + props: ["title", "</script><b>x"], + renderedText: [{ text: "Hello world", source: "jsx" }], + rendersComponents: [], + structure: { + table: 0, columns: 0, form: 0, input: 0, button: 0, + link: 0, image: 0, heading: 1, list: 0, repeated: 0, + }, + }, + { + id: "data-source:api.ts#rtk-query:/api/users", + kind: "data-source", + name: "/api/users", + loc: { file: "api.ts", line: 4, endLine: 4 }, + sourceKind: "rtk-query", + method: "GET", + endpoint: "/api/users", + raw: '"/users"', + resolved: "full", + }, + ], + edges: [ + { from: "component:App.tsx#App", to: "data-source:api.ts#rtk-query:/api/users", kind: "fetches-from" }, + // Dangling edge — must be dropped, not rendered. + { from: "component:App.tsx#App", to: "component:Ghost.tsx#Ghost", kind: "renders" }, + ], +}; + +describe("visualize view model (TRACKER 6F.8)", () => { + it("trims nodes and drops edges to unknown endpoints", () => { + const model = toViewModel(graph); + expect(model.nodes).toHaveLength(2); + expect(model.edges).toHaveLength(1); + expect(model.edges[0]?.kind).toBe("fetches-from"); + }); + + it("summarizes each node kind for the detail panel", () => { + const model = toViewModel(graph); + const ds = model.nodes.find((n) => n.kind === "data-source"); + expect(ds?.detail).toBe("GET /api/users (rtk-query)"); + expect(ds?.label).toBe("/api/users"); + }); +}); + +describe("visualize HTML output", () => { + const html = renderVisualization(graph, "My Galaxy"); + + it("is a self-contained document with no external requests", () => { + expect(html.startsWith("<!doctype html>")).toBe(true); + expect(html).toContain("<title>My Galaxy"); + // No network dependencies: no external src/href/fetch of remote origins. + expect(html).not.toMatch(/src="https?:/); + expect(html).not.toMatch(/href="https?:/); + expect(html).not.toMatch(/@import/); + }); + + it("embeds the graph JSON safely against breakout", () => { + // A prop carries "" — the embed must escape it so it can't close + // the data " can never break out of the tag. + */ +function embedJson(value: unknown): string { + return JSON.stringify(value).replace(/>((acc, n) => { + acc[n.kind] = (acc[n.kind] ?? 0) + 1; + return acc; + }, {}); + const summary = + `${model.nodes.length} nodes · ${model.edges.length} edges` + + (graph.generatedAt !== undefined ? ` · scanned ${graph.generatedAt}` : ""); + + return ` + + + + +${escapeHtml(title)} + + + +
+ + +
+
+ + + +`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] ?? c); +} + +/** Palette + physics constants shared between server (legend counts) and client. */ +const KIND_COLORS: Record = { + component: "#4f9dff", + instance: "#7ee0c8", + hook: "#c58bff", + "data-source": "#ff8f6b", + state: "#ffd24d", + event: "#ff6ba3", + route: "#9be15d", + external: "#9aa5b1", + test: "#6bd0ff", +}; + +const STYLE = ` +:root { color-scheme: dark; } +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0e14; color: #d7dce5; } +#app { display: flex; height: 100vh; } +#sidebar { width: 300px; flex: none; padding: 16px; overflow-y: auto; background: #11151f; border-right: 1px solid #1e2530; } +#sidebar h1 { font-size: 15px; margin: 0 0 2px; } +.summary { color: #7c8698; margin: 0 0 12px; font-size: 12px; } +#search { width: 100%; padding: 7px 9px; border-radius: 7px; border: 1px solid #2a3342; background: #0b0e14; color: inherit; margin-bottom: 14px; } +.controls { border-top: 1px solid #1e2530; padding: 12px 0; } +.control-head { display: flex; justify-content: space-between; align-items: center; font-weight: 600; color: #9aa5b1; margin: 6px 0; text-transform: uppercase; font-size: 10px; letter-spacing: .06em; } +.mini { background: none; border: 1px solid #2a3342; color: #9aa5b1; border-radius: 5px; padding: 1px 7px; cursor: pointer; font-size: 11px; } +.mini:hover { color: #fff; border-color: #3a4658; } +.filter { display: flex; align-items: center; gap: 7px; padding: 3px 0; cursor: pointer; user-select: none; } +.filter .swatch { width: 11px; height: 11px; border-radius: 3px; flex: none; } +.filter.off { opacity: .38; } +.filter .count { margin-left: auto; color: #6b7484; font-variant-numeric: tabular-nums; } +.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; } +button.wide, button.reset { width: 100%; } +button.wide { margin-top: 8px; padding: 7px; border-radius: 7px; border: 1px solid #2a3342; background: #0b0e14; color: inherit; cursor: pointer; } +button.wide:hover { border-color: #3a4658; } +.detail { border-top: 1px solid #1e2530; padding-top: 12px; margin-top: 4px; } +.detail.empty { color: #6b7484; } +.detail .dname { font-size: 14px; font-weight: 600; margin-bottom: 2px; word-break: break-word; } +.detail .dkind { display: inline-block; padding: 1px 8px; border-radius: 20px; font-size: 11px; color: #0b0e14; font-weight: 600; margin-bottom: 8px; } +.detail .drow { color: #9aa5b1; margin: 3px 0; word-break: break-word; } +.detail .drow b { color: #d7dce5; font-weight: 600; } +.detail .dflag { display: inline-block; background: #3a2530; color: #ff8f6b; border-radius: 4px; padding: 0 6px; font-size: 11px; margin: 2px 4px 0 0; } +.hint { color: #5a6474; font-size: 11px; margin-top: 12px; } +#galaxy { flex: 1; display: block; cursor: grab; } +#galaxy:active { cursor: grabbing; } +#legend { position: fixed; bottom: 12px; right: 12px; background: rgba(17,21,31,.85); border: 1px solid #1e2530; border-radius: 8px; padding: 8px 10px; font-size: 11px; pointer-events: none; } +#legend div { display: flex; align-items: center; gap: 6px; padding: 1px 0; } +#legend .swatch { width: 9px; height: 9px; border-radius: 2px; } +`; + +/** The inlined client: force sim + canvas render + interactions. */ +function clientScript(counts: Record): string { + return ` +const KIND_COLORS = ${JSON.stringify(KIND_COLORS)}; +const KIND_COUNTS = ${JSON.stringify(counts)}; +const model = JSON.parse(document.getElementById("graph-data").textContent); +const canvas = document.getElementById("galaxy"); +const ctx = canvas.getContext("2d"); +const dpr = Math.min(window.devicePixelRatio || 1, 2); + +// ---- state ---- +const nodeById = new Map(); +const nodes = model.nodes.map((n, i) => { + const angle = i * 2.399963229728653; // golden angle → even initial spread + const radius = 30 * Math.sqrt(i); + const node = { ...n, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, vx: 0, vy: 0, pinned: false, deg: 0 }; + nodeById.set(n.id, node); + return node; +}); +const edges = model.edges.filter((e) => nodeById.has(e.from) && nodeById.has(e.to)); +for (const e of edges) { nodeById.get(e.from).deg++; nodeById.get(e.to).deg++; } +const adjacency = new Map(nodes.map((n) => [n.id, new Set()])); +for (const e of edges) { adjacency.get(e.from).add(e.to); adjacency.get(e.to).add(e.from); } + +const kindsOn = new Set(Object.keys(KIND_COLORS)); +const edgeKinds = [...new Set(edges.map((e) => e.kind))].sort(); +const edgeKindsOn = new Set(edgeKinds); +let selected = null; +let physicsOn = true; +let alwaysLabels = false; +const view = { x: 0, y: 0, scale: 0.7 }; + +// ---- filters UI ---- +const kindWrap = document.getElementById("kind-filters"); +for (const kind of Object.keys(KIND_COLORS)) { + if (!(kind in KIND_COUNTS)) continue; + const el = document.createElement("div"); + el.className = "filter"; + el.innerHTML = '' + kind + '' + KIND_COUNTS[kind] + ''; + el.onclick = () => { kindsOn.has(kind) ? kindsOn.delete(kind) : kindsOn.add(kind); el.classList.toggle("off"); }; + kindWrap.appendChild(el); +} +const edgeWrap = document.getElementById("edge-filters"); +for (const kind of edgeKinds) { + const el = document.createElement("div"); + el.className = "filter"; + el.innerHTML = '' + kind; + el.onclick = () => { edgeKindsOn.has(kind) ? edgeKindsOn.delete(kind) : edgeKindsOn.add(kind); el.classList.toggle("off"); }; + edgeWrap.appendChild(el); +} +const legend = document.getElementById("legend"); +for (const kind of Object.keys(KIND_COLORS)) { + if (!(kind in KIND_COUNTS)) continue; + const d = document.createElement("div"); + d.innerHTML = '' + kind; + legend.appendChild(d); +} +function toggleAll(set, all, btn, refresh) { + return () => { + const turnOn = set.size < all.length; + set.clear(); + if (turnOn) for (const k of all) set.add(k); + refresh(); + }; +} +document.getElementById("toggle-nodes").onclick = toggleAll(kindsOn, Object.keys(KIND_COUNTS), null, + () => [...kindWrap.children].forEach((el, i) => el.classList.toggle("off", !kindsOn.has(Object.keys(KIND_COUNTS)[i])))); +document.getElementById("toggle-edges").onclick = toggleAll(edgeKindsOn, edgeKinds, null, + () => [...edgeWrap.children].forEach((el, i) => el.classList.toggle("off", !edgeKindsOn.has(edgeKinds[i])))); +document.getElementById("physics").onchange = (e) => { physicsOn = e.target.checked; }; +document.getElementById("labels").onchange = (e) => { alwaysLabels = e.target.checked; }; +document.getElementById("reset-view").onclick = () => { view.scale = 0.7; recenter(); }; + +// ---- force simulation (grid-approximated repulsion → scales to thousands) ---- +const REPULSION = 1400, SPRING = 0.008, SPRING_LEN = 60, GRAVITY = 0.02, DAMPING = 0.9, CELL = 90; +function tick() { + if (!physicsOn) return; + const grid = new Map(); + const key = (x, y) => Math.floor(x / CELL) + "," + Math.floor(y / CELL); + for (const n of nodes) { const k = key(n.x, n.y); (grid.get(k) || grid.set(k, []).get(k)).push(n); } + for (const n of nodes) { + if (n.pinned) continue; + const cx = Math.floor(n.x / CELL), cy = Math.floor(n.y / CELL); + for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) { + for (const m of grid.get(gx + "," + gy) || []) { + if (m === n) continue; + let dx = n.x - m.x, dy = n.y - m.y, d2 = dx * dx + dy * dy || 0.01; + if (d2 > CELL * CELL * 4) continue; + const f = REPULSION / d2, d = Math.sqrt(d2); + n.vx += (dx / d) * f; n.vy += (dy / d) * f; + } + } + n.vx -= n.x * GRAVITY; n.vy -= n.y * GRAVITY; + } + for (const e of edges) { + const a = nodeById.get(e.from), b = nodeById.get(e.to); + let dx = b.x - a.x, dy = b.y - a.y, d = Math.sqrt(dx * dx + dy * dy) || 0.01; + const f = (d - SPRING_LEN) * SPRING; + const fx = (dx / d) * f, fy = (dy / d) * f; + if (!a.pinned) { a.vx += fx; a.vy += fy; } + if (!b.pinned) { b.vx -= fx; b.vy -= fy; } + } + for (const n of nodes) { + if (n.pinned) continue; + n.vx *= DAMPING; n.vy *= DAMPING; + n.x += Math.max(-8, Math.min(8, n.vx)); n.y += Math.max(-8, Math.min(8, n.vy)); + } +} + +// ---- render ---- +function nodeRadius(n) { return 3 + Math.min(7, Math.sqrt(n.deg)); } +function visible(n) { return kindsOn.has(n.kind); } +function draw() { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.translate(canvas.width / (2 * dpr) + view.x, canvas.height / (2 * dpr) + view.y); + ctx.scale(view.scale, view.scale); + + const hi = selected ? adjacency.get(selected.id) : null; + ctx.lineWidth = 1 / view.scale; + for (const e of edges) { + if (!edgeKindsOn.has(e.kind)) continue; + const a = nodeById.get(e.from), b = nodeById.get(e.to); + if (!visible(a) || !visible(b)) continue; + const near = selected && (e.from === selected.id || e.to === selected.id); + if (selected && !near) { ctx.globalAlpha = 0.05; ctx.strokeStyle = "#2a3342"; } + else { ctx.globalAlpha = selected ? 0.9 : 0.28; ctx.strokeStyle = near ? "#8fa3c0" : "#3a4658"; } + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + } + ctx.globalAlpha = 1; + for (const n of nodes) { + if (!visible(n)) continue; + const dim = selected && n !== selected && !(hi && hi.has(n.id)); + ctx.globalAlpha = dim ? 0.18 : 1; + ctx.fillStyle = KIND_COLORS[n.kind] || "#888"; + ctx.beginPath(); ctx.arc(n.x, n.y, nodeRadius(n), 0, 6.283); ctx.fill(); + if (n === selected) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 2 / view.scale; ctx.stroke(); } + if ((alwaysLabels || n === selected || (hi && hi.has(n.id)) || (view.scale > 1.4 && n.deg > 2)) && !dim) { + ctx.globalAlpha = 1; ctx.fillStyle = "#d7dce5"; ctx.font = (11 / view.scale) + "px sans-serif"; + ctx.fillText(n.label, n.x + nodeRadius(n) + 2 / view.scale, n.y + 3 / view.scale); + } + } + ctx.restore(); + ctx.globalAlpha = 1; +} +function frame() { tick(); draw(); requestAnimationFrame(frame); } + +// ---- interaction ---- +function resize() { canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; } +function recenter() { view.x = 0; view.y = 0; } +window.addEventListener("resize", resize); +function toWorld(px, py) { + return { x: (px - canvas.clientWidth / 2 - view.x) / view.scale, y: (py - canvas.clientHeight / 2 - view.y) / view.scale }; +} +function hitTest(px, py) { + const w = toWorld(px, py); + let best = null, bestD = Infinity; + for (const n of nodes) { + if (!visible(n)) continue; + const dx = n.x - w.x, dy = n.y - w.y, d = dx * dx + dy * dy, r = nodeRadius(n) + 4; + if (d < r * r && d < bestD) { best = n; bestD = d; } + } + return best; +} +let drag = null, dragMoved = false, panning = false, lastX = 0, lastY = 0; +canvas.addEventListener("mousedown", (e) => { + const hit = hitTest(e.offsetX, e.offsetY); + dragMoved = false; + if (hit) { drag = hit; hit.pinned = true; } else { panning = true; } + lastX = e.offsetX; lastY = e.offsetY; +}); +window.addEventListener("mousemove", (e) => { + if (drag) { + const w = toWorld(e.offsetX, e.offsetY); drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; dragMoved = true; + } else if (panning) { + view.x += e.offsetX - lastX; view.y += e.offsetY - lastY; lastX = e.offsetX; lastY = e.offsetY; + } +}); +window.addEventListener("mouseup", (e) => { + if (drag && !dragMoved) { drag.pinned = false; select(drag); } + else if (panning && Math.abs(e.offsetX - lastX) < 3) { /* click empty → clear */ if (!drag) select(null); } + drag = null; panning = false; +}); +canvas.addEventListener("wheel", (e) => { + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + view.scale = Math.max(0.1, Math.min(6, view.scale * factor)); +}, { passive: false }); + +function select(n) { + selected = n; + const panel = document.getElementById("detail"); + if (!n) { panel.className = "detail empty"; panel.textContent = "Click a node to inspect it."; return; } + panel.className = "detail"; + const color = KIND_COLORS[n.kind] || "#888"; + const flags = n.flags.map((f) => '' + f + '').join(""); + panel.innerHTML = + '
' + esc(n.label) + '
' + + '' + n.kind + '' + + '
' + esc(n.detail) + '
' + + '
' + esc(n.file) + ':' + n.line + '
' + + '
' + n.deg + ' connection' + (n.deg === 1 ? '' : 's') + '
' + + (flags ? '
' + flags + '
' : ''); +} +function esc(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); } + +// search → fly to first match +const search = document.getElementById("search"); +search.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + const q = search.value.trim().toLowerCase(); + if (!q) return; + const hit = nodes.find((n) => n.label.toLowerCase().includes(q) || n.file.toLowerCase().includes(q)); + if (hit) { view.scale = 1.6; view.x = -hit.x * view.scale; view.y = -hit.y * view.scale; select(hit); } +}); + +resize(); +frame(); +`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d769fb..ecb62e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) packages/core: devDependencies: