@polycode-projects/seonix 0.1.0 → 0.2.1
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/bin/cli.mjs +95 -12
- package/package.json +5 -2
- package/src/ask-vocab.mjs +258 -0
- package/src/ask.mjs +762 -0
- package/src/browser.mjs +769 -0
- package/src/codegraph.mjs +352 -23
- package/src/cs_treesitter.mjs +2 -2
- package/src/extract.mjs +51 -8
- package/src/extract_lang.mjs +17 -2
- package/src/java_javaparser.mjs +54 -0
- package/src/java_treesitter.mjs +216 -0
- package/src/jsts_tsc.mjs +2 -2
- package/src/prose.mjs +145 -0
- package/src/schema-docs.mjs +225 -0
- package/src/server.mjs +35 -4
- package/src/temporal.mjs +559 -0
- package/src/viz.mjs +666 -94
- package/src/walk.mjs +52 -5
package/src/viz.mjs
CHANGED
|
@@ -1,30 +1,47 @@
|
|
|
1
|
-
// viz.mjs — render a FOCUSED sub-graph of the typed code-map
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// sub-graph + an inlined copy of Cytoscape, and offers controls to navigate
|
|
6
|
-
// (depth/hub sliders), change label verbosity, switch layout, and inspect nodes.
|
|
1
|
+
// viz.mjs — render a FOCUSED sub-graph of the typed code-map with Cytoscape.js.
|
|
2
|
+
// Deliberately NOT a whole-graph dump (that hangs the browser): we BFS an ego-network
|
|
3
|
+
// around a focus node to a small depth, stop expanding through high-degree hubs, and
|
|
4
|
+
// cap the node count.
|
|
7
5
|
//
|
|
8
|
-
//
|
|
6
|
+
// ONE viewer, three data channels. The viewer page (renderViewerHtml) contains no
|
|
7
|
+
// repo data; at boot it resolves its graph payload from, in order: a `?data=` query
|
|
8
|
+
// param, an embedded <script id="seonix-data"> block, a generated config pointer,
|
|
9
|
+
// or the sibling ./seonix-graph-data.json. The same page therefore serves:
|
|
10
|
+
// - the website (seonix-graph.html + seonix-graph-data.json, both deployed),
|
|
11
|
+
// - the portable single-file artifact (`viz --out f.html`, data embedded),
|
|
12
|
+
// - the local live view (`viz --serve`, data served from the repo's own index).
|
|
13
|
+
//
|
|
14
|
+
// `buildSubgraph` and `buildViewerData` are pure and unit-tested; the CLI adds graph
|
|
15
|
+
// loading, file I/O, and the zero-dependency node:http server.
|
|
9
16
|
|
|
10
17
|
import { readFile, writeFile } from "node:fs/promises";
|
|
11
|
-
import {
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
12
22
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { parseArgs } from "node:util";
|
|
23
|
+
import { parseArgs, promisify } from "node:util";
|
|
14
24
|
import { parseEntities, resolveSymbol, relationKind, siteOf } from "./codegraph.mjs";
|
|
15
25
|
import { loadConfig } from "./config.mjs";
|
|
16
26
|
import * as source from "./source.mjs";
|
|
27
|
+
import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
|
|
17
28
|
|
|
18
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const execFileP = promisify(execFile);
|
|
19
31
|
|
|
32
|
+
// Categorical type palette, CVD-validated as an ordered set against the dark canvas
|
|
33
|
+
// (#1a1b26): worst adjacent pair is Class↔Function ΔE 10.3 (protan) — floor-band,
|
|
34
|
+
// which is legal here because every node carries an outlined direct label and the
|
|
35
|
+
// legend chips + detail badge name the type. Red is deliberately absent so the
|
|
36
|
+
// white selection/focus ring and any status color can never impersonate a type.
|
|
20
37
|
const CLASS_COLOR = {
|
|
21
|
-
Module: "#
|
|
22
|
-
Class: "#
|
|
23
|
-
Function: "#
|
|
24
|
-
Method: "#
|
|
25
|
-
Attribute: "#
|
|
26
|
-
GlobalVariable: "#
|
|
27
|
-
Commit: "#
|
|
38
|
+
Module: "#3987e5",
|
|
39
|
+
Class: "#c98500",
|
|
40
|
+
Function: "#008300",
|
|
41
|
+
Method: "#d55181",
|
|
42
|
+
Attribute: "#9085e9",
|
|
43
|
+
GlobalVariable: "#d95926",
|
|
44
|
+
Commit: "#199e70",
|
|
28
45
|
};
|
|
29
46
|
|
|
30
47
|
/** Build the adjacency + degree of every node across all typed relations. */
|
|
@@ -47,6 +64,19 @@ function adjacency(graph) {
|
|
|
47
64
|
return { adj, degree };
|
|
48
65
|
}
|
|
49
66
|
|
|
67
|
+
/** Highest-degree individual (Modules preferred) — the fallback focus when the
|
|
68
|
+
* caller names none, so `viz --serve` works out of the box in any indexed repo. */
|
|
69
|
+
export function defaultFocus(graph) {
|
|
70
|
+
const { degree } = adjacency(graph);
|
|
71
|
+
let best = null;
|
|
72
|
+
for (const ind of graph.byId.values()) {
|
|
73
|
+
const d = degree.get(ind.id) || 0;
|
|
74
|
+
const score = d + (ind.class === "Module" ? 1e6 : 0); // any Module beats any non-Module
|
|
75
|
+
if (!best || score > best.score) best = { id: ind.id, label: ind.label || ind.id, score };
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
/**
|
|
51
81
|
* Focused ego-network around `focusId`: BFS to `depth`, not expanding through nodes
|
|
52
82
|
* whose total degree exceeds `hubDegree` (so hubs appear but don't drag in the world),
|
|
@@ -102,82 +132,551 @@ export function buildSubgraph(graph, { focusId, depth = 2, hubDegree = 40, maxNo
|
|
|
102
132
|
return { nodes, edges, focusId, truncated };
|
|
103
133
|
}
|
|
104
134
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
join(here, "..", "..", "..", "node_modules", "cytoscape", "dist", "cytoscape.min.js"),
|
|
110
|
-
];
|
|
111
|
-
for (const p of candidates) {
|
|
112
|
-
try { return await readFile(p, "utf8"); } catch { /* try next */ }
|
|
113
|
-
}
|
|
114
|
-
throw new Error("cytoscape not installed — run `npm install` in packages/seonix");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function renderHtml({ subgraph, focusLabel, cytoscape, colors = CLASS_COLOR }) {
|
|
118
|
-
const data = {
|
|
119
|
-
nodes: subgraph.nodes.map((n) => ({ data: { ...n, color: colors[n.cls] || "#8a8f98" } })),
|
|
120
|
-
edges: subgraph.edges.map((e, i) => ({ data: { id: `e${i}`, ...e } })),
|
|
135
|
+
/** The viewer's runtime payload — everything repo-specific lives here, nothing in
|
|
136
|
+
* the page. Pure; schema is part of the viewer contract (tests pin it). */
|
|
137
|
+
export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false } = {}) {
|
|
138
|
+
return {
|
|
121
139
|
focusId: subgraph.focusId,
|
|
140
|
+
focusLabel: focusLabel || subgraph.focusId,
|
|
141
|
+
truncated: !!subgraph.truncated,
|
|
122
142
|
maxDepth: subgraph.nodes.reduce((m, n) => Math.max(m, n.depth), 0),
|
|
143
|
+
repoUrl: repoUrl.replace(/\/+$/, ""),
|
|
144
|
+
repoRef,
|
|
145
|
+
siteNav: !!siteNav,
|
|
146
|
+
nodes: subgraph.nodes,
|
|
147
|
+
edges: subgraph.edges,
|
|
123
148
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Inline the Cytoscape dist so the HTML is one portable file (no sidecar, no CDN).
|
|
152
|
+
* Resolved via Node's own module resolution (createRequire), not a hardcoded
|
|
153
|
+
* relative-path guess — the guess only worked inside this monorepo's own dev
|
|
154
|
+
* layout (root-hoisted node_modules) and broke for a real npm-installed consumer,
|
|
155
|
+
* where cytoscape sits under the consumer's own node_modules, at a different
|
|
156
|
+
* depth from this file (`node_modules/@polycode-projects/seonix/src/…`). Node's
|
|
157
|
+
* resolution algorithm walks every parent node_modules correctly regardless of
|
|
158
|
+
* install topology (monorepo, flat install, nested install, pnpm symlinks). */
|
|
159
|
+
export async function cytoscapeSource() {
|
|
160
|
+
try {
|
|
161
|
+
const path = createRequire(import.meta.url).resolve("cytoscape/dist/cytoscape.min.js");
|
|
162
|
+
return await readFile(path, "utf8");
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error("cytoscape not installed — reinstall @polycode-projects/seonix (cytoscape is a declared dependency)");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The mechanical (zero-model-call) chat panel's engine, inlined into the viewer
|
|
169
|
+
* page — codegraph.mjs's parseEntities/relationKind (both zero-import, so the
|
|
170
|
+
* whole file inlines safely, same as temporal.mjs does for the code browser)
|
|
171
|
+
* in dependency order under ask-vocab.mjs's tables under ask.mjs's grammar/
|
|
172
|
+
* render logic. `export`/`import` lines stripped so the three files run as one
|
|
173
|
+
* plain <script> block; nothing here diverges from the node-tested source. */
|
|
174
|
+
async function askSource() {
|
|
175
|
+
const [codegraph, vocab, ask] = await Promise.all(
|
|
176
|
+
["codegraph.mjs", "ask-vocab.mjs", "ask.mjs"].map((f) => readFile(join(here, f), "utf8")),
|
|
177
|
+
);
|
|
178
|
+
// Non-greedy up to the closing `";` (not `$`-anchored): ask.mjs's own import spans
|
|
179
|
+
// multiple lines (a multi-name destructure), which a single-line `^...$` pattern
|
|
180
|
+
// silently leaves in place — and unlike a stray declaration, a surviving `import`
|
|
181
|
+
// is a hard SyntaxError in a classic (non-module) inlined <script>, not a quiet bug.
|
|
182
|
+
const strip = (src) => src
|
|
183
|
+
.replace(/^import\s[\s\S]*?from\s+"[^"]+";\s*$/gm, "")
|
|
184
|
+
.replace(/^export (?=(function|const|async function))/gm, "");
|
|
185
|
+
return [strip(codegraph), strip(vocab), strip(ask)].join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** JSON safe to sit inside a <script> block (no </script> or comment-open breakouts). */
|
|
189
|
+
const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The ONE viewer page. Repo-agnostic: no graph data, no repo URL, no focus baked in.
|
|
193
|
+
* `embedData` (portable single-file mode) and `dataPath` (generated pointer to a
|
|
194
|
+
* non-default data file) are the only generation-time inserts for the DISPLAY
|
|
195
|
+
* sub-graph; both are optional. `askEngine` (the inlined ask.mjs stack, from
|
|
196
|
+
* askSource()) powers the chat panel — omit it and the panel still renders but
|
|
197
|
+
* every query reports the engine as unavailable, never a silent no-op. `askData`/
|
|
198
|
+
* `askDataPath` mirror `embedData`/`dataPath` but for the FULL raw graph payload
|
|
199
|
+
* the chat panel queries — deliberately a SEPARATE, lazily-loaded channel from the
|
|
200
|
+
* depth-limited display sub-graph: querying only what's currently drawn would
|
|
201
|
+
* silently produce incomplete answers, which this project's honesty contract
|
|
202
|
+
* doesn't allow (see the file-level comment's "ONE viewer, three data channels" —
|
|
203
|
+
* this is a fourth, for the same one-viewer-many-modes reason).
|
|
204
|
+
*/
|
|
205
|
+
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null } = {}) {
|
|
206
|
+
const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
|
|
207
|
+
const askEmbedded = askData ? `<script type="application/json" id="seonix-ask-data">${inlineJson(askData)}</script>\n` : "";
|
|
208
|
+
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}) };
|
|
209
|
+
const cfg = Object.keys(cfgObj).length ? `<script type="application/json" id="seonix-cfg">${inlineJson(cfgObj)}</script>\n` : "";
|
|
210
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map</title>
|
|
211
|
+
<!-- generated by: seonix viz (see packages/seonix/src/viz.mjs) — one viewer, data loaded at runtime -->
|
|
128
212
|
<style>
|
|
129
213
|
html,body{margin:0;height:100%;font:13px system-ui,sans-serif;background:#1a1b26;color:#c0caf5}
|
|
130
|
-
|
|
131
|
-
#bar
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
.
|
|
135
|
-
|
|
136
|
-
|
|
214
|
+
body{display:flex;flex-direction:column}
|
|
215
|
+
#bar{padding:6px 12px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex:0 0 auto}
|
|
216
|
+
#bar .grp{display:inline-flex;gap:8px;align-items:center;padding:0 10px;border-right:1px solid #2a2e42}
|
|
217
|
+
#bar .grp:first-child{padding-left:0} #bar .grp:last-of-type,#bar .nav{border-right:0}
|
|
218
|
+
#bar .nav{margin-left:auto} #bar .nav a{color:#7aa2f7;text-decoration:none;font-size:12px} #bar .nav a:hover{text-decoration:underline}
|
|
219
|
+
#bar label,.lbl{color:#a9b1d6} #bar b{color:#7aa2f7}
|
|
220
|
+
#bar button{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;line-height:1.2}
|
|
221
|
+
#bar button:hover{border-color:#7aa2f7;color:#7aa2f7}
|
|
222
|
+
.seg{display:inline-flex;border:1px solid #2a2e42;border-radius:5px;overflow:hidden}
|
|
223
|
+
.seg .segb{border:0;border-radius:0;padding:2px 11px}
|
|
224
|
+
.seg .segb.on{background:#7aa2f7;color:#16161e;font-weight:600}
|
|
225
|
+
#hub{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
|
|
226
|
+
#legend{padding:4px 12px 6px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px;flex:0 0 auto}
|
|
227
|
+
#main{flex:1 1 auto;position:relative;min-height:0}
|
|
228
|
+
#cy{position:absolute;top:0;left:0;right:300px;bottom:0}
|
|
229
|
+
#side{position:absolute;top:0;right:0;width:300px;bottom:0;background:#16161e;border-left:1px solid #2a2e42;padding:14px;overflow:auto;box-sizing:border-box}
|
|
230
|
+
.lg{white-space:nowrap;cursor:pointer;user-select:none;color:#a9b1d6}.lg i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:middle}.lg input{vertical-align:middle;margin:0 3px 0 0}
|
|
231
|
+
.lg .cnt{color:#565f89;margin-left:3px;font-size:11px;font-variant-numeric:tabular-nums}
|
|
232
|
+
select{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;font:inherit}
|
|
233
|
+
#side h3{margin:2px 0 8px;color:#c0caf5;font-size:15px;word-break:break-all}
|
|
234
|
+
.badge{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border:1px solid #2a2e42;border-radius:10px;font-size:11px;color:#a9b1d6}
|
|
235
|
+
.badge i{width:8px;height:8px;border-radius:50%;display:inline-block}
|
|
236
|
+
#detail dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;margin:10px 0;font-size:12px;color:#c0caf5}
|
|
237
|
+
#detail dt{color:#565f89}#detail dd{margin:0}
|
|
238
|
+
.btn{display:inline-block;padding:4px 10px;border:1px solid #3b4261;border-radius:5px;color:#7aa2f7;background:#1a1b26;cursor:pointer;text-decoration:none;font:inherit;font-size:12px}
|
|
239
|
+
.btn:hover{border-color:#7aa2f7}
|
|
240
|
+
.row{display:flex;gap:8px;margin:10px 0;flex-wrap:wrap}
|
|
241
|
+
.hint{color:#565f89;font-size:11px;margin-top:10px}
|
|
242
|
+
.empty{color:#a9b1d6;font-size:12px;line-height:1.7}
|
|
243
|
+
code{color:#9aa5ce;word-break:break-all}
|
|
244
|
+
#ask{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid #2a2e42}
|
|
245
|
+
.askrow{display:flex;gap:6px}
|
|
246
|
+
#askq{flex:1;min-width:0;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:4px 8px;font:inherit}
|
|
247
|
+
#askq:focus{outline:none;border-color:#7aa2f7}
|
|
248
|
+
.askresult{margin-top:8px;font-size:12px;line-height:1.6;color:#c0caf5}
|
|
249
|
+
.askresult.miss{color:#a9b1d6;font-style:italic}
|
|
250
|
+
.askresult .askq-echo{color:#565f89;margin-bottom:4px;font-style:normal}
|
|
137
251
|
</style></head><body>
|
|
138
252
|
<div id="bar">
|
|
139
|
-
<span><b>seon</b> focus: <b id="focuslabel"
|
|
140
|
-
<
|
|
141
|
-
<label>hide deg> <input id="hub" type="
|
|
142
|
-
<label>labels <select id="verb"><option value="
|
|
143
|
-
<label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label>
|
|
144
|
-
<
|
|
145
|
-
|
|
253
|
+
<span class="grp"><b>seon</b> focus: <b id="focuslabel">…</b></span>
|
|
254
|
+
<span class="grp"><span class="lbl">depth</span><span class="seg" id="depthseg"></span></span>
|
|
255
|
+
<span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg> <input id="hub" type="number" min="2" max="200" value="16"></label></span>
|
|
256
|
+
<span class="grp"><label>labels <select id="verb"><option value="smart">smart</option><option value="name">all names</option><option value="site">name+site</option><option value="none">none</option></select></label>
|
|
257
|
+
<label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label></span>
|
|
258
|
+
<span class="grp" id="view">
|
|
259
|
+
<button id="reset" title="fit the whole graph in view (Esc)">fit</button>
|
|
260
|
+
<button id="zoomout" title="zoom out">−</button>
|
|
261
|
+
<button id="zoomin" title="zoom in">+</button>
|
|
262
|
+
</span>
|
|
263
|
+
<span class="grp nav" id="sitenav" hidden><a href="/">home</a><a href="/code-browser.html">browser</a><a href="/timeline.html">timeline</a></span>
|
|
264
|
+
</div>
|
|
265
|
+
<div id="legend"></div>
|
|
266
|
+
<div id="main">
|
|
267
|
+
<div id="cy"></div>
|
|
268
|
+
<div id="side">
|
|
269
|
+
<div id="ask">
|
|
270
|
+
<div class="askrow"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "what calls this"'><button class="btn" id="asksubmit">ask</button></div>
|
|
271
|
+
<div id="askresult" class="askresult"></div>
|
|
272
|
+
</div>
|
|
273
|
+
<div id="detail"></div>
|
|
274
|
+
</div>
|
|
146
275
|
</div>
|
|
147
|
-
<
|
|
148
|
-
<
|
|
149
|
-
<script>${cytoscape}</script>
|
|
276
|
+
${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
|
|
277
|
+
<script>${askEngine}</script>
|
|
150
278
|
<script>
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
style:[
|
|
154
|
-
{selector:'node',style:{'background-color':'data(color)','label':'data(label)','color':'#c0caf5','font-size':10,'text-wrap':'wrap','text-max-width':120,'width':18,'height':18}},
|
|
155
|
-
{selector:'node[id = "'+G.focusId+'"]',style:{'width':30,'height':30,'border-width':3,'border-color':'#f7768e'}},
|
|
156
|
-
{selector:'edge',style:{'label':'data(rel)','width':1,'line-color':'#3b4261','target-arrow-color':'#3b4261','target-arrow-shape':'triangle','curve-style':'bezier','font-size':8,'color':'#7a88cf','text-rotation':'autorotate'}}
|
|
157
|
-
],
|
|
158
|
-
layout:{name:'cose',animate:false}});
|
|
279
|
+
// Type palette (see the CVD note in viz.mjs) — viewer styling, identical for every repo.
|
|
280
|
+
const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
|
|
159
281
|
const $=id=>document.getElementById(id);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
282
|
+
const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
283
|
+
// Data resolution order: ?data= query param > embedded block (portable single-file
|
|
284
|
+
// artifact) > generated config pointer > the site/serve default sibling JSON.
|
|
285
|
+
async function loadData(){
|
|
286
|
+
const q=new URLSearchParams(location.search).get('data');
|
|
287
|
+
if(!q){
|
|
288
|
+
const emb=document.getElementById('seonix-data');
|
|
289
|
+
if(emb) return JSON.parse(emb.textContent);
|
|
290
|
+
}
|
|
291
|
+
const cfgEl=document.getElementById('seonix-cfg');
|
|
292
|
+
const url=q||(cfgEl?JSON.parse(cfgEl.textContent).data:'./seonix-graph-data.json');
|
|
293
|
+
const r=await fetch(url);
|
|
294
|
+
if(!r.ok) throw new Error('HTTP '+r.status+' loading '+url);
|
|
295
|
+
return r.json();
|
|
296
|
+
}
|
|
297
|
+
// The chat panel's data is a SEPARATE, lazily-loaded channel from the display
|
|
298
|
+
// sub-graph above — the FULL raw graph, fetched/parsed only on the first ask
|
|
299
|
+
// (never on page load), same resolution order as loadData() minus the ?data=
|
|
300
|
+
// override (that param is reserved for the display graph). Cached after the
|
|
301
|
+
// first call; parseEntities/ask come from the inlined ask engine (askSource()).
|
|
302
|
+
let _askGraphPromise=null;
|
|
303
|
+
function loadAskData(){
|
|
304
|
+
if(_askGraphPromise) return _askGraphPromise;
|
|
305
|
+
_askGraphPromise=(async()=>{
|
|
306
|
+
const emb=document.getElementById('seonix-ask-data');
|
|
307
|
+
let raw;
|
|
308
|
+
if(emb){
|
|
309
|
+
raw=JSON.parse(emb.textContent);
|
|
310
|
+
}else{
|
|
311
|
+
const cfgEl=document.getElementById('seonix-cfg');
|
|
312
|
+
const url=(cfgEl&&JSON.parse(cfgEl.textContent).ask)||'./seonix-ask-data.json';
|
|
313
|
+
const r=await fetch(url);
|
|
314
|
+
if(!r.ok) throw new Error('HTTP '+r.status+' loading '+url);
|
|
315
|
+
raw=await r.json();
|
|
316
|
+
}
|
|
317
|
+
return parseEntities(raw);
|
|
318
|
+
})();
|
|
319
|
+
return _askGraphPromise;
|
|
320
|
+
}
|
|
321
|
+
function init(G){
|
|
322
|
+
document.title='seon code-map — '+G.focusLabel;
|
|
323
|
+
$('focuslabel').textContent=G.focusLabel;
|
|
324
|
+
if(G.siteNav)$('sitenav').hidden=false;
|
|
325
|
+
// Legend doubles as a node-type filter: a checkbox chip per mgx type
|
|
326
|
+
// (Module/Class/Function/Method/Attribute/GlobalVariable/Commit) with a live
|
|
327
|
+
// visible/loaded count for the current depth + hub-degree filters.
|
|
328
|
+
$('legend').innerHTML=Object.entries(COLORS)
|
|
329
|
+
.map(([k,v])=>'<label class="lg" title="show/hide '+k+' nodes"><input type="checkbox" class="typechk" data-cls="'+k+'" checked><i style="background:'+v+'"></i>'+k+'<span class="cnt" data-cnt="'+k+'"></span></label>')
|
|
330
|
+
.join(' ');
|
|
331
|
+
// depth is a segmented control (no dead range beyond the data's max depth), default 2
|
|
332
|
+
const maxDepth=Math.max(2,G.maxDepth);
|
|
333
|
+
$('depthseg').innerHTML=Array.from({length:maxDepth},(_,i)=>i+1)
|
|
334
|
+
.map(d=>'<button class="segb'+(d===2?' on':'')+'" data-d="'+d+'">'+d+'</button>').join('');
|
|
335
|
+
const cy=cytoscape({container:document.getElementById('cy'),
|
|
336
|
+
elements:[...G.nodes.map(n=>({data:{...n,color:COLORS[n.cls]||'#8a8f98'}})),...G.edges.map((e,i)=>({data:{id:'e'+i,...e}}))],
|
|
337
|
+
style:[
|
|
338
|
+
// black text outline keeps white labels readable over node fills and the dark canvas;
|
|
339
|
+
// node size encodes degree (bounded) so hubs read as hubs at a glance.
|
|
340
|
+
{selector:'node',style:{'background-color':'data(color)','label':'','color':'#fff','font-size':10,'min-zoomed-font-size':7,'text-outline-color':'#000','text-outline-width':2,'text-wrap':'wrap','text-max-width':120,'width':'mapData(degree,1,40,16,34)','height':'mapData(degree,1,40,16,34)'}},
|
|
341
|
+
// focus + selection wear a white ring — white is no type's hue, so attention never impersonates identity
|
|
342
|
+
{selector:'node.focus',style:{'width':34,'height':34,'border-width':3,'border-color':'#fff'}},
|
|
343
|
+
{selector:'node.sel',style:{'border-width':3,'border-color':'#fff'}},
|
|
344
|
+
{selector:'.faded',style:{'opacity':0.15}},
|
|
345
|
+
// edge type labels only on hover/tap — painted everywhere they bury the graph
|
|
346
|
+
{selector:'edge',style:{'width':1,'line-color':'#3b4261','target-arrow-color':'#3b4261','target-arrow-shape':'triangle','curve-style':'bezier'}},
|
|
347
|
+
{selector:'edge.hl',style:{'width':2,'line-color':'#7aa2f7','target-arrow-color':'#7aa2f7'}},
|
|
348
|
+
{selector:'edge.showrel',style:{'label':'data(rel)','font-size':8,'color':'#c0caf5','text-outline-color':'#000','text-outline-width':2,'text-rotation':'autorotate','line-color':'#7aa2f7','target-arrow-color':'#7aa2f7'}}
|
|
349
|
+
],
|
|
350
|
+
layout:{name:'cose',animate:false},
|
|
351
|
+
wheelSensitivity:0.2});
|
|
352
|
+
cy.on('mouseover','edge',e=>e.target.addClass('showrel'));
|
|
353
|
+
cy.on('mouseout','edge',e=>e.target.removeClass('showrel'));
|
|
354
|
+
cy.on('tap','edge',e=>{cy.edges('.showrel').removeClass('showrel');e.target.addClass('showrel');});
|
|
355
|
+
let selId=null;
|
|
356
|
+
function enabledTypes(){
|
|
357
|
+
const on=new Set();
|
|
358
|
+
document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)on.add(c.dataset.cls);});
|
|
359
|
+
return on;
|
|
360
|
+
}
|
|
361
|
+
function curDepth(){return +document.querySelector('.segb.on').dataset.d;}
|
|
362
|
+
function curHub(){return $('hubon').checked ? +$('hub').value : Infinity;}
|
|
363
|
+
function applyFilters(){
|
|
364
|
+
const d=curDepth(), h=curHub(), types=enabledTypes();
|
|
365
|
+
const vis={}, tot={};
|
|
366
|
+
cy.batch(()=>cy.nodes().forEach(n=>{
|
|
367
|
+
const cls=n.data('cls');
|
|
368
|
+
tot[cls]=(tot[cls]||0)+1;
|
|
369
|
+
const v = n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
|
|
370
|
+
if(v) vis[cls]=(vis[cls]||0)+1;
|
|
371
|
+
n.style('display', v?'element':'none');
|
|
372
|
+
}));
|
|
373
|
+
// live per-type counts: visible/loaded under the current depth + hub filters
|
|
374
|
+
document.querySelectorAll('.cnt').forEach(s=>{
|
|
375
|
+
const k=s.dataset.cnt;
|
|
376
|
+
s.textContent=(vis[k]||0)+'/'+(tot[k]||0);
|
|
377
|
+
s.title=(vis[k]||0)+' visible of '+(tot[k]||0)+' loaded '+k+' node(s) at depth '+d+(isFinite(h)?', hide deg>'+h:'');
|
|
378
|
+
});
|
|
379
|
+
applyLabels();
|
|
380
|
+
}
|
|
381
|
+
const labelText=(n,withSite)=>n.data('label')+(withSite&&n.data('site')?'\\n'+n.data('site'):'');
|
|
382
|
+
// smart labels (default): a label budget instead of label soup — the focus node, the
|
|
383
|
+
// selection + its neighbours, and the top visible nodes by degree; everything else
|
|
384
|
+
// labels on hover. "all names"/"name+site" restore the old paint-everything modes.
|
|
385
|
+
const LABEL_BUDGET=20;
|
|
386
|
+
function applyLabels(){
|
|
387
|
+
const v=$('verb').value;
|
|
388
|
+
if(v==='none'){cy.nodes().forEach(n=>n.style('label',''));return;}
|
|
389
|
+
let show=null;
|
|
390
|
+
if(v==='smart'){
|
|
391
|
+
show=new Set([G.focusId]);
|
|
392
|
+
if(selId){show.add(selId);cy.getElementById(selId).closedNeighborhood('node').forEach(n=>show.add(n.id()));}
|
|
393
|
+
const vis=cy.nodes().filter(n=>n.style('display')!=='none');
|
|
394
|
+
vis.sort((a,b)=>b.data('degree')-a.data('degree')).slice(0,LABEL_BUDGET).forEach(n=>show.add(n.id()));
|
|
395
|
+
}
|
|
396
|
+
cy.nodes().forEach(n=>n.style('label',(!show||show.has(n.id()))?labelText(n,v==='site'):''));
|
|
397
|
+
}
|
|
398
|
+
cy.on('mouseover','node',e=>{if($('verb').value==='smart')e.target.style('label',labelText(e.target,false));});
|
|
399
|
+
cy.on('mouseout','node',e=>{if($('verb').value==='smart')applyLabels();});
|
|
400
|
+
document.querySelectorAll('.segb').forEach(b=>b.addEventListener('click',()=>{
|
|
401
|
+
document.querySelectorAll('.segb').forEach(x=>x.classList.remove('on'));b.classList.add('on');applyFilters();}));
|
|
402
|
+
$('hubon').addEventListener('change',applyFilters);
|
|
403
|
+
$('hub').addEventListener('input',applyFilters);
|
|
404
|
+
document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',applyFilters));
|
|
405
|
+
$('verb').addEventListener('change',applyLabels);
|
|
406
|
+
$('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
|
|
407
|
+
const ZF=1.2;
|
|
408
|
+
const zoomBy=(f)=>cy.zoom({level:cy.zoom()*f,renderedPosition:{x:cy.width()/2,y:cy.height()/2}});
|
|
409
|
+
$('reset').addEventListener('click',()=>{cy.fit(undefined,30);});
|
|
410
|
+
$('zoomin').addEventListener('click',()=>zoomBy(ZF));
|
|
411
|
+
$('zoomout').addEventListener('click',()=>zoomBy(1/ZF));
|
|
412
|
+
// Deep link into the source host (GitLab URL layout) when the data carries a
|
|
413
|
+
// repoUrl: Commit nodes -> /-/commit/<sha>, anything with a file site ->
|
|
414
|
+
// /-/blob/<ref>/<path>#L<lines>, bare Modules -> the file blob.
|
|
415
|
+
function nodeUrl(d){
|
|
416
|
+
if(!G.repoUrl) return null;
|
|
417
|
+
if(d.cls==='Commit'&&d.id.startsWith('commit:')) return G.repoUrl+'/-/commit/'+d.id.slice(7);
|
|
418
|
+
if(d.site){
|
|
419
|
+
const i=d.site.lastIndexOf(':');
|
|
420
|
+
return G.repoUrl+'/-/blob/'+G.repoRef+'/'+d.site.slice(0,i)+'#L'+d.site.slice(i+1);
|
|
421
|
+
}
|
|
422
|
+
if(d.id.startsWith('mod:')) return G.repoUrl+'/-/blob/'+G.repoRef+'/'+d.id.slice(4);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
function renderDetail(d){
|
|
426
|
+
const url=nodeUrl(d);
|
|
427
|
+
$('detail').innerHTML='<h3>'+esc(d.label)+'</h3>'
|
|
428
|
+
+'<span class="badge"><i style="background:'+d.color+'"></i>'+esc(d.cls)+'</span>'
|
|
429
|
+
+'<dl><dt>depth</dt><dd>'+d.depth+'</dd><dt>degree</dt><dd>'+d.degree+'</dd>'
|
|
430
|
+
+(d.site?'<dt>site</dt><dd><code>'+esc(d.site)+'</code></dd>':'')+'</dl>'
|
|
431
|
+
+'<div class="row">'
|
|
432
|
+
+(url?'<a class="btn" href="'+url+'" target="_blank" rel="noopener">open in GitLab ↗</a>':'')
|
|
433
|
+
+'<button class="btn" id="recentrebtn">re-centre here</button></div>'
|
|
434
|
+
+'<div class="hint">id <code>'+esc(d.id)+'</code></div>';
|
|
435
|
+
$('recentrebtn').addEventListener('click',()=>recentre(d.id));
|
|
436
|
+
}
|
|
437
|
+
function renderDetailEmpty(){
|
|
438
|
+
$('detail').innerHTML='<p class="empty">Click a node to inspect it.<br>Double-click a node to re-centre on it.<br>Drag to pan · scroll to zoom · Esc fits all.</p>'
|
|
439
|
+
+'<p class="hint">'+G.nodes.length+' nodes · '+G.edges.length+' edges loaded'+(G.truncated?' (capped)':'')+'.</p>';
|
|
440
|
+
}
|
|
441
|
+
// selection: dim everything outside the tapped node's neighbourhood so the local
|
|
442
|
+
// structure reads instantly; background tap or Esc clears.
|
|
443
|
+
function select(node){
|
|
444
|
+
cy.elements().removeClass('faded sel hl');
|
|
445
|
+
selId=node?node.id():null;
|
|
446
|
+
if(node){
|
|
447
|
+
cy.elements().not(node.closedNeighborhood()).addClass('faded');
|
|
448
|
+
node.addClass('sel');
|
|
449
|
+
node.connectedEdges().addClass('hl');
|
|
450
|
+
renderDetail(node.data());
|
|
451
|
+
} else renderDetailEmpty();
|
|
452
|
+
applyLabels();
|
|
453
|
+
}
|
|
454
|
+
cy.on('tap','node',e=>select(e.target));
|
|
455
|
+
cy.on('tap',e=>{if(e.target===cy)select(null);});
|
|
456
|
+
document.addEventListener('keydown',e=>{if(e.key==='Escape'){select(null);cy.fit(undefined,30);}});
|
|
457
|
+
// re-centre: recompute node depths client-side (BFS over the LOADED sub-graph — an
|
|
458
|
+
// honest approximation; deeper structure than the data carries stays absent)
|
|
459
|
+
// and move the focus ring; double-click or the panel button.
|
|
460
|
+
const ADJ={};
|
|
461
|
+
G.edges.forEach(e=>{(ADJ[e.source]??=[]).push(e.target);(ADJ[e.target]??=[]).push(e.source);});
|
|
462
|
+
function recentre(id){
|
|
463
|
+
const depth={[id]:0};const q=[id];
|
|
464
|
+
while(q.length){const cur=q.shift();for(const nb of ADJ[cur]||[]){if(!(nb in depth)){depth[nb]=depth[cur]+1;q.push(nb);}}}
|
|
465
|
+
cy.getElementById(G.focusId).removeClass('focus');
|
|
466
|
+
G.focusId=id;
|
|
467
|
+
cy.batch(()=>cy.nodes().forEach(n=>n.data('depth', depth[n.id()] ?? 99)));
|
|
468
|
+
const f=cy.getElementById(id);
|
|
469
|
+
f.addClass('focus');
|
|
470
|
+
$('focuslabel').textContent=f.data('label');
|
|
471
|
+
applyFilters();
|
|
472
|
+
select(f);
|
|
473
|
+
cy.fit(cy.nodes().filter(n=>n.style('display')!=='none'),30);
|
|
474
|
+
}
|
|
475
|
+
cy.on('dbltap','node',e=>recentre(e.target.id()));
|
|
476
|
+
cy.getElementById(G.focusId).addClass('focus');
|
|
477
|
+
applyFilters();select(null);
|
|
478
|
+
cy.fit(undefined,30); // land fitted to the whole depth-2 neighbourhood, not zoomed into label soup
|
|
479
|
+
// Mechanical (zero-model-call) chat panel: queries the FULL raw graph (loadAskData,
|
|
480
|
+
// lazy — never the depth-limited display sub-graph above, so an answer is never
|
|
481
|
+
// silently incomplete), with the current selection as pronoun context ("this"/"it").
|
|
482
|
+
// Highlighting is honest about the display/query mismatch: matches that AREN'T among
|
|
483
|
+
// the currently-loaded nodes are named in the answer text but can't be spotlighted —
|
|
484
|
+
// stated plainly rather than pretending the view moved.
|
|
485
|
+
function highlightAsk(ids){
|
|
486
|
+
cy.elements().removeClass('faded sel hl');
|
|
487
|
+
selId=null;
|
|
488
|
+
const eles=cy.collection();
|
|
489
|
+
ids.forEach(id=>{const n=cy.getElementById(id);if(n.nonempty())eles.merge(n);});
|
|
490
|
+
if(eles.nonempty()){
|
|
491
|
+
cy.elements().not(eles).addClass('faded');
|
|
492
|
+
eles.addClass('sel');
|
|
493
|
+
cy.fit(eles,50);
|
|
494
|
+
}
|
|
495
|
+
applyLabels();
|
|
496
|
+
return eles.length;
|
|
497
|
+
}
|
|
498
|
+
function runAsk(query){
|
|
499
|
+
const out=$('askresult');
|
|
500
|
+
out.classList.remove('miss');
|
|
501
|
+
if(typeof ask!=='function'){
|
|
502
|
+
out.classList.add('miss');
|
|
503
|
+
out.textContent='the ask engine did not load with this page.';
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
out.textContent='thinking…';
|
|
507
|
+
loadAskData().then(askGraph=>{
|
|
508
|
+
const {content,seonix_ask}=ask(askGraph,query,{contextId:selId});
|
|
509
|
+
const total=(seonix_ask.matches||[]).length;
|
|
510
|
+
const shown=total?highlightAsk(seonix_ask.matches.map(m=>m.id)):0;
|
|
511
|
+
out.classList.toggle('miss',!!seonix_ask.miss);
|
|
512
|
+
let note='';
|
|
513
|
+
if(total&&shown<total) note=' <span class="hint">('+shown+' of '+total+' match(es) are in the currently loaded view)</span>';
|
|
514
|
+
else if(total&&!shown) note=' <span class="hint">(not shown — outside the currently loaded view)</span>';
|
|
515
|
+
out.innerHTML='<div class="askq-echo">"'+esc(query)+'"</div>'+esc(content)+note;
|
|
516
|
+
}).catch(err=>{
|
|
517
|
+
out.classList.add('miss');
|
|
518
|
+
out.textContent='could not load the graph for this query: '+err.message;
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
$('askresult').innerHTML='<span class="hint">try: "what calls this" (after selecting a node) · "which functions import X" · "does A import B"</span>';
|
|
522
|
+
$('asksubmit').addEventListener('click',()=>{const q=$('askq').value.trim();if(q)runAsk(q);});
|
|
523
|
+
$('askq').addEventListener('keydown',e=>{if(e.key==='Enter'){const q=$('askq').value.trim();if(q)runAsk(q);}});
|
|
524
|
+
window.cy=cy; // scripted checks (playwright) drive the instance directly
|
|
525
|
+
}
|
|
526
|
+
loadData().then(init).catch(err=>{
|
|
527
|
+
$('detail').innerHTML='<p class="empty">Failed to load graph data: '+esc(err.message)
|
|
528
|
+
+'</p><p class="hint">Serve a seonix-graph-data.json next to this page, pass ?data=<url>, or regenerate with seonix viz.</p>';
|
|
529
|
+
});
|
|
178
530
|
</script></body></html>`;
|
|
179
531
|
}
|
|
180
532
|
|
|
533
|
+
/** Compat wrapper: the portable single-file artifact (viewer + embedded data). */
|
|
534
|
+
export function renderHtml({ subgraph, focusLabel, cytoscape, askEngine = "", askData = null, repoUrl = "", repoRef = "main", siteNav = false }) {
|
|
535
|
+
const data = buildViewerData(subgraph, { focusLabel, repoUrl, repoRef, siteNav });
|
|
536
|
+
return renderViewerHtml({ cytoscape, askEngine, embedData: data, askData });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Best-effort GitLab base URL from the repo's `origin` remote; empty when the
|
|
540
|
+
* remote is absent or not GitLab-shaped (the viewer then renders no dead links). */
|
|
541
|
+
export async function repoUrlFromGit(cwd) {
|
|
542
|
+
try {
|
|
543
|
+
const { stdout } = await execFileP("git", ["remote", "get-url", "origin"], { cwd });
|
|
544
|
+
const raw = stdout.trim();
|
|
545
|
+
const m = raw.match(/^(?:git@|ssh:\/\/git@)([^:/]+)[:/](.+?)(?:\.git)?$/) || raw.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
546
|
+
if (!m) return "";
|
|
547
|
+
const [, host, path] = m;
|
|
548
|
+
if (!/gitlab/i.test(host)) return ""; // the deep-link layout is GitLab's
|
|
549
|
+
return `https://${host}/${path}`;
|
|
550
|
+
} catch {
|
|
551
|
+
return "";
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The literal `git rev-parse HEAD` (P3 live re-annotate's cheap change signal —
|
|
556
|
+
* independent of the temporal graph's own commit list, which only carries
|
|
557
|
+
* commits that touched a tracked symbol). Empty string on any git failure. */
|
|
558
|
+
async function gitHeadOf(cwd) {
|
|
559
|
+
try {
|
|
560
|
+
const { stdout } = await execFileP("git", ["-C", cwd, "rev-parse", "HEAD"], {});
|
|
561
|
+
return stdout.trim();
|
|
562
|
+
} catch {
|
|
563
|
+
return "";
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function loadGraph(values) {
|
|
568
|
+
const config = loadConfig(values.graph ? { ...process.env, SEONIX_GRAPH_FILE: resolve(values.graph) } : process.env);
|
|
569
|
+
const raw = await source.fetchEntities(config);
|
|
570
|
+
const graph = parseEntities(raw);
|
|
571
|
+
return { config, graph, raw };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function resolveFocus(graph, focusArg) {
|
|
575
|
+
if (focusArg) {
|
|
576
|
+
const { match } = resolveSymbol(graph, focusArg);
|
|
577
|
+
return match ? { id: match.id, label: match.label, defaulted: false } : null;
|
|
578
|
+
}
|
|
579
|
+
const best = defaultFocus(graph);
|
|
580
|
+
return best ? { id: best.id, label: best.label, defaulted: true } : null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* `viz --serve`: the site experience against the LOCAL repo's index. Serves the one
|
|
585
|
+
* viewer at `/` and rebuilds `/seonix-graph-data.json` from the graph file on every
|
|
586
|
+
* request, so a re-index shows up on refresh without restarting. The Chronograph
|
|
587
|
+
* code browser rides along at `/code-browser.html` (alias `/browser`) with a live
|
|
588
|
+
* temporal payload — the local equivalent of the site's browser section.
|
|
589
|
+
*/
|
|
590
|
+
export async function startVizServer({ values, cytoscape, askEngine = "", log = () => {} }) {
|
|
591
|
+
const port = Math.max(0, parseInt(values.port, 10) || 0);
|
|
592
|
+
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
593
|
+
const viewer = renderViewerHtml({ cytoscape, askEngine });
|
|
594
|
+
const browserPage = await renderBrowserHtml({ cytoscape });
|
|
595
|
+
const buildData = async () => {
|
|
596
|
+
const { graph } = await loadGraph(values);
|
|
597
|
+
const focus = resolveFocus(graph, values.focus);
|
|
598
|
+
if (!focus) throw new Error(values.focus ? `no entity matching "${values.focus}"` : "graph has no entities");
|
|
599
|
+
const subgraph = buildSubgraph(graph, {
|
|
600
|
+
focusId: focus.id,
|
|
601
|
+
depth: Math.max(1, parseInt(values.depth, 10) || 2),
|
|
602
|
+
hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
|
|
603
|
+
maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
|
|
604
|
+
});
|
|
605
|
+
return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref });
|
|
606
|
+
};
|
|
607
|
+
// The chat panel's own channel — the FULL raw graph (not the depth-limited display
|
|
608
|
+
// sub-graph above), rebuilt fresh per request so a re-index shows up without a
|
|
609
|
+
// restart, same invariant as buildData().
|
|
610
|
+
const buildAskData = async () => {
|
|
611
|
+
const { raw } = await loadGraph(values);
|
|
612
|
+
return raw;
|
|
613
|
+
};
|
|
614
|
+
const buildBrowserPayload = async () => {
|
|
615
|
+
const config = loadConfig(values.graph ? { ...process.env, SEONIX_GRAPH_FILE: resolve(values.graph) } : process.env);
|
|
616
|
+
const raw = await source.fetchEntities(config);
|
|
617
|
+
const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
|
|
618
|
+
const [order, parentsBySha] = await Promise.all([
|
|
619
|
+
gitCommitOrder(process.cwd(), commitIds),
|
|
620
|
+
gitCommitParents(process.cwd()), // P3 ghost-branch merges
|
|
621
|
+
]);
|
|
622
|
+
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
623
|
+
return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()) });
|
|
624
|
+
};
|
|
625
|
+
await buildData(); // fail fast (missing graph, bad focus) before binding the port
|
|
626
|
+
const server = createServer(async (req, res) => {
|
|
627
|
+
const path = new URL(req.url, "http://x").pathname;
|
|
628
|
+
if (path === "/" || path === "/index.html" || path === "/seonix-graph.html") {
|
|
629
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
630
|
+
res.end(viewer);
|
|
631
|
+
} else if (path === "/browser" || path === "/code-browser.html") {
|
|
632
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
633
|
+
res.end(browserPage);
|
|
634
|
+
} else if (path === "/seonix-graph-data.json") {
|
|
635
|
+
try {
|
|
636
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
637
|
+
res.end(JSON.stringify(await buildData()));
|
|
638
|
+
} catch (err) {
|
|
639
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
640
|
+
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
641
|
+
}
|
|
642
|
+
} else if (path === "/seonix-ask-data.json") {
|
|
643
|
+
try {
|
|
644
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
645
|
+
res.end(JSON.stringify(await buildAskData()));
|
|
646
|
+
} catch (err) {
|
|
647
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
648
|
+
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
649
|
+
}
|
|
650
|
+
} else if (path === "/code-browser-data.json") {
|
|
651
|
+
try {
|
|
652
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
653
|
+
res.end(JSON.stringify(await buildBrowserPayload()));
|
|
654
|
+
} catch (err) {
|
|
655
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
656
|
+
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
657
|
+
}
|
|
658
|
+
} else if (path === "/code-browser-version") {
|
|
659
|
+
// P3 live re-annotate: a CHEAP poll target (one git call, no graph rebuild)
|
|
660
|
+
// so the client can notice HEAD moved without re-fetching the full payload
|
|
661
|
+
// every few seconds.
|
|
662
|
+
try {
|
|
663
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
664
|
+
res.end(JSON.stringify({ head: await gitHeadOf(process.cwd()) }));
|
|
665
|
+
} catch (err) {
|
|
666
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
667
|
+
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
671
|
+
res.end("not found");
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
await new Promise((ok) => server.listen(port, "127.0.0.1", ok));
|
|
675
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
676
|
+
log(`seonix viz: serving the code-map viewer on ${url} (code browser at ${url}code-browser.html; Ctrl-C to stop)\n`);
|
|
677
|
+
return { server, url, port: server.address().port };
|
|
678
|
+
}
|
|
679
|
+
|
|
181
680
|
export async function runVizCli(argv) {
|
|
182
681
|
const { values } = parseArgs({
|
|
183
682
|
args: argv,
|
|
@@ -187,32 +686,105 @@ export async function runVizCli(argv) {
|
|
|
187
686
|
hub: { type: "string", default: "40" },
|
|
188
687
|
max: { type: "string", default: "200" },
|
|
189
688
|
out: { type: "string", default: "seon-graph.html" },
|
|
689
|
+
"data-out": { type: "string" },
|
|
190
690
|
graph: { type: "string" },
|
|
691
|
+
"repo-url": { type: "string", default: "" },
|
|
692
|
+
ref: { type: "string", default: "main" },
|
|
693
|
+
"site-nav": { type: "boolean", default: false },
|
|
694
|
+
serve: { type: "boolean", default: false },
|
|
695
|
+
port: { type: "string", default: "0" },
|
|
696
|
+
// Chronograph (code browser) artifacts — see src/browser.mjs
|
|
697
|
+
"browser-out": { type: "string" },
|
|
698
|
+
"browser-data-out": { type: "string" },
|
|
699
|
+
scope: { type: "string", default: "product" },
|
|
191
700
|
},
|
|
192
701
|
allowPositionals: false,
|
|
193
702
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
703
|
+
const cytoscape = await cytoscapeSource();
|
|
704
|
+
const askEngine = await askSource();
|
|
705
|
+
if (values.serve) {
|
|
706
|
+
await startVizServer({ values, cytoscape, askEngine, log: (s) => process.stderr.write(s) });
|
|
707
|
+
return; // keeps serving until Ctrl-C
|
|
197
708
|
}
|
|
198
|
-
const config
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
709
|
+
const { config, graph, raw } = await loadGraph(values);
|
|
710
|
+
const focus = resolveFocus(graph, values.focus);
|
|
711
|
+
if (!focus) {
|
|
712
|
+
process.stderr.write(values.focus
|
|
713
|
+
? `seonix viz: no entity matching "${values.focus}" in ${config.graphFile}\n`
|
|
714
|
+
: `seonix viz: graph has no entities in ${config.graphFile}\n`);
|
|
203
715
|
process.exit(1);
|
|
204
716
|
}
|
|
717
|
+
if (focus.defaulted) process.stderr.write(`seonix viz: no --focus given — defaulting to "${focus.label}" (highest-degree module)\n`);
|
|
205
718
|
const subgraph = buildSubgraph(graph, {
|
|
206
|
-
focusId:
|
|
719
|
+
focusId: focus.id,
|
|
207
720
|
depth: Math.max(1, parseInt(values.depth, 10) || 2),
|
|
208
721
|
hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
|
|
209
722
|
maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
|
|
210
723
|
});
|
|
211
|
-
const
|
|
724
|
+
const data = buildViewerData(subgraph, {
|
|
725
|
+
focusLabel: focus.label,
|
|
726
|
+
repoUrl: values["repo-url"],
|
|
727
|
+
repoRef: values.ref,
|
|
728
|
+
siteNav: values["site-nav"],
|
|
729
|
+
});
|
|
212
730
|
const out = resolve(values.out);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
731
|
+
if (values["data-out"]) {
|
|
732
|
+
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
733
|
+
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
734
|
+
// written next to the display data file rather than embedded in the page.
|
|
735
|
+
const dataOut = resolve(values["data-out"]);
|
|
736
|
+
const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
|
|
737
|
+
const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
|
|
738
|
+
const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
|
|
739
|
+
await Promise.all([writeFile(dataOut, JSON.stringify(data)), writeFile(askDataOut, JSON.stringify(raw))]);
|
|
740
|
+
await writeFile(out, renderViewerHtml({
|
|
741
|
+
cytoscape, askEngine,
|
|
742
|
+
dataPath: rel === "seonix-graph-data.json" ? null : rel,
|
|
743
|
+
askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
|
|
744
|
+
}));
|
|
745
|
+
process.stderr.write(
|
|
746
|
+
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
747
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
748
|
+
);
|
|
749
|
+
} else {
|
|
750
|
+
// portable mode: one self-contained file, both channels embedded
|
|
751
|
+
await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw }));
|
|
752
|
+
process.stderr.write(
|
|
753
|
+
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
754
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Chronograph artifacts (the code browser) — same one-page/data-sibling pattern.
|
|
759
|
+
if (values["browser-out"]) {
|
|
760
|
+
const raw = await source.fetchEntities(config);
|
|
761
|
+
const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
|
|
762
|
+
const [order, parentsBySha] = await Promise.all([
|
|
763
|
+
gitCommitOrder(process.cwd(), commitIds),
|
|
764
|
+
gitCommitParents(process.cwd()), // P3 ghost-branch merges — static data, no polling here
|
|
765
|
+
]);
|
|
766
|
+
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
767
|
+
const browserData = buildBrowserData(tg, {
|
|
768
|
+
repoUrl: values["repo-url"] || (await repoUrlFromGit(process.cwd())),
|
|
769
|
+
repoRef: values.ref,
|
|
770
|
+
siteNav: values["site-nav"],
|
|
771
|
+
});
|
|
772
|
+
const bOut = resolve(values["browser-out"]);
|
|
773
|
+
if (values["browser-data-out"]) {
|
|
774
|
+
const bDataOut = resolve(values["browser-data-out"]);
|
|
775
|
+
const rel = relative(dirname(bOut), bDataOut) || "code-browser-data.json";
|
|
776
|
+
await writeFile(bDataOut, JSON.stringify(browserData));
|
|
777
|
+
await writeFile(bOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
|
|
778
|
+
process.stderr.write(
|
|
779
|
+
`seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
|
|
780
|
+
`(scope ${values.scope}) → ${bOut} + ${bDataOut}\n`,
|
|
781
|
+
);
|
|
782
|
+
} else {
|
|
783
|
+
await writeFile(bOut, await renderBrowserHtml({ cytoscape, embedData: browserData }));
|
|
784
|
+
process.stderr.write(
|
|
785
|
+
`seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
|
|
786
|
+
`(scope ${values.scope}) → ${bOut}\n`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
218
790
|
}
|