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/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
- await refreshBranches(name);
483
- await refreshSnapshots(name);
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 listOntologies();
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
- : summaries.length > 0
515
- ? summaries[0].name
516
- : null;
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 listOntologies();
875
+ const [updated, updatedRemotes] = await Promise.all([
876
+ listOntologies(),
877
+ listRemotes().catch(() => []),
878
+ ]);
632
879
  sidebar.setSummaries(updated);
633
- if (updated.length > 0)
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 = await loadOntology(activeOntology);
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 {};
@@ -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
@@ -18,6 +18,8 @@ const ACTION_ORDER = [
18
18
  "spacingDecrease", "spacingIncrease",
19
19
  "clusteringDecrease", "clusteringIncrease",
20
20
  "toggleSidebar",
21
+ "walkMode",
22
+ "walkIsolate",
21
23
  "escape",
22
24
  ];
23
25
  /** Format a binding string for display (e.g. "ctrl+z" → "Ctrl+Z"). */
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
  };