@msareen/knowledge-hub-builder 0.1.3
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/.agents/skills/catalog/SKILL.md +7 -0
- package/.agents/skills/export/SKILL.md +7 -0
- package/.agents/skills/ingest/SKILL.md +7 -0
- package/.agents/skills/lint/SKILL.md +7 -0
- package/.agents/skills/new-bundle/SKILL.md +7 -0
- package/.agents/skills/query/SKILL.md +7 -0
- package/.agents/skills/visualize/SKILL.md +7 -0
- package/.bundle_template/index.md +9 -0
- package/.bundle_template/log.md +10 -0
- package/.bundle_template/raw/.gitkeep +15 -0
- package/.bundle_template/refs.md +6 -0
- package/.bundle_template/sources.yaml +13 -0
- package/.claude/skills/catalog/SKILL.md +7 -0
- package/.claude/skills/export/SKILL.md +7 -0
- package/.claude/skills/ingest/SKILL.md +7 -0
- package/.claude/skills/lint/SKILL.md +7 -0
- package/.claude/skills/new-bundle/SKILL.md +7 -0
- package/.claude/skills/query/SKILL.md +7 -0
- package/.claude/skills/visualize/SKILL.md +7 -0
- package/AGENTS.md +167 -0
- package/CLAUDE.md +13 -0
- package/README.md +289 -0
- package/SPEC.md +354 -0
- package/document/faq.md +156 -0
- package/package.json +52 -0
- package/scripts/cli.ts +66 -0
- package/scripts/export.ts +42 -0
- package/scripts/ingest/acquire.ts +189 -0
- package/scripts/ingest/exts.ts +29 -0
- package/scripts/ingest/files.ts +29 -0
- package/scripts/ingest/folder.ts +44 -0
- package/scripts/ingest/index.ts +125 -0
- package/scripts/ingest/protect.ts +42 -0
- package/scripts/ingest/web.ts +54 -0
- package/scripts/init.ts +93 -0
- package/scripts/lib/args.ts +8 -0
- package/scripts/lib/extract.ts +384 -0
- package/scripts/lib/graph-page.ts +477 -0
- package/scripts/lib/graph.ts +117 -0
- package/scripts/lib/ledger.ts +136 -0
- package/scripts/lib/log.ts +53 -0
- package/scripts/lib/paths.ts +55 -0
- package/scripts/lib/scaffold.ts +56 -0
- package/scripts/lib/util.ts +154 -0
- package/scripts/lint.ts +165 -0
- package/scripts/new-bundle.ts +17 -0
- package/scripts/visualize.ts +104 -0
- package/skills/catalog/SKILL.md +164 -0
- package/skills/export/SKILL.md +31 -0
- package/skills/ingest/SKILL.md +229 -0
- package/skills/lint/SKILL.md +69 -0
- package/skills/new-bundle/SKILL.md +25 -0
- package/skills/query/SKILL.md +114 -0
- package/skills/visualize/SKILL.md +33 -0
- package/templates/hub/gitattributes +12 -0
- package/templates/hub/gitignore +12 -0
- package/templates/hub/outer.index.md +13 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// Renders the interactive graph UI served by `khb visualize`. Two zoom levels: bundles
|
|
2
|
+
// (outer graph, refs.md edges) and, after clicking a bundle, its concepts (inner graph,
|
|
3
|
+
// markdown-link edges). Clicking a node opens a panel with its full title, path and body.
|
|
4
|
+
//
|
|
5
|
+
// The inner graph is *clustered*: every concept is pinned to the region of its top-level
|
|
6
|
+
// subdirectory (tables/, notes/, …) so the bundle's own organisation is the visible
|
|
7
|
+
// structure. The canvas is pan/zoomable and the layout is pre-settled before first paint,
|
|
8
|
+
// so the view opens on a stable picture rather than an exploding one.
|
|
9
|
+
//
|
|
10
|
+
// Labels are drawn in *screen* space, not world space: they stay legible at any zoom, and
|
|
11
|
+
// any label whose box would collide with one already drawn is dropped. Node text is
|
|
12
|
+
// therefore always sparse and readable — the full title and path live in the click panel.
|
|
13
|
+
import type { GraphData } from "./graph";
|
|
14
|
+
|
|
15
|
+
/** Escape `</script>` so injected JSON can't terminate the surrounding <script> tag. */
|
|
16
|
+
const safeJSON = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
17
|
+
|
|
18
|
+
export function renderGraphPage(data: GraphData): string {
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<meta charset="utf-8"><title>KHB — bundle graph</title>
|
|
21
|
+
<style>
|
|
22
|
+
:root{
|
|
23
|
+
--bg:#111318; --fg:#e6e8ee; --dim:#8a91a3; --line:#2b303b;
|
|
24
|
+
--panel:#181b22; --accent:#6ee7d5;
|
|
25
|
+
}
|
|
26
|
+
*{box-sizing:border-box}
|
|
27
|
+
body{margin:0;font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif;
|
|
28
|
+
background:var(--bg);color:var(--fg);overflow:hidden}
|
|
29
|
+
canvas{display:block;touch-action:none}
|
|
30
|
+
|
|
31
|
+
/* top bar --------------------------------------------------------------- */
|
|
32
|
+
#bar{position:fixed;top:0;left:0;right:0;z-index:4;display:flex;align-items:center;
|
|
33
|
+
gap:12px;padding:9px 14px;background:color-mix(in srgb,var(--bg) 82%,transparent);
|
|
34
|
+
backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
|
|
35
|
+
#bar .brand{font-weight:650;letter-spacing:.04em;color:var(--accent)}
|
|
36
|
+
#bar .stat{color:var(--dim);font-size:12.5px}
|
|
37
|
+
#bar .spacer{flex:1}
|
|
38
|
+
.btn{background:transparent;color:var(--fg);border:1px solid var(--line);
|
|
39
|
+
border-radius:6px;padding:4px 10px;cursor:pointer;font:12.5px system-ui}
|
|
40
|
+
.btn:hover{border-color:var(--accent);color:var(--accent)}
|
|
41
|
+
.btn:disabled{opacity:.4;cursor:default}
|
|
42
|
+
#back{display:none}
|
|
43
|
+
#back.on{display:inline-block}
|
|
44
|
+
|
|
45
|
+
/* concept panel --------------------------------------------------------- */
|
|
46
|
+
#panel{position:fixed;top:0;right:0;width:min(460px,42vw);height:100%;
|
|
47
|
+
background:var(--panel);border-left:1px solid var(--line);transform:translateX(101%);
|
|
48
|
+
transition:transform .16s ease;z-index:5;display:flex;flex-direction:column}
|
|
49
|
+
#panel.open{transform:translateX(0)}
|
|
50
|
+
#panel header{padding:14px 16px;border-bottom:1px solid var(--line);display:flex;
|
|
51
|
+
justify-content:space-between;align-items:start;gap:10px}
|
|
52
|
+
#panel h2{font-size:15.5px;margin:0 0 5px;overflow-wrap:anywhere}
|
|
53
|
+
#panel .meta{color:var(--dim);font-size:12px;overflow-wrap:anywhere}
|
|
54
|
+
#panel .chip{display:inline-block;margin-top:7px;padding:2px 8px;border-radius:99px;
|
|
55
|
+
font-size:11.5px;border:1px solid currentColor}
|
|
56
|
+
#panel button{background:none;border:none;color:var(--dim);font-size:18px;cursor:pointer}
|
|
57
|
+
#panel button:hover{color:var(--fg)}
|
|
58
|
+
#panel pre{margin:0;padding:16px;overflow:auto;white-space:pre-wrap;word-break:break-word;
|
|
59
|
+
font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--fg);flex:1}
|
|
60
|
+
</style>
|
|
61
|
+
|
|
62
|
+
<div id="bar">
|
|
63
|
+
<span class="brand">KHB</span>
|
|
64
|
+
<button class="btn" id="back">← all bundles</button>
|
|
65
|
+
<span class="stat" id="stat"></span>
|
|
66
|
+
<span class="spacer"></span>
|
|
67
|
+
<button class="btn" id="fit" title="Fit to view (F)">⤢ fit</button>
|
|
68
|
+
<button class="btn" id="theme" title="Toggle light / dark">☾</button>
|
|
69
|
+
<button class="btn" id="refresh" title="Rescan the hub">↻</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div id="panel">
|
|
72
|
+
<header>
|
|
73
|
+
<div><h2 id="pTitle"></h2><div class="meta" id="pMeta"></div><span class="chip" id="pChip"></span></div>
|
|
74
|
+
<button id="pClose">✕</button>
|
|
75
|
+
</header>
|
|
76
|
+
<pre id="pBody">loading…</pre>
|
|
77
|
+
</div>
|
|
78
|
+
<canvas id="c"></canvas>
|
|
79
|
+
|
|
80
|
+
<script>
|
|
81
|
+
let DATA = ${safeJSON(data)};
|
|
82
|
+
const cv = document.getElementById('c'), cx = cv.getContext('2d');
|
|
83
|
+
const $ = id => document.getElementById(id);
|
|
84
|
+
let W, H, DPR = 1;
|
|
85
|
+
function rs(){
|
|
86
|
+
DPR = Math.min(devicePixelRatio || 1, 2);
|
|
87
|
+
W = innerWidth; H = innerHeight;
|
|
88
|
+
cv.width = W * DPR; cv.height = H * DPR;
|
|
89
|
+
cv.style.width = W + 'px'; cv.style.height = H + 'px';
|
|
90
|
+
}
|
|
91
|
+
rs();
|
|
92
|
+
onresize = rs; // pan/zoom means the layout no longer depends on viewport size
|
|
93
|
+
|
|
94
|
+
/* ---- themes: two, because a picker with four was one option too many ---------- */
|
|
95
|
+
const THEMES = {
|
|
96
|
+
dark: {bg:'#111318',fg:'#e6e8ee',dim:'#8a91a3',line:'#2b303b',panel:'#181b22',
|
|
97
|
+
accent:'#6ee7d5',edge:'#59606f',hull:'rgba(255,255,255,.045)',hullLine:'rgba(255,255,255,.12)'},
|
|
98
|
+
light: {bg:'#f6f7f9',fg:'#1c2028',dim:'#697082',line:'#dfe2e8',panel:'#ffffff',
|
|
99
|
+
accent:'#0d7d70',edge:'#aab1bd',hull:'rgba(20,30,60,.04)',hullLine:'rgba(20,30,60,.12)'},
|
|
100
|
+
};
|
|
101
|
+
let T = THEMES.dark;
|
|
102
|
+
function setTheme(name){
|
|
103
|
+
T = THEMES[name] || THEMES.dark;
|
|
104
|
+
const r = document.documentElement.style;
|
|
105
|
+
for (const k of ['bg','fg','dim','line','panel','accent']) r.setProperty('--' + k, T[k]);
|
|
106
|
+
document.documentElement.style.colorScheme = name;
|
|
107
|
+
$('theme').textContent = name === 'light' ? '☀' : '☾';
|
|
108
|
+
try { localStorage.setItem('khb-theme', name); } catch {}
|
|
109
|
+
}
|
|
110
|
+
let themeName = 'dark';
|
|
111
|
+
try { themeName = localStorage.getItem('khb-theme') === 'light' ? 'light' : 'dark'; } catch {}
|
|
112
|
+
setTheme(themeName);
|
|
113
|
+
$('theme').onclick = () => setTheme(themeName = themeName === 'dark' ? 'light' : 'dark');
|
|
114
|
+
|
|
115
|
+
/* ---- type encoding: colour only. No legend, no shape vocabulary to learn — the
|
|
116
|
+
type is spelled out in the hover strip and the panel. ------------------------- */
|
|
117
|
+
const PALETTE = ['#4f9cf9','#e8883b','#28a97f','#a173e0','#e05c5c','#d7b13a','#3ec1d3','#8b93a8'];
|
|
118
|
+
let typeKeys = [];
|
|
119
|
+
const typeColor = t => PALETTE[Math.max(0, typeKeys.indexOf(t)) % PALETTE.length];
|
|
120
|
+
|
|
121
|
+
/* ---- state -------------------------------------------------------------------- */
|
|
122
|
+
let view = { level: 'bundles', bundle: null };
|
|
123
|
+
let N = [], E = [], idx = {}, clusters = [];
|
|
124
|
+
let drag = null, hover = null, down = null, panning = null;
|
|
125
|
+
let scale = 1, tx = 0, ty = 0;
|
|
126
|
+
const toWorld = (sx, sy) => ({ x: (sx - tx) / scale, y: (sy - ty) / scale });
|
|
127
|
+
|
|
128
|
+
function build(){
|
|
129
|
+
let nodes, edges;
|
|
130
|
+
if (view.level === 'bundles') {
|
|
131
|
+
typeKeys = [];
|
|
132
|
+
nodes = DATA.bundles.map(n => ({ id: n.id, label: n.id, note: n.scope || '',
|
|
133
|
+
group: '', type: '', size: n.notes, kind: 'bundle', badge: n.notes + ' concepts' }));
|
|
134
|
+
edges = DATA.bundleEdges.map(e => ({ from: e.from, to: e.to, why: e.why }));
|
|
135
|
+
} else {
|
|
136
|
+
const g = DATA.bundleGraphs[view.bundle] || { concepts: [], edges: [] };
|
|
137
|
+
typeKeys = [...new Set(g.concepts.map(c => c.type || 'Untyped'))].sort();
|
|
138
|
+
nodes = g.concepts.map(c => ({ id: c.id, label: c.title || c.id.split('/').pop(),
|
|
139
|
+
note: c.id, group: c.folder || '(root)', type: c.type || 'Untyped',
|
|
140
|
+
size: c.bytes, kind: 'concept', badge: '' }));
|
|
141
|
+
edges = g.edges.map(e => ({ from: e.from, to: e.to, why: '' }));
|
|
142
|
+
}
|
|
143
|
+
N = nodes.map(n => ({ ...n, x: 0, y: 0, vx: 0, vy: 0,
|
|
144
|
+
r: n.kind === 'bundle' ? 16 + Math.sqrt(Math.max(n.size, 0)) * 3.2
|
|
145
|
+
: 7 + Math.min(Math.sqrt(Math.max(n.size, 1) / 300) * 3, 9) }));
|
|
146
|
+
idx = Object.fromEntries(N.map((n, i) => [n.id, i]));
|
|
147
|
+
E = edges.filter(e => idx[e.from] !== undefined && idx[e.to] !== undefined)
|
|
148
|
+
.map(e => ({ a: idx[e.from], b: idx[e.to], why: e.why }));
|
|
149
|
+
drag = hover = null;
|
|
150
|
+
place();
|
|
151
|
+
for (let i = 0; i < 260; i++) step(); // settle before first paint, then frame it
|
|
152
|
+
fitView();
|
|
153
|
+
renderChrome();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Seat every node: bundles on a ring, concepts around their folder's cluster centre.
|
|
157
|
+
* World coordinates are viewport-independent — fitView() frames whatever comes out. */
|
|
158
|
+
function place(){
|
|
159
|
+
if (view.level === 'bundles') {
|
|
160
|
+
clusters = [];
|
|
161
|
+
// Ring just big enough to seat the nodes side by side: the hub opens as one compact
|
|
162
|
+
// cluster you can read at a glance, and zoom handles the detail.
|
|
163
|
+
const circ = N.reduce((s, n) => s + n.r * 2 + 34, 0);
|
|
164
|
+
const R = N.length < 2 ? 0 : Math.max(70, circ / 6.2832);
|
|
165
|
+
N.forEach((n, i) => {
|
|
166
|
+
const a = i / Math.max(N.length, 1) * 6.2832 - Math.PI / 2;
|
|
167
|
+
n.x = Math.cos(a) * R; n.y = Math.sin(a) * R; n.vx = n.vy = 0;
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const names = [...new Set(N.map(n => n.group))].sort((a, b) =>
|
|
172
|
+
a === '(root)' ? -1 : b === '(root)' ? 1 : a.localeCompare(b));
|
|
173
|
+
const k = names.length;
|
|
174
|
+
// Each folder occupies a disc whose radius grows with its file count; the ring is then
|
|
175
|
+
// sized so neighbouring discs just clear each other. Sizing off a *global* constant is
|
|
176
|
+
// what used to fling two folders a screen and a half apart.
|
|
177
|
+
const rad = Object.fromEntries(names.map(nm =>
|
|
178
|
+
[nm, 55 + Math.sqrt(N.filter(n => n.group === nm).length) * 42]));
|
|
179
|
+
const rs_ = names.map(nm => rad[nm]);
|
|
180
|
+
const R = k === 1 ? 0
|
|
181
|
+
: k === 2 ? (rs_[0] + rs_[1] + 70) / 2
|
|
182
|
+
: Math.max(rs_.reduce((s, r) => s + r * 2 + 70, 0) / 6.2832, Math.max(...rs_) + 50);
|
|
183
|
+
// Laid out on an ellipse, not a circle: screens are wider than they are tall, and two
|
|
184
|
+
// folders stacked vertically is the one arrangement that never fits.
|
|
185
|
+
clusters = names.map((name, i) => {
|
|
186
|
+
const a = i / k * 6.2832 + (k === 2 ? 0 : -Math.PI / 2);
|
|
187
|
+
return { name, x: Math.cos(a) * R * 1.3, y: Math.sin(a) * R * 0.82 };
|
|
188
|
+
});
|
|
189
|
+
const home = Object.fromEntries(clusters.map(c => [c.name, c]));
|
|
190
|
+
N.forEach((n, i) => {
|
|
191
|
+
n.home = home[n.group];
|
|
192
|
+
const a = i * 2.399; // golden-angle scatter so nodes don't start stacked
|
|
193
|
+
n.x = n.home.x + Math.cos(a) * 26 * Math.sqrt(i % 12 + 1);
|
|
194
|
+
n.y = n.home.y + Math.sin(a) * 26 * Math.sqrt(i % 12 + 1);
|
|
195
|
+
n.vx = n.vy = 0;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Frame the whole graph with a floor on the zoom, so a small hub opens zoomed in
|
|
200
|
+
* rather than as three dots in the middle of an empty canvas. */
|
|
201
|
+
function fitView(){
|
|
202
|
+
if (!N.length) { scale = 1; tx = W / 2; ty = H / 2; return; }
|
|
203
|
+
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
|
|
204
|
+
for (const n of N) {
|
|
205
|
+
x0 = Math.min(x0, n.x - n.r); x1 = Math.max(x1, n.x + n.r);
|
|
206
|
+
y0 = Math.min(y0, n.y - n.r); y1 = Math.max(y1, n.y + n.r);
|
|
207
|
+
}
|
|
208
|
+
const pad = 110, top = 52;
|
|
209
|
+
const s = Math.min((W - pad * 2) / Math.max(x1 - x0, 1),
|
|
210
|
+
(H - top - pad * 1.4) / Math.max(y1 - y0, 1));
|
|
211
|
+
// Only a ceiling, never a floor: a floor would leave a big graph cropped at the edges,
|
|
212
|
+
// which is worse than small. The outer ring is compact by design, so it may magnify more.
|
|
213
|
+
scale = Math.min(Math.max(s, 0.05), view.level === 'bundles' ? 2.4 : 1.7);
|
|
214
|
+
tx = W / 2 - (x0 + x1) / 2 * scale;
|
|
215
|
+
ty = (H + top) / 2 - (y0 + y1) / 2 * scale;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderChrome(){
|
|
219
|
+
$('back').classList.toggle('on', view.level !== 'bundles');
|
|
220
|
+
$('stat').textContent = view.level === 'bundles'
|
|
221
|
+
? DATA.bundles.length + ' bundles · ' + DATA.bundleEdges.length + ' refs · click one to open'
|
|
222
|
+
: view.bundle + ' · ' + N.length + ' concepts · ' + E.length + ' links';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ---- forces: cluster-anchored, so the layout settles instead of drifting ------- */
|
|
226
|
+
function step(){
|
|
227
|
+
const inner = view.level !== 'bundles';
|
|
228
|
+
// Outer forces are deliberately gentle and short-range: bundles stay a tight group
|
|
229
|
+
// instead of flinging themselves to the corners of an unbounded world.
|
|
230
|
+
const rep = inner ? 4200 : 2200, cap = inner ? 9 : 6, reach = inner ? 460 : 300;
|
|
231
|
+
for (let i = 0; i < N.length; i++) for (let j = i + 1; j < N.length; j++) {
|
|
232
|
+
const a = N[i], b = N[j];
|
|
233
|
+
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1;
|
|
234
|
+
if (d > reach) continue;
|
|
235
|
+
const f = Math.min(rep / (d * d), cap); dx /= d; dy /= d;
|
|
236
|
+
a.vx -= dx * f; a.vy -= dy * f; b.vx += dx * f; b.vy += dy * f;
|
|
237
|
+
}
|
|
238
|
+
const rest = inner ? 110 : 150;
|
|
239
|
+
for (const e of E) {
|
|
240
|
+
const a = N[e.a], b = N[e.b];
|
|
241
|
+
// links that leave a folder pull only weakly — clusters own the layout, not edges
|
|
242
|
+
const k = inner && a.group !== b.group ? 0.0006 : 0.003;
|
|
243
|
+
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1;
|
|
244
|
+
const f = (d - rest) * k;
|
|
245
|
+
a.vx += dx / d * f; a.vy += dy / d * f; b.vx -= dx / d * f; b.vy -= dy / d * f;
|
|
246
|
+
}
|
|
247
|
+
for (const n of N) {
|
|
248
|
+
if (inner) { n.vx += (n.home.x - n.x) * 0.012; n.vy += (n.home.y - n.y) * 0.012; }
|
|
249
|
+
else { n.vx += -n.x * 0.006; n.vy += -n.y * 0.006; }
|
|
250
|
+
if (n !== drag) { n.x += n.vx *= 0.82; n.y += n.vy *= 0.82; }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ---- folder hulls -------------------------------------------------------------- */
|
|
255
|
+
function hullOf(pts){
|
|
256
|
+
if (pts.length < 3) return pts;
|
|
257
|
+
const p = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
258
|
+
const cr = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
259
|
+
const lo = [], up = [];
|
|
260
|
+
for (const q of p) { while (lo.length >= 2 && cr(lo[lo.length-2], lo[lo.length-1], q) <= 0) lo.pop(); lo.push(q); }
|
|
261
|
+
for (let i = p.length - 1; i >= 0; i--) { const q = p[i];
|
|
262
|
+
while (up.length >= 2 && cr(up[up.length-2], up[up.length-1], q) <= 0) up.pop(); up.push(q); }
|
|
263
|
+
lo.pop(); up.pop(); return lo.concat(up);
|
|
264
|
+
}
|
|
265
|
+
/** Draws the hulls in world space and returns their captions for the screen-space pass. */
|
|
266
|
+
function drawHulls(){
|
|
267
|
+
const caps = [];
|
|
268
|
+
for (const c of clusters) {
|
|
269
|
+
const mem = N.filter(n => n.group === c.name);
|
|
270
|
+
if (!mem.length) continue;
|
|
271
|
+
const pad = 40;
|
|
272
|
+
let pts = hullOf(mem.map(n => ({ x: n.x, y: n.y })));
|
|
273
|
+
const gx = mem.reduce((s, n) => s + n.x, 0) / mem.length;
|
|
274
|
+
const gy = mem.reduce((s, n) => s + n.y, 0) / mem.length;
|
|
275
|
+
if (pts.length < 3) {
|
|
276
|
+
cx.beginPath(); cx.arc(gx, gy, (mem[0].r || 12) + pad, 0, 6.2832);
|
|
277
|
+
} else {
|
|
278
|
+
pts = pts.map(p => { const dx = p.x - gx, dy = p.y - gy, d = Math.hypot(dx, dy) || 1;
|
|
279
|
+
return { x: p.x + dx / d * pad, y: p.y + dy / d * pad }; });
|
|
280
|
+
cx.beginPath();
|
|
281
|
+
const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
|
282
|
+
const m0 = mid(pts[pts.length - 1], pts[0]);
|
|
283
|
+
cx.moveTo(m0.x, m0.y);
|
|
284
|
+
for (let i = 0; i < pts.length; i++) {
|
|
285
|
+
const nx = mid(pts[i], pts[(i + 1) % pts.length]);
|
|
286
|
+
cx.quadraticCurveTo(pts[i].x, pts[i].y, nx.x, nx.y);
|
|
287
|
+
}
|
|
288
|
+
cx.closePath();
|
|
289
|
+
}
|
|
290
|
+
cx.fillStyle = T.hull; cx.fill();
|
|
291
|
+
cx.strokeStyle = T.hullLine; cx.lineWidth = 1 / scale;
|
|
292
|
+
cx.setLineDash([5 / scale, 5 / scale]); cx.stroke(); cx.setLineDash([]);
|
|
293
|
+
const top = Math.min(...mem.map(n => n.y - n.r)) - pad - 6;
|
|
294
|
+
caps.push({ x: gx * scale + tx, y: top * scale + ty,
|
|
295
|
+
text: c.name.toUpperCase() + ' · ' + mem.length });
|
|
296
|
+
}
|
|
297
|
+
return caps;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ---- draw ---------------------------------------------------------------------- */
|
|
301
|
+
function draw(){
|
|
302
|
+
cx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
|
303
|
+
cx.clearRect(0, 0, W, H);
|
|
304
|
+
cx.setTransform(DPR * scale, 0, 0, DPR * scale, DPR * tx, DPR * ty);
|
|
305
|
+
|
|
306
|
+
const caps = view.level !== 'bundles' ? drawHulls() : [];
|
|
307
|
+
|
|
308
|
+
const lit = hover ? new Set([hover.id]) : null;
|
|
309
|
+
if (lit) for (const e of E) {
|
|
310
|
+
if (N[e.a] === hover) lit.add(N[e.b].id);
|
|
311
|
+
if (N[e.b] === hover) lit.add(N[e.a].id);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const e of E) {
|
|
315
|
+
const a = N[e.a], b = N[e.b];
|
|
316
|
+
const on = !lit || (lit.has(a.id) && lit.has(b.id));
|
|
317
|
+
cx.globalAlpha = on ? (lit ? .95 : .45) : .1;
|
|
318
|
+
cx.strokeStyle = on && lit ? T.accent : T.edge;
|
|
319
|
+
cx.lineWidth = (on && lit ? 1.8 : 1.1) / scale;
|
|
320
|
+
// gentle arc: two straight lines between the same pair would overlap
|
|
321
|
+
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
|
322
|
+
const ox = -(b.y - a.y) * .08, oy = (b.x - a.x) * .08;
|
|
323
|
+
cx.beginPath(); cx.moveTo(a.x, a.y); cx.quadraticCurveTo(mx + ox, my + oy, b.x, b.y); cx.stroke();
|
|
324
|
+
const ang = Math.atan2(b.y - (my + oy), b.x - (mx + ox));
|
|
325
|
+
const hx = b.x - Math.cos(ang) * (b.r + 2), hy = b.y - Math.sin(ang) * (b.r + 2);
|
|
326
|
+
const ah = 7 / scale;
|
|
327
|
+
cx.fillStyle = on && lit ? T.accent : T.edge;
|
|
328
|
+
cx.beginPath(); cx.moveTo(hx, hy);
|
|
329
|
+
cx.lineTo(hx - ah * Math.cos(ang - .38), hy - ah * Math.sin(ang - .38));
|
|
330
|
+
cx.lineTo(hx - ah * Math.cos(ang + .38), hy - ah * Math.sin(ang + .38));
|
|
331
|
+
cx.fill();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
for (const n of N) {
|
|
335
|
+
cx.globalAlpha = !lit || lit.has(n.id) ? 1 : .16;
|
|
336
|
+
const col = n.kind === 'bundle' ? T.accent : typeColor(n.type);
|
|
337
|
+
cx.beginPath(); cx.arc(n.x, n.y, n.r, 0, 6.2832);
|
|
338
|
+
cx.fillStyle = n === hover ? col : col + '33'; cx.fill();
|
|
339
|
+
cx.strokeStyle = col; cx.lineWidth = (n === hover ? 2 : 1.4) / scale; cx.stroke();
|
|
340
|
+
}
|
|
341
|
+
cx.globalAlpha = 1;
|
|
342
|
+
|
|
343
|
+
/* ---- labels: screen space, fixed size, collision-culled ---------------------- */
|
|
344
|
+
cx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
|
345
|
+
cx.textAlign = 'center';
|
|
346
|
+
const boxes = [];
|
|
347
|
+
const fits = (x, y, w, h) => {
|
|
348
|
+
for (const b of boxes) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return false;
|
|
349
|
+
boxes.push({ x, y, w, h }); return true;
|
|
350
|
+
};
|
|
351
|
+
cx.font = '600 11px system-ui'; cx.fillStyle = T.dim;
|
|
352
|
+
for (const c of caps) {
|
|
353
|
+
const w = cx.measureText(c.text).width;
|
|
354
|
+
if (fits(c.x - w / 2, c.y - 11, w, 14)) cx.fillText(c.text, c.x, c.y);
|
|
355
|
+
}
|
|
356
|
+
// biggest first, so when labels compete the important node keeps its name
|
|
357
|
+
const order = [...N].sort((a, b) => b.r - a.r);
|
|
358
|
+
for (const n of order) {
|
|
359
|
+
if (lit && !lit.has(n.id)) continue;
|
|
360
|
+
const sx = n.x * scale + tx, sy = n.y * scale + ty, sr = n.r * scale;
|
|
361
|
+
if (sx < -80 || sx > W + 80 || sy < 30 || sy > H + 40) continue;
|
|
362
|
+
cx.font = (n.kind === 'bundle' ? '600 13px' : '12px') + ' system-ui';
|
|
363
|
+
const text = clip(n.label, n.kind === 'bundle' ? 22 : 16);
|
|
364
|
+
const w = cx.measureText(text).width, y = sy + sr + 13;
|
|
365
|
+
if (!fits(sx - w / 2, y - 10, w + 8, 14) && n !== hover) continue;
|
|
366
|
+
cx.fillStyle = T.fg; cx.fillText(text, sx, y);
|
|
367
|
+
if (n.badge) { cx.fillStyle = T.dim; cx.font = '11px system-ui'; cx.fillText(n.badge, sx, y + 14); }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (hover) {
|
|
371
|
+
const txt = hover.kind === 'bundle'
|
|
372
|
+
? hover.id + (hover.note ? ' — ' + hover.note : '')
|
|
373
|
+
: hover.label + ' ' + hover.note + (hover.type ? ' · ' + hover.type : '');
|
|
374
|
+
cx.font = '12.5px system-ui'; cx.textAlign = 'left';
|
|
375
|
+
const w = cx.measureText(txt).width;
|
|
376
|
+
cx.fillStyle = T.panel; cx.globalAlpha = .96;
|
|
377
|
+
roundRect(14, H - 42, w + 20, 28, 7); cx.fill();
|
|
378
|
+
cx.globalAlpha = 1; cx.strokeStyle = T.line; cx.lineWidth = 1;
|
|
379
|
+
roundRect(14, H - 42, w + 20, 28, 7); cx.stroke();
|
|
380
|
+
cx.fillStyle = T.fg; cx.fillText(txt, 24, H - 23);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function roundRect(x, y, w, h, r){
|
|
384
|
+
cx.beginPath(); cx.moveTo(x + r, y); cx.arcTo(x + w, y, x + w, y + h, r);
|
|
385
|
+
cx.arcTo(x + w, y + h, x, y + h, r); cx.arcTo(x, y + h, x, y, r); cx.arcTo(x, y, x + w, y, r); cx.closePath();
|
|
386
|
+
}
|
|
387
|
+
const clip = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
388
|
+
|
|
389
|
+
/* ---- interaction ---------------------------------------------------------------- */
|
|
390
|
+
function at(sx, sy){
|
|
391
|
+
const p = toWorld(sx, sy);
|
|
392
|
+
for (let i = N.length - 1; i >= 0; i--) {
|
|
393
|
+
const n = N[i];
|
|
394
|
+
if (Math.hypot(n.x - p.x, n.y - p.y) < n.r + 6 / scale) return n;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function goBundles(){ view = { level: 'bundles', bundle: null }; build(); $('panel').classList.remove('open'); }
|
|
398
|
+
$('back').onclick = goBundles;
|
|
399
|
+
$('fit').onclick = fitView;
|
|
400
|
+
addEventListener('keydown', e => {
|
|
401
|
+
if (e.key === 'Escape') {
|
|
402
|
+
$('panel').classList.contains('open') ? $('panel').classList.remove('open')
|
|
403
|
+
: view.level !== 'bundles' && goBundles();
|
|
404
|
+
} else if (e.key === 'f' || e.key === 'F') fitView();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
async function openPanel(n){
|
|
408
|
+
$('panel').classList.add('open');
|
|
409
|
+
$('pTitle').textContent = n.label; // full title, never clipped
|
|
410
|
+
$('pMeta').textContent = view.bundle + ' / ' + n.id; // full path
|
|
411
|
+
const chip = $('pChip');
|
|
412
|
+
chip.textContent = n.type || '';
|
|
413
|
+
chip.style.color = typeColor(n.type);
|
|
414
|
+
chip.style.display = n.type ? 'inline-block' : 'none';
|
|
415
|
+
const body = $('pBody');
|
|
416
|
+
body.textContent = 'loading…';
|
|
417
|
+
try {
|
|
418
|
+
const r = await fetch('/api/file?bundle=' + encodeURIComponent(view.bundle) + '&path=' + encodeURIComponent(n.id));
|
|
419
|
+
body.textContent = r.ok ? await r.text() : '(could not load file)';
|
|
420
|
+
} catch { body.textContent = '(could not load file)'; }
|
|
421
|
+
}
|
|
422
|
+
$('pClose').onclick = () => $('panel').classList.remove('open');
|
|
423
|
+
|
|
424
|
+
cv.onmousedown = e => {
|
|
425
|
+
const node = at(e.clientX, e.clientY);
|
|
426
|
+
down = { x: e.clientX, y: e.clientY, node };
|
|
427
|
+
drag = node;
|
|
428
|
+
panning = node ? null : { x: e.clientX - tx, y: e.clientY - ty };
|
|
429
|
+
};
|
|
430
|
+
cv.onmouseup = e => {
|
|
431
|
+
drag = null; panning = null;
|
|
432
|
+
if (!down) return;
|
|
433
|
+
const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y) > 4;
|
|
434
|
+
if (!moved) {
|
|
435
|
+
// A click on empty space does nothing on purpose: leaving a bundle is the back button
|
|
436
|
+
// or Escape only. Backing out on a stray click made the canvas hostile to pan and poke.
|
|
437
|
+
const n = down.node;
|
|
438
|
+
if (n && view.level === 'bundles') { view = { level: 'concepts', bundle: n.id }; build(); }
|
|
439
|
+
else if (n) openPanel(n);
|
|
440
|
+
}
|
|
441
|
+
down = null;
|
|
442
|
+
};
|
|
443
|
+
cv.onmousemove = e => {
|
|
444
|
+
if (panning) { tx = e.clientX - panning.x; ty = e.clientY - panning.y; cv.style.cursor = 'grabbing'; return; }
|
|
445
|
+
if (drag) { const p = toWorld(e.clientX, e.clientY); drag.x = p.x; drag.y = p.y; drag.vx = drag.vy = 0; return; }
|
|
446
|
+
hover = at(e.clientX, e.clientY);
|
|
447
|
+
cv.style.cursor = hover ? 'pointer' : 'grab';
|
|
448
|
+
};
|
|
449
|
+
cv.onmouseleave = () => { drag = null; panning = null; down = null; hover = null; };
|
|
450
|
+
cv.onwheel = e => {
|
|
451
|
+
e.preventDefault();
|
|
452
|
+
const ns = Math.min(4, Math.max(0.15, scale * Math.exp(-e.deltaY * 0.0015)));
|
|
453
|
+
const k = ns / scale; // keep the point under the cursor put
|
|
454
|
+
tx = e.clientX - (e.clientX - tx) * k;
|
|
455
|
+
ty = e.clientY - (e.clientY - ty) * k;
|
|
456
|
+
scale = ns;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
$('refresh').onclick = async () => {
|
|
460
|
+
const btn = $('refresh');
|
|
461
|
+
btn.disabled = true;
|
|
462
|
+
try {
|
|
463
|
+
DATA = await (await fetch('/api/graph?rebuild=1')).json();
|
|
464
|
+
if (view.level !== 'bundles' && !DATA.bundleGraphs[view.bundle]) view = { level: 'bundles', bundle: null };
|
|
465
|
+
build();
|
|
466
|
+
} finally { btn.disabled = false; }
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Tell the server someone's still looking, so it can shut itself down once we're gone.
|
|
470
|
+
fetch('/api/heartbeat').catch(() => {});
|
|
471
|
+
setInterval(() => fetch('/api/heartbeat').catch(() => {}), 3000);
|
|
472
|
+
addEventListener('pagehide', () => navigator.sendBeacon('/api/close'));
|
|
473
|
+
|
|
474
|
+
build();
|
|
475
|
+
(function loop(){ step(); draw(); requestAnimationFrame(loop); })();
|
|
476
|
+
</script>`;
|
|
477
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Graph data for `khb visualize` — bundles + refs (outer graph) and, per bundle, concept
|
|
2
|
+
// docs + the markdown links between them (inner graph). Read-only: this never writes
|
|
3
|
+
// anything, so the live server's `/api/graph` and `/api/file` can call it freely.
|
|
4
|
+
import { readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { dirname, relative, sep } from "node:path";
|
|
6
|
+
import { parse as parseYaml } from "yaml";
|
|
7
|
+
import { HUB, BUNDLES, listBundles, read, mdLinks, join, existsSync } from "./util";
|
|
8
|
+
|
|
9
|
+
export type ConceptNode = {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
type: string;
|
|
13
|
+
bytes: number;
|
|
14
|
+
/** Top-level subdirectory the concept lives in ("" for bundle-root files). The inner
|
|
15
|
+
* graph clusters by this, so nesting deeper than one level collapses into its parent —
|
|
16
|
+
* a handful of labelled regions reads better than one region per directory. */
|
|
17
|
+
folder: string;
|
|
18
|
+
};
|
|
19
|
+
export type ConceptEdge = { from: string; to: string };
|
|
20
|
+
export type BundleGraph = { concepts: ConceptNode[]; edges: ConceptEdge[] };
|
|
21
|
+
export type BundleNode = { id: string; notes: number; scope: string };
|
|
22
|
+
export type BundleEdge = { from: string; to: string; why: string };
|
|
23
|
+
export type GraphData = {
|
|
24
|
+
builtAt: string;
|
|
25
|
+
bundles: BundleNode[];
|
|
26
|
+
bundleEdges: BundleEdge[];
|
|
27
|
+
bundleGraphs: Record<string, BundleGraph>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const RESERVED = ["index.md", "log.md", "refs.md"];
|
|
31
|
+
|
|
32
|
+
/** All files under dir (bundle-relative, posix separators), skipping raw/. */
|
|
33
|
+
function walk(dir: string, base = dir): string[] {
|
|
34
|
+
return readdirSync(dir).flatMap((f) => {
|
|
35
|
+
const p = join(dir, f);
|
|
36
|
+
if (statSync(p).isDirectory()) return f === "raw" ? [] : walk(p, base);
|
|
37
|
+
return [relative(base, p).replaceAll("\\", "/")];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function frontmatter(body: string): Record<string, unknown> {
|
|
42
|
+
const fm = body.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
43
|
+
if (fm === undefined) return {};
|
|
44
|
+
try {
|
|
45
|
+
return (parseYaml(fm) ?? {}) as Record<string, unknown>;
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildGraphData(): GraphData {
|
|
52
|
+
const bundles = listBundles();
|
|
53
|
+
const bundleNodes: BundleNode[] = [];
|
|
54
|
+
const bundleEdges: BundleEdge[] = [];
|
|
55
|
+
const bundleGraphs: Record<string, BundleGraph> = {};
|
|
56
|
+
|
|
57
|
+
const scopes: Record<string, string> = {};
|
|
58
|
+
const outerIndexPath = join(HUB, "outer.index.md");
|
|
59
|
+
if (existsSync(outerIndexPath)) {
|
|
60
|
+
for (const line of read(outerIndexPath).split("\n")) {
|
|
61
|
+
const m = line.match(/^\|\s*\[([a-z0-9-]+)\][^|]*\|\s*([^|]+)\|/);
|
|
62
|
+
if (m) scopes[m[1]] = m[2].trim();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const b of bundles) {
|
|
67
|
+
const dir = join(BUNDLES, b);
|
|
68
|
+
const files = existsSync(dir) ? walk(dir) : [];
|
|
69
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
70
|
+
const concepts = mdFiles.filter((f) => !RESERVED.includes(f.split("/").pop()!));
|
|
71
|
+
|
|
72
|
+
const conceptNodes: ConceptNode[] = [];
|
|
73
|
+
const conceptEdges: ConceptEdge[] = [];
|
|
74
|
+
for (const c of concepts) {
|
|
75
|
+
const body = read(join(dir, c));
|
|
76
|
+
const fm = frontmatter(body);
|
|
77
|
+
conceptNodes.push({
|
|
78
|
+
id: c,
|
|
79
|
+
title: typeof fm.title === "string" && fm.title ? fm.title : c,
|
|
80
|
+
type: typeof fm.type === "string" ? fm.type : "",
|
|
81
|
+
bytes: body.length,
|
|
82
|
+
folder: c.includes("/") ? c.split("/")[0] : "",
|
|
83
|
+
});
|
|
84
|
+
for (const l of mdLinks(body)) {
|
|
85
|
+
if (l.target.startsWith("http") || !l.target.endsWith(".md")) continue;
|
|
86
|
+
const target = l.target.startsWith("/")
|
|
87
|
+
? l.target.slice(1)
|
|
88
|
+
: relative(dir, join(dir, dirname(c), l.target)).replaceAll("\\", "/");
|
|
89
|
+
if (concepts.includes(target)) conceptEdges.push({ from: c, to: target });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
bundleGraphs[b] = { concepts: conceptNodes, edges: conceptEdges };
|
|
93
|
+
|
|
94
|
+
bundleNodes.push({ id: b, notes: concepts.length, scope: scopes[b] ?? "" });
|
|
95
|
+
const refsPath = join(dir, "refs.md");
|
|
96
|
+
if (existsSync(refsPath)) {
|
|
97
|
+
for (const line of read(refsPath).split("\n")) {
|
|
98
|
+
const m = line.match(/^\|\s*\[?([a-z0-9][a-z0-9-]*)\]?[^|]*\|\s*([^|]*)\|/);
|
|
99
|
+
if (m && !["bundle", "---"].includes(m[1]) && bundles.includes(m[1]))
|
|
100
|
+
bundleEdges.push({ from: b, to: m[1], why: m[2].trim() });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { builtAt: new Date().toISOString(), bundles: bundleNodes, bundleEdges, bundleGraphs };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A single concept's body, for the graph page's on-demand file panel. Guards against
|
|
109
|
+
* path traversal since `path` comes off a request query string. */
|
|
110
|
+
export function readConceptFile(bundle: string, path: string): string | undefined {
|
|
111
|
+
if (!listBundles().includes(bundle)) return undefined;
|
|
112
|
+
const dir = join(BUNDLES, bundle);
|
|
113
|
+
const full = join(dir, path);
|
|
114
|
+
if (full !== dir && !full.startsWith(dir + sep)) return undefined;
|
|
115
|
+
if (!existsSync(full)) return undefined;
|
|
116
|
+
return read(full);
|
|
117
|
+
}
|