@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.
- package/README.md +132 -23
- package/bin/cli.mjs +71 -17
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +172 -3
- package/src/ask.mjs +703 -79
- package/src/browser.mjs +12 -6
- package/src/chat.mjs +188 -0
- package/src/codegraph.mjs +159 -4
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/prose-nlp.mjs +52 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/timeline.mjs +117 -0
- package/src/viz.mjs +200 -73
package/src/timeline.mjs
ADDED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
.
|
|
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><
|
|
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> <input id="hub" type="number" min="2" max="200" value="16"></label
|
|
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> <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="
|
|
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
|
-
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
326
|
-
const maxDepth=Math.max(
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
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-
|
|
411
|
+
// live per-group counts: visible/loaded summed across the group's classes
|
|
368
412
|
document.querySelectorAll('.cnt').forEach(s=>{
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
s.
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
$('
|
|
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 —
|
|
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
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
}
|