backpack-viewer 0.2.21 → 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 +2 -0
- package/bin/serve.js +122 -1
- package/dist/api.d.ts +13 -0
- package/dist/api.js +12 -0
- package/dist/app/assets/index-CBjy2b6N.js +34 -0
- package/dist/app/assets/index-CvkozBSE.css +1 -0
- package/dist/app/assets/layout-worker-BZXiBoiC.js +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +2 -0
- package/dist/canvas.js +473 -161
- package/dist/empty-state.js +13 -0
- package/dist/info-panel.js +2 -2
- package/dist/label-cache.d.ts +14 -0
- package/dist/label-cache.js +54 -0
- package/dist/layout-worker.d.ts +17 -0
- package/dist/layout-worker.js +78 -0
- package/dist/layout.js +73 -18
- package/dist/main.js +47 -14
- package/dist/quadtree.d.ts +43 -0
- package/dist/quadtree.js +147 -0
- package/dist/sidebar.d.ts +2 -0
- package/dist/sidebar.js +90 -1
- package/dist/spatial-hash.d.ts +22 -0
- package/dist/spatial-hash.js +67 -0
- package/dist/style.css +193 -0
- package/dist/tools-pane.d.ts +1 -0
- package/dist/tools-pane.js +109 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BBfZ1JvO.js +0 -21
- package/dist/app/assets/index-DNiYjxNx.css +0 -1
package/dist/empty-state.js
CHANGED
|
@@ -2,6 +2,19 @@ export function initEmptyState(container) {
|
|
|
2
2
|
const el = document.createElement("div");
|
|
3
3
|
el.className = "empty-state";
|
|
4
4
|
el.innerHTML = `
|
|
5
|
+
<div class="empty-state-bg">
|
|
6
|
+
<div class="empty-state-circle c1"></div>
|
|
7
|
+
<div class="empty-state-circle c2"></div>
|
|
8
|
+
<div class="empty-state-circle c3"></div>
|
|
9
|
+
<div class="empty-state-circle c4"></div>
|
|
10
|
+
<div class="empty-state-circle c5"></div>
|
|
11
|
+
<svg class="empty-state-lines" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
|
|
12
|
+
<line x1="80" y1="60" x2="220" y2="140" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
13
|
+
<line x1="220" y1="140" x2="320" y2="80" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
14
|
+
<line x1="220" y1="140" x2="160" y2="240" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
15
|
+
<line x1="160" y1="240" x2="300" y2="220" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
16
|
+
</svg>
|
|
17
|
+
</div>
|
|
5
18
|
<div class="empty-state-content">
|
|
6
19
|
<div class="empty-state-icon">
|
|
7
20
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
package/dist/info-panel.js
CHANGED
|
@@ -403,7 +403,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
403
403
|
label.textContent = `${nodes.length} nodes selected`;
|
|
404
404
|
header.appendChild(label);
|
|
405
405
|
const badgeRow = document.createElement("div");
|
|
406
|
-
badgeRow.
|
|
406
|
+
badgeRow.className = "info-badge-row";
|
|
407
407
|
const typeCounts = new Map();
|
|
408
408
|
for (const node of nodes) {
|
|
409
409
|
typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
|
|
@@ -451,7 +451,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
451
451
|
: "Connections Between Selected");
|
|
452
452
|
if (sharedEdges.length === 0) {
|
|
453
453
|
const empty = document.createElement("p");
|
|
454
|
-
empty.
|
|
454
|
+
empty.className = "info-empty-message";
|
|
455
455
|
empty.textContent = "No direct connections between selected nodes";
|
|
456
456
|
connSection.appendChild(empty);
|
|
457
457
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen canvas label cache.
|
|
3
|
+
*
|
|
4
|
+
* Pre-renders text labels to small offscreen canvases, then draws them
|
|
5
|
+
* via drawImage() which is much faster than fillText() per frame.
|
|
6
|
+
* Cache keys are "text|font|color" to handle theme changes.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Draw a cached label centered at (x, y) with the given baseline alignment.
|
|
10
|
+
* Returns immediately — cache miss renders inline and stores for next frame.
|
|
11
|
+
*/
|
|
12
|
+
export declare function drawCachedLabel(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, font: string, color: string, align: "top" | "bottom"): void;
|
|
13
|
+
/** Clear the entire cache (call on theme change or graph reload). */
|
|
14
|
+
export declare function clearLabelCache(): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen canvas label cache.
|
|
3
|
+
*
|
|
4
|
+
* Pre-renders text labels to small offscreen canvases, then draws them
|
|
5
|
+
* via drawImage() which is much faster than fillText() per frame.
|
|
6
|
+
* Cache keys are "text|font|color" to handle theme changes.
|
|
7
|
+
*/
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
const MAX_CACHE_SIZE = 2000;
|
|
10
|
+
function key(text, font, color) {
|
|
11
|
+
return `${text}|${font}|${color}`;
|
|
12
|
+
}
|
|
13
|
+
// Shared measurement canvas — reused across all renderLabel calls
|
|
14
|
+
const measureCanvas = new OffscreenCanvas(1, 1);
|
|
15
|
+
const measureCtx = measureCanvas.getContext("2d");
|
|
16
|
+
function renderLabel(text, font, color) {
|
|
17
|
+
measureCtx.font = font;
|
|
18
|
+
const metrics = measureCtx.measureText(text);
|
|
19
|
+
const w = Math.ceil(metrics.width) + 2; // 1px padding each side
|
|
20
|
+
const h = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent) + 4;
|
|
21
|
+
const canvas = new OffscreenCanvas(w, h);
|
|
22
|
+
const ctx = canvas.getContext("2d");
|
|
23
|
+
ctx.font = font;
|
|
24
|
+
ctx.fillStyle = color;
|
|
25
|
+
ctx.textAlign = "left";
|
|
26
|
+
ctx.textBaseline = "top";
|
|
27
|
+
ctx.fillText(text, 1, 1);
|
|
28
|
+
return { canvas, width: w, height: h };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Draw a cached label centered at (x, y) with the given baseline alignment.
|
|
32
|
+
* Returns immediately — cache miss renders inline and stores for next frame.
|
|
33
|
+
*/
|
|
34
|
+
export function drawCachedLabel(ctx, text, x, y, font, color, align) {
|
|
35
|
+
const k = key(text, font, color);
|
|
36
|
+
let entry = cache.get(k);
|
|
37
|
+
if (!entry) {
|
|
38
|
+
// Evict oldest entries if cache is full
|
|
39
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
40
|
+
const first = cache.keys().next().value;
|
|
41
|
+
if (first !== undefined)
|
|
42
|
+
cache.delete(first);
|
|
43
|
+
}
|
|
44
|
+
entry = renderLabel(text, font, color);
|
|
45
|
+
cache.set(k, entry);
|
|
46
|
+
}
|
|
47
|
+
const dx = x - entry.width / 2;
|
|
48
|
+
const dy = align === "top" ? y : y - entry.height;
|
|
49
|
+
ctx.drawImage(entry.canvas, dx, dy);
|
|
50
|
+
}
|
|
51
|
+
/** Clear the entire cache (call on theme change or graph reload). */
|
|
52
|
+
export function clearLabelCache() {
|
|
53
|
+
cache.clear();
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Worker for off-main-thread force-directed layout.
|
|
3
|
+
*
|
|
4
|
+
* Runs the tick loop in a worker thread so physics never blocks
|
|
5
|
+
* the main thread's rendering or input handling.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* Main → Worker:
|
|
9
|
+
* { type: 'start', nodes, edges, params } — begin simulation
|
|
10
|
+
* { type: 'stop' } — halt simulation
|
|
11
|
+
* { type: 'params', params } — update layout params + reheat
|
|
12
|
+
*
|
|
13
|
+
* Worker → Main:
|
|
14
|
+
* { type: 'tick', positions: Float64Array, alpha } — position update per tick
|
|
15
|
+
* { type: 'settled' } — simulation converged
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Worker for off-main-thread force-directed layout.
|
|
3
|
+
*
|
|
4
|
+
* Runs the tick loop in a worker thread so physics never blocks
|
|
5
|
+
* the main thread's rendering or input handling.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* Main → Worker:
|
|
9
|
+
* { type: 'start', nodes, edges, params } — begin simulation
|
|
10
|
+
* { type: 'stop' } — halt simulation
|
|
11
|
+
* { type: 'params', params } — update layout params + reheat
|
|
12
|
+
*
|
|
13
|
+
* Worker → Main:
|
|
14
|
+
* { type: 'tick', positions: Float64Array, alpha } — position update per tick
|
|
15
|
+
* { type: 'settled' } — simulation converged
|
|
16
|
+
*/
|
|
17
|
+
import { createLayout, tick, setLayoutParams, autoLayoutParams } from "./layout.js";
|
|
18
|
+
const ALPHA_MIN = 0.001;
|
|
19
|
+
const TICK_BATCH = 3; // ticks per message to reduce postMessage overhead
|
|
20
|
+
let running = false;
|
|
21
|
+
let state = null;
|
|
22
|
+
let alpha = 1;
|
|
23
|
+
function packPositions(nodes) {
|
|
24
|
+
const buf = new Float64Array(nodes.length * 4);
|
|
25
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
26
|
+
const n = nodes[i];
|
|
27
|
+
buf[i * 4] = n.x;
|
|
28
|
+
buf[i * 4 + 1] = n.y;
|
|
29
|
+
buf[i * 4 + 2] = n.vx;
|
|
30
|
+
buf[i * 4 + 3] = n.vy;
|
|
31
|
+
}
|
|
32
|
+
return buf;
|
|
33
|
+
}
|
|
34
|
+
function runLoop() {
|
|
35
|
+
if (!running || !state)
|
|
36
|
+
return;
|
|
37
|
+
for (let i = 0; i < TICK_BATCH; i++) {
|
|
38
|
+
if (alpha < ALPHA_MIN) {
|
|
39
|
+
running = false;
|
|
40
|
+
const positions = packPositions(state.nodes);
|
|
41
|
+
self.postMessage({ type: "tick", positions, alpha }, { transfer: [positions.buffer] });
|
|
42
|
+
self.postMessage({ type: "settled" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
alpha = tick(state, alpha);
|
|
46
|
+
}
|
|
47
|
+
const positions = packPositions(state.nodes);
|
|
48
|
+
self.postMessage({ type: "tick", positions, alpha }, { transfer: [positions.buffer] });
|
|
49
|
+
// Yield to allow incoming messages, then continue
|
|
50
|
+
setTimeout(runLoop, 0);
|
|
51
|
+
}
|
|
52
|
+
self.onmessage = (e) => {
|
|
53
|
+
const msg = e.data;
|
|
54
|
+
if (msg.type === "start") {
|
|
55
|
+
running = false; // stop any existing loop
|
|
56
|
+
const data = msg.data;
|
|
57
|
+
const params = msg.params;
|
|
58
|
+
// Auto-scale params based on graph size, then apply overrides
|
|
59
|
+
const auto = autoLayoutParams(data.nodes.length);
|
|
60
|
+
setLayoutParams({ ...auto, ...params });
|
|
61
|
+
state = createLayout(data);
|
|
62
|
+
alpha = 1;
|
|
63
|
+
running = true;
|
|
64
|
+
runLoop();
|
|
65
|
+
}
|
|
66
|
+
if (msg.type === "stop") {
|
|
67
|
+
running = false;
|
|
68
|
+
}
|
|
69
|
+
if (msg.type === "params") {
|
|
70
|
+
setLayoutParams(msg.params);
|
|
71
|
+
// Reheat simulation
|
|
72
|
+
alpha = Math.max(alpha, 0.3);
|
|
73
|
+
if (!running && state) {
|
|
74
|
+
running = true;
|
|
75
|
+
runLoop();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
package/dist/layout.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildQuadtree, applyRepulsion } from "./quadtree.js";
|
|
1
2
|
export const DEFAULT_LAYOUT_PARAMS = {
|
|
2
3
|
clusterStrength: 0.08,
|
|
3
4
|
spacing: 1.5,
|
|
@@ -106,27 +107,81 @@ export function createLayout(data) {
|
|
|
106
107
|
}));
|
|
107
108
|
return { nodes, edges, nodeMap };
|
|
108
109
|
}
|
|
110
|
+
// Barnes-Hut accuracy parameter (0.5 = accurate, 1.0 = fast).
|
|
111
|
+
// 0.7 is a good balance for interactive use.
|
|
112
|
+
const BH_THETA = 0.7;
|
|
113
|
+
// Threshold below which we fall back to direct O(n²) — quadtree overhead isn't worth it
|
|
114
|
+
const BH_THRESHOLD = 80;
|
|
109
115
|
/** Run one tick of the force simulation. Returns new alpha. */
|
|
110
116
|
export function tick(state, alpha) {
|
|
111
117
|
const { nodes, edges, nodeMap } = state;
|
|
112
|
-
// Repulsion —
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
118
|
+
// Repulsion — Barnes-Hut O(n log n) for large graphs, direct O(n²) for small
|
|
119
|
+
const crossRep = CROSS_TYPE_REPULSION_BASE * params.spacing;
|
|
120
|
+
if (nodes.length >= BH_THRESHOLD) {
|
|
121
|
+
// Barnes-Hut: apply cross-type repulsion strength globally via quadtree
|
|
122
|
+
const tree = buildQuadtree(nodes);
|
|
123
|
+
if (tree) {
|
|
124
|
+
for (const node of nodes) {
|
|
125
|
+
applyRepulsion(tree, node, BH_THETA, crossRep, alpha, MIN_DISTANCE);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Same-type correction: same-type pairs should use REPULSION (weaker) not crossRep.
|
|
129
|
+
// Apply a negative correction of (crossRep - REPULSION) for same-type pairs.
|
|
130
|
+
// Group by type to avoid checking all n² pairs — only intra-group pairs.
|
|
131
|
+
const repDiff = crossRep - REPULSION;
|
|
132
|
+
if (repDiff > 0) {
|
|
133
|
+
const typeGroups = new Map();
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
let group = typeGroups.get(node.type);
|
|
136
|
+
if (!group) {
|
|
137
|
+
group = [];
|
|
138
|
+
typeGroups.set(node.type, group);
|
|
139
|
+
}
|
|
140
|
+
group.push(node);
|
|
141
|
+
}
|
|
142
|
+
for (const group of typeGroups.values()) {
|
|
143
|
+
for (let i = 0; i < group.length; i++) {
|
|
144
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
145
|
+
const a = group[i];
|
|
146
|
+
const b = group[j];
|
|
147
|
+
let dx = b.x - a.x;
|
|
148
|
+
let dy = b.y - a.y;
|
|
149
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
150
|
+
if (dist < MIN_DISTANCE)
|
|
151
|
+
dist = MIN_DISTANCE;
|
|
152
|
+
// Subtract the excess repulsion (correction is attractive between same-type)
|
|
153
|
+
const force = (repDiff * alpha) / (dist * dist);
|
|
154
|
+
const fx = (dx / dist) * force;
|
|
155
|
+
const fy = (dy / dist) * force;
|
|
156
|
+
a.vx += fx;
|
|
157
|
+
a.vy += fy;
|
|
158
|
+
b.vx -= fx;
|
|
159
|
+
b.vy -= fy;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Small graph — direct all-pairs (original algorithm)
|
|
167
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
168
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
169
|
+
const a = nodes[i];
|
|
170
|
+
const b = nodes[j];
|
|
171
|
+
let dx = b.x - a.x;
|
|
172
|
+
let dy = b.y - a.y;
|
|
173
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
174
|
+
if (dist < MIN_DISTANCE)
|
|
175
|
+
dist = MIN_DISTANCE;
|
|
176
|
+
const rep = a.type === b.type ? REPULSION : crossRep;
|
|
177
|
+
const force = (rep * alpha) / (dist * dist);
|
|
178
|
+
const fx = (dx / dist) * force;
|
|
179
|
+
const fy = (dy / dist) * force;
|
|
180
|
+
a.vx -= fx;
|
|
181
|
+
a.vy -= fy;
|
|
182
|
+
b.vx += fx;
|
|
183
|
+
b.vy += fy;
|
|
184
|
+
}
|
|
130
185
|
}
|
|
131
186
|
}
|
|
132
187
|
// Attraction — along edges (shorter rest length within same type)
|
package/dist/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listOntologies, loadOntology, saveOntology, renameOntology, listBranches, createBranch, switchBranch, deleteBranch, listSnapshots, createSnapshot, rollbackSnapshot, listSnippets, saveSnippet, loadSnippet, deleteSnippet, } from "./api";
|
|
1
|
+
import { listOntologies, loadOntology, saveOntology, renameOntology, listBranches, createBranch, switchBranch, deleteBranch, listSnapshots, createSnapshot, rollbackSnapshot, listSnippets, saveSnippet, loadSnippet, deleteSnippet, listRemotes, loadRemote, } from "./api";
|
|
2
2
|
import { initSidebar } from "./sidebar";
|
|
3
3
|
import { initCanvas } from "./canvas";
|
|
4
4
|
import { initInfoPanel } from "./info-panel";
|
|
@@ -14,6 +14,8 @@ import defaultConfig from "./default-config.json";
|
|
|
14
14
|
import "./style.css";
|
|
15
15
|
let activeOntology = "";
|
|
16
16
|
let currentData = null;
|
|
17
|
+
let remoteNames = new Set();
|
|
18
|
+
let activeIsRemote = false;
|
|
17
19
|
async function main() {
|
|
18
20
|
const canvasContainer = document.getElementById("canvas-container");
|
|
19
21
|
// --- Load config ---
|
|
@@ -344,6 +346,16 @@ async function main() {
|
|
|
344
346
|
await saveSnippet(activeOntology, label, trail, edgeIds);
|
|
345
347
|
await refreshSnippets(activeOntology);
|
|
346
348
|
},
|
|
349
|
+
async onStarredSaveSnippet(label, nodeIds) {
|
|
350
|
+
if (!activeOntology || !currentData)
|
|
351
|
+
return;
|
|
352
|
+
const nodeSet = new Set(nodeIds);
|
|
353
|
+
const edgeIds = currentData.edges
|
|
354
|
+
.filter((e) => nodeSet.has(e.sourceId) && nodeSet.has(e.targetId))
|
|
355
|
+
.map((e) => e.id);
|
|
356
|
+
await saveSnippet(activeOntology, label, nodeIds, edgeIds);
|
|
357
|
+
await refreshSnippets(activeOntology);
|
|
358
|
+
},
|
|
347
359
|
onFocusChange(seedNodeIds) {
|
|
348
360
|
if (seedNodeIds && seedNodeIds.length > 0) {
|
|
349
361
|
canvas.enterFocus(seedNodeIds, 0);
|
|
@@ -660,12 +672,13 @@ async function main() {
|
|
|
660
672
|
}
|
|
661
673
|
async function selectGraph(name, panToNodeIds, focusSeedIds, focusHops) {
|
|
662
674
|
activeOntology = name;
|
|
675
|
+
activeIsRemote = remoteNames.has(name);
|
|
663
676
|
sidebar.setActive(name);
|
|
664
677
|
infoPanel.hide();
|
|
665
678
|
removeFocusIndicator();
|
|
666
679
|
search.clear();
|
|
667
680
|
undoHistory.clear();
|
|
668
|
-
currentData = await loadOntology(name);
|
|
681
|
+
currentData = activeIsRemote ? await loadRemote(name) : await loadOntology(name);
|
|
669
682
|
const autoParams = autoLayoutParams(currentData.nodes.length);
|
|
670
683
|
setLayoutParams({
|
|
671
684
|
spacing: Math.max(cfg.layout.spacing, autoParams.spacing),
|
|
@@ -676,10 +689,13 @@ async function main() {
|
|
|
676
689
|
toolsPane.setData(currentData);
|
|
677
690
|
emptyState.hide();
|
|
678
691
|
updateUrl(name);
|
|
679
|
-
// Load branches and snapshots
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
692
|
+
// Load branches and snapshots — skipped for remote graphs (read-only,
|
|
693
|
+
// no branch/snapshot/snippet APIs on the remote endpoint)
|
|
694
|
+
if (!activeIsRemote) {
|
|
695
|
+
await refreshBranches(name);
|
|
696
|
+
await refreshSnapshots(name);
|
|
697
|
+
await refreshSnippets(name);
|
|
698
|
+
}
|
|
683
699
|
// Restore focus mode if requested
|
|
684
700
|
if (focusSeedIds?.length && currentData) {
|
|
685
701
|
const validFocus = focusSeedIds.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
@@ -703,16 +719,25 @@ async function main() {
|
|
|
703
719
|
}
|
|
704
720
|
}
|
|
705
721
|
}
|
|
706
|
-
// Load ontology list
|
|
707
|
-
const summaries = await
|
|
722
|
+
// Load ontology list (local + remote in parallel)
|
|
723
|
+
const [summaries, remotes] = await Promise.all([
|
|
724
|
+
listOntologies(),
|
|
725
|
+
listRemotes().catch(() => []),
|
|
726
|
+
]);
|
|
708
727
|
sidebar.setSummaries(summaries);
|
|
728
|
+
sidebar.setRemotes(remotes);
|
|
729
|
+
remoteNames = new Set(remotes.map((r) => r.name));
|
|
709
730
|
// Auto-load from URL hash, or first graph
|
|
710
731
|
const initialUrl = parseUrl();
|
|
711
732
|
const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
|
|
712
733
|
? initialUrl.graph
|
|
713
|
-
:
|
|
714
|
-
?
|
|
715
|
-
:
|
|
734
|
+
: initialUrl.graph && remoteNames.has(initialUrl.graph)
|
|
735
|
+
? initialUrl.graph
|
|
736
|
+
: summaries.length > 0
|
|
737
|
+
? summaries[0].name
|
|
738
|
+
: remotes.length > 0
|
|
739
|
+
? remotes[0].name
|
|
740
|
+
: null;
|
|
716
741
|
if (initialName) {
|
|
717
742
|
await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
|
|
718
743
|
}
|
|
@@ -847,13 +872,20 @@ async function main() {
|
|
|
847
872
|
// Live reload — when Claude adds nodes via MCP, re-fetch and re-render
|
|
848
873
|
if (import.meta.hot) {
|
|
849
874
|
import.meta.hot.on("ontology-change", async () => {
|
|
850
|
-
const updated = await
|
|
875
|
+
const [updated, updatedRemotes] = await Promise.all([
|
|
876
|
+
listOntologies(),
|
|
877
|
+
listRemotes().catch(() => []),
|
|
878
|
+
]);
|
|
851
879
|
sidebar.setSummaries(updated);
|
|
852
|
-
|
|
880
|
+
sidebar.setRemotes(updatedRemotes);
|
|
881
|
+
remoteNames = new Set(updatedRemotes.map((r) => r.name));
|
|
882
|
+
if (updated.length > 0 || updatedRemotes.length > 0)
|
|
853
883
|
emptyState.hide();
|
|
854
884
|
if (activeOntology) {
|
|
855
885
|
try {
|
|
856
|
-
currentData =
|
|
886
|
+
currentData = activeIsRemote
|
|
887
|
+
? await loadRemote(activeOntology)
|
|
888
|
+
: await loadOntology(activeOntology);
|
|
857
889
|
canvas.loadGraph(currentData);
|
|
858
890
|
search.setLearningGraphData(currentData);
|
|
859
891
|
toolsPane.setData(currentData);
|
|
@@ -864,6 +896,7 @@ async function main() {
|
|
|
864
896
|
}
|
|
865
897
|
else if (updated.length > 0) {
|
|
866
898
|
activeOntology = updated[0].name;
|
|
899
|
+
activeIsRemote = false;
|
|
867
900
|
sidebar.setActive(activeOntology);
|
|
868
901
|
currentData = await loadOntology(activeOntology);
|
|
869
902
|
canvas.loadGraph(currentData);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barnes-Hut quadtree for O(n log n) force-directed repulsion.
|
|
3
|
+
*
|
|
4
|
+
* Instead of computing repulsion between every pair of nodes (O(n²)),
|
|
5
|
+
* the quadtree groups distant nodes into aggregate "bodies" and applies
|
|
6
|
+
* a single force from each group. The θ parameter controls accuracy:
|
|
7
|
+
* lower θ = more accurate but slower, higher θ = faster but less precise.
|
|
8
|
+
*/
|
|
9
|
+
export interface Body {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
vx: number;
|
|
13
|
+
vy: number;
|
|
14
|
+
type: string;
|
|
15
|
+
}
|
|
16
|
+
interface QuadNode {
|
|
17
|
+
x0: number;
|
|
18
|
+
y0: number;
|
|
19
|
+
x1: number;
|
|
20
|
+
y1: number;
|
|
21
|
+
cx: number;
|
|
22
|
+
cy: number;
|
|
23
|
+
mass: number;
|
|
24
|
+
children: (QuadNode | null)[];
|
|
25
|
+
body: Body | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a quadtree from an array of bodies.
|
|
29
|
+
* Computes bounding box automatically with padding.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildQuadtree(bodies: Body[]): QuadNode | null;
|
|
32
|
+
/**
|
|
33
|
+
* Apply Barnes-Hut repulsion forces to a single body.
|
|
34
|
+
*
|
|
35
|
+
* @param root Quadtree root
|
|
36
|
+
* @param body The body to compute forces for
|
|
37
|
+
* @param theta Accuracy parameter (0.5–1.0). Higher = faster, less accurate.
|
|
38
|
+
* @param strength Base repulsion strength
|
|
39
|
+
* @param alpha Simulation alpha (decays over time)
|
|
40
|
+
* @param minDist Minimum distance clamp to avoid explosion
|
|
41
|
+
*/
|
|
42
|
+
export declare function applyRepulsion(root: QuadNode, body: Body, theta: number, strength: number, alpha: number, minDist: number): void;
|
|
43
|
+
export {};
|
package/dist/quadtree.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barnes-Hut quadtree for O(n log n) force-directed repulsion.
|
|
3
|
+
*
|
|
4
|
+
* Instead of computing repulsion between every pair of nodes (O(n²)),
|
|
5
|
+
* the quadtree groups distant nodes into aggregate "bodies" and applies
|
|
6
|
+
* a single force from each group. The θ parameter controls accuracy:
|
|
7
|
+
* lower θ = more accurate but slower, higher θ = faster but less precise.
|
|
8
|
+
*/
|
|
9
|
+
function createNode(x0, y0, x1, y1) {
|
|
10
|
+
return { x0, y0, x1, y1, cx: 0, cy: 0, mass: 0, children: [null, null, null, null], body: null };
|
|
11
|
+
}
|
|
12
|
+
function quadrant(node, x, y) {
|
|
13
|
+
const mx = (node.x0 + node.x1) / 2;
|
|
14
|
+
const my = (node.y0 + node.y1) / 2;
|
|
15
|
+
return (x < mx ? 0 : 1) + (y < my ? 0 : 2);
|
|
16
|
+
}
|
|
17
|
+
function childBounds(node, q) {
|
|
18
|
+
const mx = (node.x0 + node.x1) / 2;
|
|
19
|
+
const my = (node.y0 + node.y1) / 2;
|
|
20
|
+
switch (q) {
|
|
21
|
+
case 0: return [node.x0, node.y0, mx, my]; // NW
|
|
22
|
+
case 1: return [mx, node.y0, node.x1, my]; // NE
|
|
23
|
+
case 2: return [node.x0, my, mx, node.y1]; // SW
|
|
24
|
+
default: return [mx, my, node.x1, node.y1]; // SE
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function insert(node, body) {
|
|
28
|
+
// Empty leaf — place body here
|
|
29
|
+
if (node.mass === 0 && node.body === null) {
|
|
30
|
+
node.body = body;
|
|
31
|
+
node.cx = body.x;
|
|
32
|
+
node.cy = body.y;
|
|
33
|
+
node.mass = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// If leaf with existing body, push it down
|
|
37
|
+
if (node.body !== null) {
|
|
38
|
+
const existing = node.body;
|
|
39
|
+
node.body = null;
|
|
40
|
+
// If bodies are at the exact same position, nudge slightly to avoid infinite recursion
|
|
41
|
+
if (existing.x === body.x && existing.y === body.y) {
|
|
42
|
+
body.x += (Math.random() - 0.5) * 0.1;
|
|
43
|
+
body.y += (Math.random() - 0.5) * 0.1;
|
|
44
|
+
}
|
|
45
|
+
const eq = quadrant(node, existing.x, existing.y);
|
|
46
|
+
if (node.children[eq] === null) {
|
|
47
|
+
const [x0, y0, x1, y1] = childBounds(node, eq);
|
|
48
|
+
node.children[eq] = createNode(x0, y0, x1, y1);
|
|
49
|
+
}
|
|
50
|
+
insert(node.children[eq], existing);
|
|
51
|
+
}
|
|
52
|
+
// Insert new body into appropriate child
|
|
53
|
+
const q = quadrant(node, body.x, body.y);
|
|
54
|
+
if (node.children[q] === null) {
|
|
55
|
+
const [x0, y0, x1, y1] = childBounds(node, q);
|
|
56
|
+
node.children[q] = createNode(x0, y0, x1, y1);
|
|
57
|
+
}
|
|
58
|
+
insert(node.children[q], body);
|
|
59
|
+
// Update aggregate center of mass
|
|
60
|
+
const total = node.mass + 1;
|
|
61
|
+
node.cx = (node.cx * node.mass + body.x) / total;
|
|
62
|
+
node.cy = (node.cy * node.mass + body.y) / total;
|
|
63
|
+
node.mass = total;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a quadtree from an array of bodies.
|
|
67
|
+
* Computes bounding box automatically with padding.
|
|
68
|
+
*/
|
|
69
|
+
export function buildQuadtree(bodies) {
|
|
70
|
+
if (bodies.length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
// Find bounding box
|
|
73
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
74
|
+
for (const b of bodies) {
|
|
75
|
+
if (b.x < minX)
|
|
76
|
+
minX = b.x;
|
|
77
|
+
if (b.y < minY)
|
|
78
|
+
minY = b.y;
|
|
79
|
+
if (b.x > maxX)
|
|
80
|
+
maxX = b.x;
|
|
81
|
+
if (b.y > maxY)
|
|
82
|
+
maxY = b.y;
|
|
83
|
+
}
|
|
84
|
+
// Pad and square the bounds (quadtree needs square region)
|
|
85
|
+
const pad = Math.max(maxX - minX, maxY - minY) * 0.1 + 50;
|
|
86
|
+
const cx = (minX + maxX) / 2;
|
|
87
|
+
const cy = (minY + maxY) / 2;
|
|
88
|
+
const half = Math.max(maxX - minX, maxY - minY) / 2 + pad;
|
|
89
|
+
const root = createNode(cx - half, cy - half, cx + half, cy + half);
|
|
90
|
+
for (const b of bodies)
|
|
91
|
+
insert(root, b);
|
|
92
|
+
return root;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Apply Barnes-Hut repulsion forces to a single body.
|
|
96
|
+
*
|
|
97
|
+
* @param root Quadtree root
|
|
98
|
+
* @param body The body to compute forces for
|
|
99
|
+
* @param theta Accuracy parameter (0.5–1.0). Higher = faster, less accurate.
|
|
100
|
+
* @param strength Base repulsion strength
|
|
101
|
+
* @param alpha Simulation alpha (decays over time)
|
|
102
|
+
* @param minDist Minimum distance clamp to avoid explosion
|
|
103
|
+
*/
|
|
104
|
+
export function applyRepulsion(root, body, theta, strength, alpha, minDist) {
|
|
105
|
+
_walk(root, body, theta, strength, alpha, minDist);
|
|
106
|
+
}
|
|
107
|
+
function _walk(node, body, theta, strength, alpha, minDist) {
|
|
108
|
+
if (node.mass === 0)
|
|
109
|
+
return;
|
|
110
|
+
const dx = node.cx - body.x;
|
|
111
|
+
const dy = node.cy - body.y;
|
|
112
|
+
const distSq = dx * dx + dy * dy;
|
|
113
|
+
// If this is a leaf with a single body, compute direct force (skip self)
|
|
114
|
+
if (node.body !== null) {
|
|
115
|
+
if (node.body !== body) {
|
|
116
|
+
let dist = Math.sqrt(distSq);
|
|
117
|
+
if (dist < minDist)
|
|
118
|
+
dist = minDist;
|
|
119
|
+
const force = (strength * alpha) / (dist * dist);
|
|
120
|
+
const fx = (dx / dist) * force;
|
|
121
|
+
const fy = (dy / dist) * force;
|
|
122
|
+
body.vx -= fx;
|
|
123
|
+
body.vy -= fy;
|
|
124
|
+
// Newton's 3rd law applied in the caller loop to avoid double-counting
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Barnes-Hut criterion: if node is far enough away, treat as aggregate
|
|
129
|
+
const size = node.x1 - node.x0;
|
|
130
|
+
if (size * size / distSq < theta * theta) {
|
|
131
|
+
let dist = Math.sqrt(distSq);
|
|
132
|
+
if (dist < minDist)
|
|
133
|
+
dist = minDist;
|
|
134
|
+
const force = (strength * node.mass * alpha) / (dist * dist);
|
|
135
|
+
const fx = (dx / dist) * force;
|
|
136
|
+
const fy = (dy / dist) * force;
|
|
137
|
+
body.vx -= fx;
|
|
138
|
+
body.vy -= fy;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Otherwise, recurse into children
|
|
142
|
+
for (let i = 0; i < 4; i++) {
|
|
143
|
+
if (node.children[i] !== null) {
|
|
144
|
+
_walk(node.children[i], body, theta, strength, alpha, minDist);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/sidebar.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LearningGraphSummary } from "backpack-ontology";
|
|
2
|
+
import type { RemoteSummary } from "./api.js";
|
|
2
3
|
export interface SidebarCallbacks {
|
|
3
4
|
onSelect: (name: string) => void;
|
|
4
5
|
onRename?: (oldName: string, newName: string) => void;
|
|
@@ -11,6 +12,7 @@ export interface SidebarCallbacks {
|
|
|
11
12
|
export declare function initSidebar(container: HTMLElement, onSelectOrCallbacks: ((name: string) => void) | SidebarCallbacks): {
|
|
12
13
|
setSummaries(summaries: LearningGraphSummary[]): void;
|
|
13
14
|
setActive(name: string): void;
|
|
15
|
+
setRemotes(remotes: RemoteSummary[]): void;
|
|
14
16
|
setActiveBranch(graphName: string, branchName: string, allBranches?: {
|
|
15
17
|
name: string;
|
|
16
18
|
active: boolean;
|