@polycode-projects/seonix 0.2.0 → 0.3.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.
@@ -0,0 +1,117 @@
1
+ // timeline.mjs — render the repo's commit timeline to a single, portable HTML
2
+ // page from a seonix graph artifact. Third sibling of the viz viewer and the
3
+ // Chronograph code browser: generated by `seonix viz` (--timeline-out, on by
4
+ // default next to --out) and served live at /timeline.html by `viz --serve`.
5
+ //
6
+ // Data comes straight from the graph artifact: Commit individuals carry the full
7
+ // sha (id), author, date, and message; each Module's `derived_from` lists the
8
+ // commits that touched it (`git:<short sha>`), which gives per-commit churn.
9
+ // Session individuals (chat sessions recorded by sessions.mjs) join the same
10
+ // timeline as `type:"session"` entries at their started timestamp — a graph with
11
+ // zero sessions renders exactly as before.
12
+
13
+ const esc = (s) =>
14
+ String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
15
+
16
+ export function extractTimeline(graph) {
17
+ const commits = [];
18
+ const sessions = [];
19
+ const churnByShort = new Map(); // short sha -> modules touched
20
+ for (const ind of graph.individuals || []) {
21
+ const attr = (key) => ind.attributes?.find((a) => a.key === key)?.value || "";
22
+ if (ind.class === "Module") {
23
+ for (const ref of ind.derived_from || []) {
24
+ const short = ref.replace(/^git:/, "");
25
+ churnByShort.set(short, (churnByShort.get(short) || 0) + 1);
26
+ }
27
+ } else if (ind.class === "Commit") {
28
+ commits.push({
29
+ sha: ind.id.replace(/^commit:/, ""),
30
+ short: ind.label,
31
+ date: attr("date"),
32
+ author: attr("author"),
33
+ message: attr("message"),
34
+ });
35
+ } else if (ind.class === "Session") {
36
+ // chat sessions (sessions.mjs) enter the timeline at their started timestamp
37
+ sessions.push({
38
+ type: "session",
39
+ id: ind.id.replace(/^session:/, ""),
40
+ short: ind.label,
41
+ date: attr("started"),
42
+ turns: Number(attr("turns")) || 0,
43
+ touched: 0,
44
+ });
45
+ }
46
+ }
47
+ for (const c of commits) c.touched = churnByShort.get(c.short) || 0;
48
+ const entries = [...commits, ...sessions];
49
+ entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
50
+ return entries;
51
+ }
52
+
53
+ /** `nav` is the shared {name: href} nav object the viz CLI computes from the
54
+ * actual sibling output paths — header links render ONLY from its entries, so
55
+ * a page generated without siblings never carries dead links. */
56
+ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", generatedAt = "", nav = null } = {}) {
57
+ const base = repoUrl.replace(/\/+$/, "");
58
+ const sessionCount = commits.filter((c) => c.type === "session").length;
59
+ const commitCount = commits.length - sessionCount;
60
+ const maxTouched = Math.max(1, ...commits.map((c) => c.touched));
61
+ // Bars run oldest→newest left to right; the list reads newest first. Chat
62
+ // sessions are the visually distinct entries (amber, fixed-height bars).
63
+ const bars = commits
64
+ .map((c, i) =>
65
+ c.type === "session"
66
+ ? `<a class="bar sess" href="#c${i}" title="chat session ${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${c.turns} turn(s)"><i style="height:10px"></i></a>`
67
+ : `<a class="bar" href="#c${i}" title="${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${esc(c.message)}"><i style="height:${Math.max(6, Math.round((c.touched / maxTouched) * 64))}px"></i></a>`,
68
+ )
69
+ .join("");
70
+ const rows = commits
71
+ .map((c, i) => {
72
+ if (c.type === "session") {
73
+ return `<li id="c${i}" class="sess"><span class="d">${esc(c.date.slice(0, 10))}</span> <code>${esc(c.short)}</code> <span class="m">chat session — ${c.turns} turn(s)</span></li>`;
74
+ }
75
+ const sha = base
76
+ ? `<a href="${base}/-/commit/${esc(c.sha)}" target="_blank" rel="noopener"><code>${esc(c.short)}</code></a>`
77
+ : `<code>${esc(c.short)}</code>`;
78
+ return `<li id="c${i}"><span class="d">${esc(c.date.slice(0, 10))}</span> ${sha} <span class="a">${esc(c.author)}</span> <span class="m">${esc(c.message)}</span> <span class="t" title="modules touched">${c.touched}⛁</span></li>`;
79
+ })
80
+ .reverse()
81
+ .join("\n");
82
+ const navLinks = nav
83
+ ? Object.entries(nav)
84
+ .map(([name, href]) => `<a href="${esc(href)}">${name === "home" ? "← home" : esc(name)}</a>`)
85
+ .join("\n ")
86
+ : "";
87
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
88
+ <title>seonix — commit timeline</title>
89
+ <style>
90
+ html,body{margin:0;font:14px/1.5 system-ui,sans-serif;background:#1a1b26;color:#c0caf5}
91
+ header{padding:14px 20px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}
92
+ header b{color:#7aa2f7} header a{color:#7aa2f7;text-decoration:none} header a:hover{text-decoration:underline}
93
+ header .meta{color:#565f89;font-size:12px;margin-left:auto}
94
+ #bars{display:flex;align-items:flex-end;gap:3px;padding:18px 20px 6px;overflow-x:auto}
95
+ .bar i{display:block;width:9px;background:#3d59a1;border-radius:2px 2px 0 0}
96
+ .bar:hover i{background:#7aa2f7}
97
+ ol{list-style:none;margin:6px 0 40px;padding:0 20px}
98
+ li{padding:6px 8px;border-bottom:1px solid #20222f;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
99
+ li:target{background:#20253a;border-radius:4px}
100
+ .d{color:#565f89;font-variant-numeric:tabular-nums;white-space:nowrap}
101
+ code{color:#9ece6a} li a{text-decoration:none} li a:hover code{text-decoration:underline}
102
+ .a{color:#a9b1d6;white-space:nowrap} .m{flex:1 1 24ch} .t{color:#bb9af7;white-space:nowrap}
103
+ .bar.sess i{background:#8a6a2f;border-radius:5px} .bar.sess:hover i{background:#e0af68}
104
+ li.sess{border-left:3px solid #e0af68;padding-left:5px} li.sess code,li.sess .m{color:#e0af68}
105
+ </style></head><body>
106
+ <header>
107
+ <span><b>seonix</b> commit timeline — ${commitCount} commits${sessionCount ? ` · ${sessionCount} chat session(s)` : ""}</span>
108
+ ${navLinks}
109
+ ${base ? `<a href="${base}" target="_blank" rel="noopener">repository ↗</a>` : ""}
110
+ <span class="meta">bars = modules touched per commit · generated from the seonix graph artifact${generatedAt ? ` (${esc(generatedAt.slice(0, 10))})` : ""}</span>
111
+ </header>
112
+ <div id="bars">${bars}</div>
113
+ <ol>
114
+ ${rows}
115
+ </ol>
116
+ </body></html>`;
117
+ }
package/src/viz.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import { readFile, writeFile } from "node:fs/promises";
18
18
  import { execFile } from "node:child_process";
19
19
  import { createServer } from "node:http";
20
+ import { createRequire } from "node:module";
20
21
  import { dirname, join, relative, resolve } from "node:path";
21
22
  import { fileURLToPath } from "node:url";
22
23
  import { parseArgs, promisify } from "node:util";
@@ -24,6 +25,7 @@ import { parseEntities, resolveSymbol, relationKind, siteOf } from "./codegraph.
24
25
  import { loadConfig } from "./config.mjs";
25
26
  import * as source from "./source.mjs";
26
27
  import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
28
+ import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
27
29
 
28
30
  const here = dirname(fileURLToPath(import.meta.url));
29
31
  const execFileP = promisify(execFile);
@@ -133,7 +135,7 @@ export function buildSubgraph(graph, { focusId, depth = 2, hubDegree = 40, maxNo
133
135
 
134
136
  /** The viewer's runtime payload — everything repo-specific lives here, nothing in
135
137
  * the page. Pure; schema is part of the viewer contract (tests pin it). */
136
- export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false } = {}) {
138
+ export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false, nav = null } = {}) {
137
139
  return {
138
140
  focusId: subgraph.focusId,
139
141
  focusLabel: focusLabel || subgraph.focusId,
@@ -141,22 +143,28 @@ export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef =
141
143
  maxDepth: subgraph.nodes.reduce((m, n) => Math.max(m, n.depth), 0),
142
144
  repoUrl: repoUrl.replace(/\/+$/, ""),
143
145
  repoRef,
144
- siteNav: !!siteNav,
146
+ siteNav: !!siteNav, // legacy flag — stale deployed data keeps its absolute links
147
+ ...(nav ? { nav } : {}), // {name: href} — the viewer rebuilds #sitenav from this
145
148
  nodes: subgraph.nodes,
146
149
  edges: subgraph.edges,
147
150
  };
148
151
  }
149
152
 
150
- /** Inline the Cytoscape dist so the HTML is one portable file (no sidecar, no CDN). */
151
- async function cytoscapeSource() {
152
- const candidates = [
153
- join(here, "..", "node_modules", "cytoscape", "dist", "cytoscape.min.js"),
154
- join(here, "..", "..", "..", "node_modules", "cytoscape", "dist", "cytoscape.min.js"),
155
- ];
156
- for (const p of candidates) {
157
- try { return await readFile(p, "utf8"); } catch { /* try next */ }
153
+ /** Inline the Cytoscape dist so the HTML is one portable file (no sidecar, no CDN).
154
+ * Resolved via Node's own module resolution (createRequire), not a hardcoded
155
+ * relative-path guess the guess only worked inside this monorepo's own dev
156
+ * layout (root-hoisted node_modules) and broke for a real npm-installed consumer,
157
+ * where cytoscape sits under the consumer's own node_modules, at a different
158
+ * depth from this file (`node_modules/@polycode-projects/seonix/src/…`). Node's
159
+ * resolution algorithm walks every parent node_modules correctly regardless of
160
+ * install topology (monorepo, flat install, nested install, pnpm symlinks). */
161
+ export async function cytoscapeSource() {
162
+ try {
163
+ const path = createRequire(import.meta.url).resolve("cytoscape/dist/cytoscape.min.js");
164
+ return await readFile(path, "utf8");
165
+ } catch {
166
+ throw new Error("cytoscape not installed — reinstall @polycode-projects/seonix (cytoscape is a declared dependency)");
158
167
  }
159
- throw new Error("cytoscape not installed — run `npm install` in packages/seonix");
160
168
  }
161
169
 
162
170
  /** The mechanical (zero-model-call) chat panel's engine, inlined into the viewer
@@ -213,10 +221,10 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
213
221
  #bar label,.lbl{color:#a9b1d6} #bar b{color:#7aa2f7}
214
222
  #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
223
  #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}
224
+ #bar button:disabled{opacity:.4;cursor:default}
225
+ #bar button:disabled:hover{border-color:#2a2e42;color:#c0caf5}
226
+ #depthval{min-width:1.2em;text-align:center;display:inline-block;cursor:help}
227
+ #hub,#beam{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
220
228
  #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
229
  #main{flex:1 1 auto;position:relative;min-height:0}
222
230
  #cy{position:absolute;top:0;left:0;right:300px;bottom:0}
@@ -245,14 +253,16 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
245
253
  </style></head><body>
246
254
  <div id="bar">
247
255
  <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>
256
+ <span class="grp"><span class="lbl">depth</span><button id="depthdown" title="shallower">−</button><b id="depthval"></b><button id="depthup" title="deeper">+</button></span>
257
+ <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>
258
+ <label title="beam-prune from the focus: at each hop keep only the top-N neighbours by degree (margin+cap prune) — a degree-scored analogue of the ask engine's beam search, NOT the same scorer"><input type="checkbox" id="beamon"> beam <input id="beam" type="number" min="1" max="32" value="8"></label></span>
250
259
  <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
260
  <label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label></span>
252
261
  <span class="grp" id="view">
253
- <button id="reset" title="fit the whole graph in view (Esc)">fit</button>
262
+ <button id="fit" title="fit the whole graph in view (Esc)">fit</button>
254
263
  <button id="zoomout" title="zoom out">−</button>
255
264
  <button id="zoomin" title="zoom in">+</button>
265
+ <button id="reset" title="restore the boot view: default filters, all types shown, original focus">reset</button>
256
266
  </span>
257
267
  <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
268
  </div>
@@ -315,19 +325,27 @@ function loadAskData(){
315
325
  function init(G){
316
326
  document.title='seon code-map — '+G.focusLabel;
317
327
  $('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>')
328
+ // nav rides the data payload ({name: href}, hrefs relative to THIS page);
329
+ // G.siteNav is the legacy flag stale deployed data keeps its absolute links.
330
+ if(G.nav){$('sitenav').innerHTML=Object.entries(G.nav).map(([k,h])=>'<a href="'+esc(h)+'">'+esc(k)+'</a>').join('');$('sitenav').hidden=false;}
331
+ else if(G.siteNav)$('sitenav').hidden=false;
332
+ // Legend doubles as a node-type filter: a checkbox chip per type GROUP —
333
+ // Function/Method share a chip (legend-only merge; node fills stay distinct),
334
+ // with a live visible/loaded count summed across the group.
335
+ const GROUPS=[['Module'],['Class'],['Function','Method'],['Attribute'],['GlobalVariable'],['Commit']];
336
+ $('legend').innerHTML=GROUPS
337
+ .map(g=>'<label class="lg" title="show/hide '+g.join('/')+' nodes"><input type="checkbox" class="typechk" data-cls="'+g.join(',')+'" checked>'+g.map(k=>'<i style="background:'+COLORS[k]+'"></i>').join('')+g.join('/')+'<span class="cnt" data-cnt="'+g.join(',')+'"></span></label>')
324
338
  .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('');
339
+ // depth is a [N] + stepper clamped to the DATA's max depth (no dead buttons), default 2
340
+ const maxDepth=Math.max(1,G.maxDepth);
341
+ let depthVal=Math.min(2,maxDepth);
342
+ $('depthval').title='captured to depth '+maxDepth+' — regenerate with --depth for more';
343
+ function syncDepthUi(){$('depthval').textContent=depthVal;$('depthdown').disabled=depthVal<=1;$('depthup').disabled=depthVal>=maxDepth;}
344
+ syncDepthUi();
345
+ const INITIAL_FOCUS=G.focusId;
329
346
  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}}))],
347
+ // depth0 caches the boot depth per node BEFORE any recentre mutates data('depth') — the reset button restores from it
348
+ elements:[...G.nodes.map(n=>({data:{...n,depth0:n.depth,color:COLORS[n.cls]||'#8a8f98'}})),...G.edges.map((e,i)=>({data:{id:'e'+i,...e}}))],
331
349
  style:[
332
350
  // black text outline keeps white labels readable over node fills and the dark canvas;
333
351
  // node size encodes degree (bounded) so hubs read as hubs at a glance.
@@ -349,26 +367,53 @@ function init(G){
349
367
  let selId=null;
350
368
  function enabledTypes(){
351
369
  const on=new Set();
352
- document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)on.add(c.dataset.cls);});
370
+ document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)c.dataset.cls.split(',').forEach(t=>on.add(t));});
353
371
  return on;
354
372
  }
355
- function curDepth(){return +document.querySelector('.segb.on').dataset.d;}
373
+ function curDepth(){return depthVal;}
356
374
  function curHub(){return $('hubon').checked ? +$('hub').value : Infinity;}
375
+ const NODE=new Map(G.nodes.map(n=>[n.id,n]));
376
+ // Beam mode: BFS from the focus over the loaded adjacency, pruning each node's
377
+ // candidate neighbours (those passing hub+type filters) to the top-width by
378
+ // DEGREE with margin deg >= best*0.5 — mirrors BEAM_MARGIN_FRAC=0.5 in
379
+ // codegraph.mjs, but degree-scored: an analogue of the ask engine's beam
380
+ // search, not the same scorer.
381
+ function beamSet(width,h,types){
382
+ const pass=id=>{const n=NODE.get(id);return !!n&&(id===G.focusId||n.degree<=h)&&types.has(n.cls);};
383
+ const seen=new Set([G.focusId]);
384
+ let frontier=[G.focusId];
385
+ while(frontier.length){
386
+ const next=[];
387
+ for(const id of frontier){
388
+ const cand=(ADJ[id]||[]).filter(nb=>!seen.has(nb)&&pass(nb))
389
+ .sort((a,b)=>NODE.get(b).degree-NODE.get(a).degree);
390
+ const best=cand.length?NODE.get(cand[0]).degree:0;
391
+ for(const nb of cand.slice(0,width)){
392
+ if(NODE.get(nb).degree>=best*0.5){seen.add(nb);next.push(nb);}
393
+ }
394
+ }
395
+ frontier=next;
396
+ }
397
+ return seen;
398
+ }
357
399
  function applyFilters(){
358
400
  const d=curDepth(), h=curHub(), types=enabledTypes();
401
+ const beam=$('beamon').checked?beamSet(Math.max(1,Math.min(32,+$('beam').value||8)),h,types):null;
359
402
  const vis={}, tot={};
360
403
  cy.batch(()=>cy.nodes().forEach(n=>{
361
404
  const cls=n.data('cls');
362
405
  tot[cls]=(tot[cls]||0)+1;
363
- const v = n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
406
+ const v = beam ? beam.has(n.id())
407
+ : n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
364
408
  if(v) vis[cls]=(vis[cls]||0)+1;
365
409
  n.style('display', v?'element':'none');
366
410
  }));
367
- // live per-type counts: visible/loaded under the current depth + hub filters
411
+ // live per-group counts: visible/loaded summed across the group's classes
368
412
  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:'');
413
+ const ks=s.dataset.cnt.split(',');
414
+ const v=ks.reduce((a,k)=>a+(vis[k]||0),0), t=ks.reduce((a,k)=>a+(tot[k]||0),0);
415
+ s.textContent=v+'/'+t;
416
+ s.title=v+' visible of '+t+' loaded '+ks.join('/')+' node(s) at depth '+d+(isFinite(h)?', hide deg>'+h:'');
372
417
  });
373
418
  applyLabels();
374
419
  }
@@ -391,18 +436,39 @@ function init(G){
391
436
  }
392
437
  cy.on('mouseover','node',e=>{if($('verb').value==='smart')e.target.style('label',labelText(e.target,false));});
393
438
  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();}));
439
+ const stepDepth=dir=>{const d=Math.min(maxDepth,Math.max(1,depthVal+dir));if(d===depthVal)return;depthVal=d;syncDepthUi();applyFilters();};
440
+ $('depthdown').addEventListener('click',()=>stepDepth(-1));
441
+ $('depthup').addEventListener('click',()=>stepDepth(1));
396
442
  $('hubon').addEventListener('change',applyFilters);
397
443
  $('hub').addEventListener('input',applyFilters);
444
+ $('beamon').addEventListener('change',applyFilters);
445
+ $('beam').addEventListener('input',applyFilters);
398
446
  document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',applyFilters));
399
447
  $('verb').addEventListener('change',applyLabels);
400
448
  $('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
401
449
  const ZF=1.2;
402
450
  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);});
451
+ $('fit').addEventListener('click',()=>{cy.fit(undefined,30);});
404
452
  $('zoomin').addEventListener('click',()=>zoomBy(ZF));
405
453
  $('zoomout').addEventListener('click',()=>zoomBy(1/ZF));
454
+ // real reset: back to the boot view — defaults, all types on, depths from
455
+ // depth0 (the pre-recentre capture), focus ring on the boot focus.
456
+ $('reset').addEventListener('click',()=>{
457
+ if($('layout').value!=='cose'){$('layout').value='cose';cy.layout({name:'cose',animate:false}).run();}
458
+ $('verb').value='smart';
459
+ $('hubon').checked=true;$('hub').value=16;
460
+ $('beamon').checked=false;$('beam').value=8;
461
+ depthVal=Math.min(2,maxDepth);syncDepthUi();
462
+ document.querySelectorAll('.typechk').forEach(c=>{c.checked=true;});
463
+ cy.getElementById(G.focusId).removeClass('focus');
464
+ G.focusId=INITIAL_FOCUS;
465
+ cy.batch(()=>cy.nodes().forEach(n=>n.data('depth',n.data('depth0'))));
466
+ cy.getElementById(INITIAL_FOCUS).addClass('focus');
467
+ $('focuslabel').textContent=G.focusLabel;
468
+ applyFilters();
469
+ select(null);
470
+ cy.fit(undefined,30);
471
+ });
406
472
  // Deep link into the source host (GitLab URL layout) when the data carries a
407
473
  // repoUrl: Commit nodes -> /-/commit/<sha>, anything with a file site ->
408
474
  // /-/blob/<ref>/<path>#L<lines>, bare Modules -> the file blob.
@@ -586,6 +652,8 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
586
652
  const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
587
653
  const viewer = renderViewerHtml({ cytoscape, askEngine });
588
654
  const browserPage = await renderBrowserHtml({ cytoscape });
655
+ // nav = the routes this server itself serves; injected into every payload/page
656
+ const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
589
657
  const buildData = async () => {
590
658
  const { graph } = await loadGraph(values);
591
659
  const focus = resolveFocus(graph, values.focus);
@@ -596,7 +664,7 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
596
664
  hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
597
665
  maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
598
666
  });
599
- return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref });
667
+ return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref, nav });
600
668
  };
601
669
  // The chat panel's own channel — the FULL raw graph (not the depth-limited display
602
670
  // sub-graph above), rebuilt fresh per request so a re-index shows up without a
@@ -614,7 +682,17 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
614
682
  gitCommitParents(process.cwd()), // P3 ghost-branch merges
615
683
  ]);
616
684
  const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
617
- return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()) });
685
+ return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()), nav });
686
+ };
687
+ const buildTimelinePage = async () => {
688
+ const { raw } = await loadGraph(values);
689
+ return renderTimelineHtml({
690
+ commits: extractTimeline(raw),
691
+ repoUrl,
692
+ repoRef: values.ref,
693
+ generatedAt: raw.generated_at || "",
694
+ nav,
695
+ });
618
696
  };
619
697
  await buildData(); // fail fast (missing graph, bad focus) before binding the port
620
698
  const server = createServer(async (req, res) => {
@@ -625,6 +703,16 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
625
703
  } else if (path === "/browser" || path === "/code-browser.html") {
626
704
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
627
705
  res.end(browserPage);
706
+ } else if (path === "/timeline" || path === "/timeline.html") {
707
+ // rendered from a fresh graph load, same re-index-shows-on-refresh invariant
708
+ try {
709
+ const page = await buildTimelinePage();
710
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
711
+ res.end(page);
712
+ } catch (err) {
713
+ res.writeHead(500, { "content-type": "text/plain" });
714
+ res.end(`failed to render the timeline: ${String(err.message || err)}`);
715
+ }
628
716
  } else if (path === "/seonix-graph-data.json") {
629
717
  try {
630
718
  res.writeHead(200, { "content-type": "application/json" });
@@ -687,9 +775,12 @@ export async function runVizCli(argv) {
687
775
  "site-nav": { type: "boolean", default: false },
688
776
  serve: { type: "boolean", default: false },
689
777
  port: { type: "string", default: "0" },
690
- // Chronograph (code browser) artifacts — see src/browser.mjs
778
+ // Chronograph (code browser) + timeline artifacts — generated next to --out
779
+ // by default; --graph-only suppresses both. See src/browser.mjs, src/timeline.mjs.
691
780
  "browser-out": { type: "string" },
692
781
  "browser-data-out": { type: "string" },
782
+ "timeline-out": { type: "string" },
783
+ "graph-only": { type: "boolean", default: false },
693
784
  scope: { type: "string", default: "product" },
694
785
  },
695
786
  allowPositionals: false,
@@ -715,13 +806,30 @@ export async function runVizCli(argv) {
715
806
  hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
716
807
  maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
717
808
  });
809
+ const out = resolve(values.out);
810
+ // Siblings by default: the code browser and the commit timeline land next to
811
+ // --out unless --graph-only. Nav hrefs are computed from the ACTUAL output
812
+ // paths (filenames vary: seon-graph.html locally, seonix-graph.html on site);
813
+ // --site-nav additionally adds the site's absolute home entry.
814
+ const browserOut = values["graph-only"] ? null : resolve(values["browser-out"] || join(dirname(out), "code-browser.html"));
815
+ const timelineOut = values["graph-only"] ? null : resolve(values["timeline-out"] || join(dirname(out), "timeline.html"));
816
+ const relHref = (from, to) => {
817
+ const r = relative(dirname(from), to);
818
+ return r.startsWith(".") ? r : `./${r}`;
819
+ };
820
+ const navFor = (from) => ({
821
+ ...(values["site-nav"] ? { home: "/" } : {}),
822
+ graph: relHref(from, out),
823
+ browser: relHref(from, browserOut),
824
+ timeline: relHref(from, timelineOut),
825
+ });
718
826
  const data = buildViewerData(subgraph, {
719
827
  focusLabel: focus.label,
720
828
  repoUrl: values["repo-url"],
721
829
  repoRef: values.ref,
722
830
  siteNav: values["site-nav"],
831
+ nav: values["graph-only"] ? null : navFor(out),
723
832
  });
724
- const out = resolve(values.out);
725
833
  if (values["data-out"]) {
726
834
  // site mode: viewer page + sibling data file (data updates don't touch the page).
727
835
  // The chat panel's full-graph channel rides the same sibling-file convention,
@@ -749,36 +857,55 @@ export async function runVizCli(argv) {
749
857
  );
750
858
  }
751
859
 
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
- );
860
+ // Sibling artifacts (code browser + commit timeline) — best-effort: a missing
861
+ // git history (or any other failure here) warns but never kills the graph
862
+ // artifact already written above.
863
+ if (browserOut || timelineOut) {
864
+ try {
865
+ const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
866
+ if (browserOut) {
867
+ const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
868
+ const [order, parentsBySha] = await Promise.all([
869
+ gitCommitOrder(process.cwd(), commitIds),
870
+ gitCommitParents(process.cwd()), // P3 ghost-branch merges — static data, no polling here
871
+ ]);
872
+ const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
873
+ const browserData = buildBrowserData(tg, {
874
+ repoUrl,
875
+ repoRef: values.ref,
876
+ siteNav: values["site-nav"],
877
+ nav: navFor(browserOut),
878
+ });
879
+ if (values["browser-data-out"]) {
880
+ const bDataOut = resolve(values["browser-data-out"]);
881
+ const rel = relative(dirname(browserOut), bDataOut) || "code-browser-data.json";
882
+ await writeFile(bDataOut, JSON.stringify(browserData));
883
+ await writeFile(browserOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
884
+ process.stderr.write(
885
+ `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
886
+ `(scope ${values.scope}) → ${browserOut} + ${bDataOut}\n`,
887
+ );
888
+ } else {
889
+ await writeFile(browserOut, await renderBrowserHtml({ cytoscape, embedData: browserData }));
890
+ process.stderr.write(
891
+ `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
892
+ `(scope ${values.scope}) → ${browserOut}\n`,
893
+ );
894
+ }
895
+ }
896
+ if (timelineOut) {
897
+ const commits = extractTimeline(raw);
898
+ await writeFile(timelineOut, renderTimelineHtml({
899
+ commits,
900
+ repoUrl,
901
+ repoRef: values.ref,
902
+ generatedAt: raw.generated_at || "",
903
+ nav: navFor(timelineOut),
904
+ }));
905
+ process.stderr.write(`seonix viz: timeline ${commits.length} commits → ${timelineOut}\n`);
906
+ }
907
+ } catch (err) {
908
+ process.stderr.write(`seonix viz: warning — sibling pages (browser/timeline) failed: ${String(err.message || err)}; the graph artifact is intact\n`);
782
909
  }
783
910
  }
784
911
  }