@polycode-projects/seonix 0.1.0 → 0.2.0

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