@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/src/viz.mjs CHANGED
@@ -1,30 +1,47 @@
1
- // viz.mjs — render a FOCUSED sub-graph of the typed code-map to a single, portable
2
- // HTML file driven by Cytoscape.js. Deliberately NOT a whole-graph dump (that hangs
3
- // the browser): we BFS an ego-network around a focus node to a small depth, stop
4
- // expanding through high-degree hubs, and cap the node count. The HTML embeds the
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
- // `buildSubgraph` is pure and unit-tested; the CLI adds graph loading + file I/O.
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 { dirname, join, resolve } from "node:path";
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: "#5b9bd5",
22
- Class: "#ffc000",
23
- Function: "#70ad47",
24
- Method: "#9ece6a",
25
- Attribute: "#bb9af7",
26
- GlobalVariable: "#e0af68",
27
- Commit: "#8a8f98",
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
- /** Inline the Cytoscape dist so the HTML is one portable file (no sidecar, no CDN). */
106
- async function cytoscapeSource() {
107
- const candidates = [
108
- join(here, "..", "node_modules", "cytoscape", "dist", "cytoscape.min.js"),
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
- const legend = Object.entries(colors)
125
- .map(([k, v]) => `<span class="lg"><i style="background:${v}"></i>${k}</span>`)
126
- .join(" ");
127
- return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map ${focusLabel}</title>
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
- #bar{padding:8px 12px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
131
- #bar label{color:#a9b1d6} #bar b{color:#7aa2f7}
132
- #cy{position:absolute;top:auto;left:0;right:300px;bottom:0;height:calc(100% - 44px)}
133
- #side{position:absolute;right:0;width:300px;bottom:0;height:calc(100% - 44px);background:#16161e;border-left:1px solid #2a2e42;padding:12px;overflow:auto}
134
- .lg{margin-right:8px;white-space:nowrap}.lg i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:middle}
135
- input[type=range]{vertical-align:middle} select{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px}
136
- h3{margin:4px 0;color:#7aa2f7} code{color:#9ece6a;word-break:break-all}
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">${focusLabel}</b></span>
140
- <label>depth <input id="depth" type="range" min="1" max="9" value="9"> <span id="depthv"></span></label>
141
- <label>hide deg&gt; <input id="hub" type="range" min="2" max="200" value="200"> <span id="hubv"></span></label>
142
- <label>labels <select id="verb"><option value="1">name</option><option value="0">none</option><option value="2">name+site</option></select></label>
143
- <label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label>
144
- <button id="fit">fit</button>
145
- <span style="margin-left:auto">${legend}</span>
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&gt; <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
- <div id="cy"></div>
148
- <div id="side"><h3>click a node</h3><div id="detail">Nodes: ${data.nodes.length}, edges: ${data.edges.length}${subgraph.truncated ? " (capped)" : ""}.</div></div>
149
- <script>${cytoscape}</script>
276
+ ${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
277
+ <script>${askEngine}</script>
150
278
  <script>
151
- const G=${JSON.stringify(data)};
152
- const cy=cytoscape({container:document.getElementById('cy'),elements:[...G.nodes,...G.edges],
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
- function applyFilters(){
161
- const d=+$('depth').value, h=+$('hub').value;
162
- $('depthv').textContent=d; $('hubv').textContent=h;
163
- cy.batch(()=>cy.nodes().forEach(n=>{
164
- const vis = n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h);
165
- n.style('display', vis?'element':'none');
166
- }));
167
- }
168
- function applyLabels(){const v=$('verb').value;cy.nodes().forEach(n=>{
169
- n.style('label', v==='0'?'':v==='2'?(n.data('label')+(n.data('site')?'\\n'+n.data('site'):'')):n.data('label'));});}
170
- ['depth','hub'].forEach(id=>$(id).addEventListener('input',applyFilters));
171
- $('verb').addEventListener('change',applyLabels);
172
- $('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
173
- $('fit').addEventListener('click',()=>cy.fit());
174
- cy.on('tap','node',e=>{const d=e.target.data();
175
- $('detail').innerHTML='<h3>'+d.label+'</h3>class: '+d.cls+'<br>depth: '+d.depth+' · degree: '+d.degree+
176
- (d.site?'<br>site: <code>'+d.site+'</code>':'')+'<br>id: <code>'+d.id+'</code>';});
177
- applyFilters();applyLabels();
282
+ const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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=&lt;url&gt;, 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
- if (!values.focus) {
195
- process.stderr.write('seonix viz: --focus <symbol> is required (e.g. --focus Truncator --depth 2)\n');
196
- process.exit(2);
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 = loadConfig(values.graph ? { ...process.env, SEONIX_GRAPH_FILE: resolve(values.graph) } : process.env);
199
- const graph = parseEntities(await source.fetchEntities(config));
200
- const { match } = resolveSymbol(graph, values.focus);
201
- if (!match) {
202
- process.stderr.write(`seonix viz: no entity matching "${values.focus}" in ${config.graphFile}\n`);
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: match.id,
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 html = renderHtml({ subgraph, focusLabel: match.label, cytoscape: await cytoscapeSource() });
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
- await writeFile(out, html);
214
- process.stderr.write(
215
- `seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${match.label}" ` +
216
- `(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} ${out}\n`,
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
  }