backpack-viewer 0.2.20 → 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 +184 -1
- package/dist/api.d.ts +25 -0
- package/dist/api.js +42 -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 +19 -0
- package/dist/canvas.js +662 -144
- package/dist/config.js +1 -0
- package/dist/context-menu.d.ts +13 -0
- package/dist/context-menu.js +64 -0
- package/dist/default-config.json +6 -1
- package/dist/empty-state.js +13 -0
- package/dist/info-panel.js +2 -2
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.js +2 -0
- 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 +266 -14
- package/dist/quadtree.d.ts +43 -0
- package/dist/quadtree.js +147 -0
- package/dist/shortcuts.js +2 -0
- package/dist/sidebar.d.ts +10 -0
- package/dist/sidebar.js +134 -4
- package/dist/spatial-hash.d.ts +22 -0
- package/dist/spatial-hash.js +67 -0
- package/dist/style.css +357 -0
- package/dist/tools-pane.d.ts +10 -0
- package/dist/tools-pane.js +192 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BAsAhA_i.js +0 -21
- package/dist/app/assets/index-CvETIueX.css +0 -1
package/dist/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listOntologies, loadOntology, saveOntology, renameOntology, listBranches, createBranch, switchBranch, deleteBranch, listSnapshots, createSnapshot, rollbackSnapshot, } 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";
|
|
@@ -9,10 +9,13 @@ import { initShortcuts } from "./shortcuts";
|
|
|
9
9
|
import { initEmptyState } from "./empty-state";
|
|
10
10
|
import { createHistory } from "./history";
|
|
11
11
|
import { matchKey } from "./keybindings";
|
|
12
|
+
import { initContextMenu } from "./context-menu";
|
|
12
13
|
import defaultConfig from "./default-config.json";
|
|
13
14
|
import "./style.css";
|
|
14
15
|
let activeOntology = "";
|
|
15
16
|
let currentData = null;
|
|
17
|
+
let remoteNames = new Set();
|
|
18
|
+
let activeIsRemote = false;
|
|
16
19
|
async function main() {
|
|
17
20
|
const canvasContainer = document.getElementById("canvas-container");
|
|
18
21
|
// --- Load config ---
|
|
@@ -184,10 +187,21 @@ async function main() {
|
|
|
184
187
|
exit.textContent = "\u00d7";
|
|
185
188
|
exit.title = "Exit focus (Esc)";
|
|
186
189
|
exit.addEventListener("click", () => toolsPane.clearFocusSet());
|
|
190
|
+
const walkBtn = document.createElement("button");
|
|
191
|
+
walkBtn.className = "walk-indicator";
|
|
192
|
+
if (canvas.getWalkMode())
|
|
193
|
+
walkBtn.classList.add("active");
|
|
194
|
+
walkBtn.textContent = "Walk";
|
|
195
|
+
walkBtn.title = "Toggle walk mode (W) — click nodes to traverse";
|
|
196
|
+
walkBtn.addEventListener("click", () => {
|
|
197
|
+
canvas.setWalkMode(!canvas.getWalkMode());
|
|
198
|
+
walkBtn.classList.toggle("active", canvas.getWalkMode());
|
|
199
|
+
});
|
|
187
200
|
focusIndicator.appendChild(label);
|
|
188
201
|
focusIndicator.appendChild(minus);
|
|
189
202
|
focusIndicator.appendChild(hopsLabel);
|
|
190
203
|
focusIndicator.appendChild(plus);
|
|
204
|
+
focusIndicator.appendChild(walkBtn);
|
|
191
205
|
focusIndicator.appendChild(exit);
|
|
192
206
|
}
|
|
193
207
|
function removeFocusIndicator() {
|
|
@@ -196,8 +210,65 @@ async function main() {
|
|
|
196
210
|
focusIndicator = null;
|
|
197
211
|
}
|
|
198
212
|
}
|
|
213
|
+
// --- Path bar ---
|
|
214
|
+
const pathBar = document.createElement("div");
|
|
215
|
+
pathBar.className = "path-bar hidden";
|
|
216
|
+
canvasContainer.appendChild(pathBar);
|
|
217
|
+
function showPathBar(path) {
|
|
218
|
+
pathBar.innerHTML = "";
|
|
219
|
+
if (!currentData)
|
|
220
|
+
return;
|
|
221
|
+
for (let i = 0; i < path.nodeIds.length; i++) {
|
|
222
|
+
const nodeId = path.nodeIds[i];
|
|
223
|
+
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
224
|
+
if (!node)
|
|
225
|
+
continue;
|
|
226
|
+
const label = Object.values(node.properties).find((v) => typeof v === "string") ?? node.id;
|
|
227
|
+
// Edge label before this node (except the first)
|
|
228
|
+
if (i > 0) {
|
|
229
|
+
const edgeId = path.edgeIds[i - 1];
|
|
230
|
+
const edge = currentData.edges.find((e) => e.id === edgeId);
|
|
231
|
+
const arrow = document.createElement("span");
|
|
232
|
+
arrow.className = "path-bar-edge";
|
|
233
|
+
arrow.textContent = edge ? `→ ${edge.type} →` : "→";
|
|
234
|
+
pathBar.appendChild(arrow);
|
|
235
|
+
}
|
|
236
|
+
const nodeBtn = document.createElement("span");
|
|
237
|
+
nodeBtn.className = "path-bar-node";
|
|
238
|
+
nodeBtn.textContent = label;
|
|
239
|
+
nodeBtn.addEventListener("click", () => canvas.panToNode(nodeId));
|
|
240
|
+
pathBar.appendChild(nodeBtn);
|
|
241
|
+
}
|
|
242
|
+
const closeBtn = document.createElement("button");
|
|
243
|
+
closeBtn.className = "path-bar-close";
|
|
244
|
+
closeBtn.textContent = "\u00d7";
|
|
245
|
+
closeBtn.addEventListener("click", hidePathBar);
|
|
246
|
+
pathBar.appendChild(closeBtn);
|
|
247
|
+
pathBar.classList.remove("hidden");
|
|
248
|
+
}
|
|
249
|
+
function hidePathBar() {
|
|
250
|
+
pathBar.classList.add("hidden");
|
|
251
|
+
pathBar.innerHTML = "";
|
|
252
|
+
canvas.clearHighlightedPath();
|
|
253
|
+
}
|
|
199
254
|
canvas = initCanvas(canvasContainer, (nodeIds) => {
|
|
200
255
|
currentSelection = nodeIds ?? [];
|
|
256
|
+
// Don't touch the path bar when walk mode is active — syncWalkTrail manages it
|
|
257
|
+
if (!canvas.getWalkMode()) {
|
|
258
|
+
if (nodeIds && nodeIds.length === 2) {
|
|
259
|
+
const path = canvas.findPath(nodeIds[0], nodeIds[1]);
|
|
260
|
+
if (path && path.nodeIds.length > 0) {
|
|
261
|
+
canvas.setHighlightedPath(path.nodeIds, path.edgeIds);
|
|
262
|
+
showPathBar(path);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
hidePathBar();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
hidePathBar();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
201
272
|
if (nodeIds && nodeIds.length > 0 && currentData) {
|
|
202
273
|
infoPanel.show(nodeIds, currentData);
|
|
203
274
|
if (mobileQuery.matches)
|
|
@@ -217,14 +288,16 @@ async function main() {
|
|
|
217
288
|
topLeft.appendChild(focusIndicator);
|
|
218
289
|
updateUrl(activeOntology, focus.seedNodeIds);
|
|
219
290
|
infoPanel.setFocusDisabled(focus.hops === 0);
|
|
291
|
+
syncWalkTrail();
|
|
220
292
|
}
|
|
221
293
|
else {
|
|
222
294
|
removeFocusIndicator();
|
|
223
295
|
infoPanel.setFocusDisabled(false);
|
|
224
296
|
if (activeOntology)
|
|
225
297
|
updateUrl(activeOntology);
|
|
298
|
+
syncWalkTrail();
|
|
226
299
|
}
|
|
227
|
-
}, { lod: cfg.lod, navigation: cfg.navigation });
|
|
300
|
+
}, { lod: cfg.lod, navigation: cfg.navigation, walk: cfg.walk });
|
|
228
301
|
const search = initSearch(canvasContainer, {
|
|
229
302
|
maxResults: cfg.limits.maxSearchResults,
|
|
230
303
|
debounceMs: cfg.limits.searchDebounceMs,
|
|
@@ -248,6 +321,41 @@ async function main() {
|
|
|
248
321
|
if (currentData)
|
|
249
322
|
infoPanel.show([nodeId], currentData);
|
|
250
323
|
},
|
|
324
|
+
onWalkTrailRemove(nodeId) {
|
|
325
|
+
canvas.removeFromWalkTrail(nodeId);
|
|
326
|
+
syncWalkTrail();
|
|
327
|
+
},
|
|
328
|
+
onWalkIsolate() {
|
|
329
|
+
if (!currentData)
|
|
330
|
+
return;
|
|
331
|
+
const trail = canvas.getWalkTrail();
|
|
332
|
+
if (trail.length === 0)
|
|
333
|
+
return;
|
|
334
|
+
canvas.enterFocus(trail, 0);
|
|
335
|
+
},
|
|
336
|
+
async onWalkSaveSnippet(label) {
|
|
337
|
+
if (!activeOntology || !currentData)
|
|
338
|
+
return;
|
|
339
|
+
const trail = canvas.getWalkTrail();
|
|
340
|
+
if (trail.length < 2)
|
|
341
|
+
return;
|
|
342
|
+
const nodeSet = new Set(trail);
|
|
343
|
+
const edgeIds = currentData.edges
|
|
344
|
+
.filter((e) => nodeSet.has(e.sourceId) && nodeSet.has(e.targetId))
|
|
345
|
+
.map((e) => e.id);
|
|
346
|
+
await saveSnippet(activeOntology, label, trail, edgeIds);
|
|
347
|
+
await refreshSnippets(activeOntology);
|
|
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
|
+
},
|
|
251
359
|
onFocusChange(seedNodeIds) {
|
|
252
360
|
if (seedNodeIds && seedNodeIds.length > 0) {
|
|
253
361
|
canvas.enterFocus(seedNodeIds, 0);
|
|
@@ -399,7 +507,53 @@ async function main() {
|
|
|
399
507
|
await deleteBranch(graphName, branchName);
|
|
400
508
|
await refreshBranches(graphName);
|
|
401
509
|
},
|
|
510
|
+
onSnippetLoad: async (graphName, snippetId) => {
|
|
511
|
+
const snippet = await loadSnippet(graphName, snippetId);
|
|
512
|
+
if (snippet?.nodeIds?.length > 0) {
|
|
513
|
+
canvas.enterFocus(snippet.nodeIds, 0);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
onSnippetDelete: async (graphName, snippetId) => {
|
|
517
|
+
await deleteSnippet(graphName, snippetId);
|
|
518
|
+
await refreshSnippets(graphName);
|
|
519
|
+
},
|
|
402
520
|
});
|
|
521
|
+
function syncWalkTrail() {
|
|
522
|
+
const trail = canvas.getWalkTrail();
|
|
523
|
+
if (!currentData || trail.length === 0) {
|
|
524
|
+
toolsPane.setWalkTrail([]);
|
|
525
|
+
hidePathBar();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const edgeIds = [];
|
|
529
|
+
const items = trail.map((id, i) => {
|
|
530
|
+
const node = currentData.nodes.find((n) => n.id === id);
|
|
531
|
+
let edgeType;
|
|
532
|
+
if (i > 0) {
|
|
533
|
+
const prevId = trail[i - 1];
|
|
534
|
+
const edge = currentData.edges.find((e) => (e.sourceId === prevId && e.targetId === id) ||
|
|
535
|
+
(e.targetId === prevId && e.sourceId === id));
|
|
536
|
+
edgeType = edge?.type;
|
|
537
|
+
if (edge)
|
|
538
|
+
edgeIds.push(edge.id);
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
id,
|
|
542
|
+
label: node ? (Object.values(node.properties).find((v) => typeof v === "string") ?? node.id) : id,
|
|
543
|
+
type: node?.type ?? "?",
|
|
544
|
+
edgeType,
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
toolsPane.setWalkTrail(items);
|
|
548
|
+
// Show path bar for walk trail
|
|
549
|
+
if (trail.length >= 2) {
|
|
550
|
+
canvas.setHighlightedPath(trail, edgeIds);
|
|
551
|
+
showPathBar({ nodeIds: trail, edgeIds });
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
hidePathBar();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
403
557
|
async function refreshBranches(graphName) {
|
|
404
558
|
const branches = await listBranches(graphName);
|
|
405
559
|
const active = branches.find((b) => b.active);
|
|
@@ -411,8 +565,64 @@ async function main() {
|
|
|
411
565
|
const snaps = await listSnapshots(graphName);
|
|
412
566
|
toolsPane.setSnapshots(snaps);
|
|
413
567
|
}
|
|
568
|
+
async function refreshSnippets(graphName) {
|
|
569
|
+
const snips = await listSnippets(graphName);
|
|
570
|
+
sidebar.setSnippets(graphName, snips);
|
|
571
|
+
}
|
|
572
|
+
// Insert sidebar expand button into top-left bar (before tools toggle)
|
|
573
|
+
topLeft.insertBefore(sidebar.expandBtn, topLeft.firstChild);
|
|
414
574
|
const shortcuts = initShortcuts(canvasContainer, bindings);
|
|
415
575
|
const emptyState = initEmptyState(canvasContainer);
|
|
576
|
+
// Context menu (right-click on nodes)
|
|
577
|
+
const contextMenu = initContextMenu(canvasContainer, {
|
|
578
|
+
onStar(nodeId) {
|
|
579
|
+
if (!currentData)
|
|
580
|
+
return;
|
|
581
|
+
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
582
|
+
if (!node)
|
|
583
|
+
return;
|
|
584
|
+
const starred = node.properties._starred === true;
|
|
585
|
+
node.properties._starred = !starred;
|
|
586
|
+
saveOntology(activeOntology, currentData);
|
|
587
|
+
canvas.loadGraph(currentData);
|
|
588
|
+
},
|
|
589
|
+
onFocusNode(nodeId) {
|
|
590
|
+
toolsPane.addToFocusSet([nodeId]);
|
|
591
|
+
},
|
|
592
|
+
onExploreInBranch(nodeId) {
|
|
593
|
+
// Create a branch and enter focus
|
|
594
|
+
if (activeOntology) {
|
|
595
|
+
const branchName = `explore-${nodeId.slice(0, 8)}`;
|
|
596
|
+
createBranch(activeOntology, branchName).then(() => {
|
|
597
|
+
switchBranch(activeOntology, branchName).then(() => {
|
|
598
|
+
canvas.enterFocus([nodeId], 1);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
onCopyId(nodeId) {
|
|
604
|
+
navigator.clipboard.writeText(nodeId);
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
// Right-click handler for context menu
|
|
608
|
+
canvasContainer.addEventListener("contextmenu", (e) => {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
const canvasEl = canvasContainer.querySelector("canvas");
|
|
611
|
+
if (!canvasEl || !currentData)
|
|
612
|
+
return;
|
|
613
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
614
|
+
const x = e.clientX - rect.left;
|
|
615
|
+
const y = e.clientY - rect.top;
|
|
616
|
+
const hit = canvas.nodeAtScreen(x, y);
|
|
617
|
+
if (!hit)
|
|
618
|
+
return;
|
|
619
|
+
const node = currentData.nodes.find((n) => n.id === hit.id);
|
|
620
|
+
if (!node)
|
|
621
|
+
return;
|
|
622
|
+
const label = Object.values(node.properties).find((v) => typeof v === "string") ?? node.id;
|
|
623
|
+
const isStarred = node.properties._starred === true;
|
|
624
|
+
contextMenu.show(node.id, label, isStarred, e.clientX - rect.left, e.clientY - rect.top);
|
|
625
|
+
});
|
|
416
626
|
// Apply display defaults from config
|
|
417
627
|
if (!cfg.display.edges)
|
|
418
628
|
canvas.setEdges(false);
|
|
@@ -462,12 +672,13 @@ async function main() {
|
|
|
462
672
|
}
|
|
463
673
|
async function selectGraph(name, panToNodeIds, focusSeedIds, focusHops) {
|
|
464
674
|
activeOntology = name;
|
|
675
|
+
activeIsRemote = remoteNames.has(name);
|
|
465
676
|
sidebar.setActive(name);
|
|
466
677
|
infoPanel.hide();
|
|
467
678
|
removeFocusIndicator();
|
|
468
679
|
search.clear();
|
|
469
680
|
undoHistory.clear();
|
|
470
|
-
currentData = await loadOntology(name);
|
|
681
|
+
currentData = activeIsRemote ? await loadRemote(name) : await loadOntology(name);
|
|
471
682
|
const autoParams = autoLayoutParams(currentData.nodes.length);
|
|
472
683
|
setLayoutParams({
|
|
473
684
|
spacing: Math.max(cfg.layout.spacing, autoParams.spacing),
|
|
@@ -478,9 +689,13 @@ async function main() {
|
|
|
478
689
|
toolsPane.setData(currentData);
|
|
479
690
|
emptyState.hide();
|
|
480
691
|
updateUrl(name);
|
|
481
|
-
// Load branches and snapshots
|
|
482
|
-
|
|
483
|
-
|
|
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
|
+
}
|
|
484
699
|
// Restore focus mode if requested
|
|
485
700
|
if (focusSeedIds?.length && currentData) {
|
|
486
701
|
const validFocus = focusSeedIds.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
@@ -504,16 +719,25 @@ async function main() {
|
|
|
504
719
|
}
|
|
505
720
|
}
|
|
506
721
|
}
|
|
507
|
-
// Load ontology list
|
|
508
|
-
const summaries = await
|
|
722
|
+
// Load ontology list (local + remote in parallel)
|
|
723
|
+
const [summaries, remotes] = await Promise.all([
|
|
724
|
+
listOntologies(),
|
|
725
|
+
listRemotes().catch(() => []),
|
|
726
|
+
]);
|
|
509
727
|
sidebar.setSummaries(summaries);
|
|
728
|
+
sidebar.setRemotes(remotes);
|
|
729
|
+
remoteNames = new Set(remotes.map((r) => r.name));
|
|
510
730
|
// Auto-load from URL hash, or first graph
|
|
511
731
|
const initialUrl = parseUrl();
|
|
512
732
|
const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
|
|
513
733
|
? initialUrl.graph
|
|
514
|
-
:
|
|
515
|
-
?
|
|
516
|
-
:
|
|
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;
|
|
517
741
|
if (initialName) {
|
|
518
742
|
await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
|
|
519
743
|
}
|
|
@@ -586,6 +810,26 @@ async function main() {
|
|
|
586
810
|
clusteringIncrease() { const p = getLayoutParams(); setLayoutParams({ clusterStrength: Math.min(1, p.clusterStrength + 0.03) }); canvas.reheat(); },
|
|
587
811
|
help() { shortcuts.toggle(); },
|
|
588
812
|
toggleSidebar() { sidebar.toggle(); },
|
|
813
|
+
walkIsolate() {
|
|
814
|
+
if (!currentData)
|
|
815
|
+
return;
|
|
816
|
+
const trail = canvas.getWalkTrail();
|
|
817
|
+
if (trail.length === 0)
|
|
818
|
+
return;
|
|
819
|
+
// Extract a subgraph of only the trail nodes and edges between them, re-layout as a fresh graph
|
|
820
|
+
canvas.enterFocus(trail, 0);
|
|
821
|
+
},
|
|
822
|
+
walkMode() {
|
|
823
|
+
// If not in focus mode, enter focus on current selection first
|
|
824
|
+
if (!canvas.isFocused() && currentSelection.length > 0) {
|
|
825
|
+
toolsPane.addToFocusSet(currentSelection);
|
|
826
|
+
}
|
|
827
|
+
canvas.setWalkMode(!canvas.getWalkMode());
|
|
828
|
+
const walkBtn = canvasContainer.querySelector(".walk-indicator");
|
|
829
|
+
if (walkBtn)
|
|
830
|
+
walkBtn.classList.toggle("active", canvas.getWalkMode());
|
|
831
|
+
syncWalkTrail();
|
|
832
|
+
},
|
|
589
833
|
escape() { if (canvas.isFocused()) {
|
|
590
834
|
toolsPane.clearFocusSet();
|
|
591
835
|
}
|
|
@@ -628,13 +872,20 @@ async function main() {
|
|
|
628
872
|
// Live reload — when Claude adds nodes via MCP, re-fetch and re-render
|
|
629
873
|
if (import.meta.hot) {
|
|
630
874
|
import.meta.hot.on("ontology-change", async () => {
|
|
631
|
-
const updated = await
|
|
875
|
+
const [updated, updatedRemotes] = await Promise.all([
|
|
876
|
+
listOntologies(),
|
|
877
|
+
listRemotes().catch(() => []),
|
|
878
|
+
]);
|
|
632
879
|
sidebar.setSummaries(updated);
|
|
633
|
-
|
|
880
|
+
sidebar.setRemotes(updatedRemotes);
|
|
881
|
+
remoteNames = new Set(updatedRemotes.map((r) => r.name));
|
|
882
|
+
if (updated.length > 0 || updatedRemotes.length > 0)
|
|
634
883
|
emptyState.hide();
|
|
635
884
|
if (activeOntology) {
|
|
636
885
|
try {
|
|
637
|
-
currentData =
|
|
886
|
+
currentData = activeIsRemote
|
|
887
|
+
? await loadRemote(activeOntology)
|
|
888
|
+
: await loadOntology(activeOntology);
|
|
638
889
|
canvas.loadGraph(currentData);
|
|
639
890
|
search.setLearningGraphData(currentData);
|
|
640
891
|
toolsPane.setData(currentData);
|
|
@@ -645,6 +896,7 @@ async function main() {
|
|
|
645
896
|
}
|
|
646
897
|
else if (updated.length > 0) {
|
|
647
898
|
activeOntology = updated[0].name;
|
|
899
|
+
activeIsRemote = false;
|
|
648
900
|
sidebar.setActive(activeOntology);
|
|
649
901
|
currentData = await loadOntology(activeOntology);
|
|
650
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/shortcuts.js
CHANGED
package/dist/sidebar.d.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
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;
|
|
5
6
|
onBranchSwitch?: (graphName: string, branchName: string) => void;
|
|
6
7
|
onBranchCreate?: (graphName: string, branchName: string) => void;
|
|
7
8
|
onBranchDelete?: (graphName: string, branchName: string) => void;
|
|
9
|
+
onSnippetLoad?: (graphName: string, snippetId: string) => void;
|
|
10
|
+
onSnippetDelete?: (graphName: string, snippetId: string) => void;
|
|
8
11
|
}
|
|
9
12
|
export declare function initSidebar(container: HTMLElement, onSelectOrCallbacks: ((name: string) => void) | SidebarCallbacks): {
|
|
10
13
|
setSummaries(summaries: LearningGraphSummary[]): void;
|
|
11
14
|
setActive(name: string): void;
|
|
15
|
+
setRemotes(remotes: RemoteSummary[]): void;
|
|
12
16
|
setActiveBranch(graphName: string, branchName: string, allBranches?: {
|
|
13
17
|
name: string;
|
|
14
18
|
active: boolean;
|
|
15
19
|
}[]): void;
|
|
20
|
+
setSnippets(graphName: string, snippets: {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
nodeCount: number;
|
|
24
|
+
}[]): void;
|
|
16
25
|
toggle: () => void;
|
|
26
|
+
expandBtn: HTMLButtonElement;
|
|
17
27
|
};
|