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.
@@ -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">
@@ -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.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px";
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.style.cssText = "font-size:12px;color:var(--text-dim)";
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 — all pairs (stronger between different types)
113
- for (let i = 0; i < nodes.length; i++) {
114
- for (let j = i + 1; j < nodes.length; j++) {
115
- const a = nodes[i];
116
- const b = nodes[j];
117
- let dx = b.x - a.x;
118
- let dy = b.y - a.y;
119
- let dist = Math.sqrt(dx * dx + dy * dy);
120
- if (dist < MIN_DISTANCE)
121
- dist = MIN_DISTANCE;
122
- const rep = a.type === b.type ? REPULSION : CROSS_TYPE_REPULSION_BASE * params.spacing;
123
- const force = (rep * alpha) / (dist * dist);
124
- const fx = (dx / dist) * force;
125
- const fy = (dy / dist) * force;
126
- a.vx -= fx;
127
- a.vy -= fy;
128
- b.vx += fx;
129
- b.vy += fy;
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
- await refreshBranches(name);
681
- await refreshSnapshots(name);
682
- await refreshSnippets(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
+ }
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 listOntologies();
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
- : summaries.length > 0
714
- ? summaries[0].name
715
- : 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;
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 listOntologies();
875
+ const [updated, updatedRemotes] = await Promise.all([
876
+ listOntologies(),
877
+ listRemotes().catch(() => []),
878
+ ]);
851
879
  sidebar.setSummaries(updated);
852
- 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)
853
883
  emptyState.hide();
854
884
  if (activeOntology) {
855
885
  try {
856
- currentData = await loadOntology(activeOntology);
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 {};
@@ -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;