backpack-viewer 0.2.11 → 0.2.14

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/canvas.js CHANGED
@@ -16,6 +16,9 @@ export function initCanvas(container, onNodeClick) {
16
16
  let animFrame = 0;
17
17
  let selectedNodeIds = new Set();
18
18
  let filteredNodeIds = null; // null = no filter (show all)
19
+ let showEdgeLabels = true;
20
+ let showTypeHulls = true;
21
+ let showMinimap = true;
19
22
  // Pan animation state
20
23
  let panTarget = null;
21
24
  let panStart = null;
@@ -79,6 +82,51 @@ export function initCanvas(container, onNodeClick) {
79
82
  ctx.save();
80
83
  ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
81
84
  ctx.scale(camera.scale, camera.scale);
85
+ // Draw type hulls (shaded regions behind same-type nodes)
86
+ if (showTypeHulls) {
87
+ const typeGroups = new Map();
88
+ for (const node of state.nodes) {
89
+ if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
90
+ continue;
91
+ const group = typeGroups.get(node.type) ?? [];
92
+ group.push(node);
93
+ typeGroups.set(node.type, group);
94
+ }
95
+ for (const [type, nodes] of typeGroups) {
96
+ if (nodes.length < 2)
97
+ continue;
98
+ const color = getColor(type);
99
+ const padding = NODE_RADIUS * 2.5;
100
+ // Compute bounding box
101
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
102
+ for (const n of nodes) {
103
+ if (n.x < minX)
104
+ minX = n.x;
105
+ if (n.y < minY)
106
+ minY = n.y;
107
+ if (n.x > maxX)
108
+ maxX = n.x;
109
+ if (n.y > maxY)
110
+ maxY = n.y;
111
+ }
112
+ ctx.beginPath();
113
+ const rx = (maxX - minX) / 2 + padding;
114
+ const ry = (maxY - minY) / 2 + padding;
115
+ const cx = (minX + maxX) / 2;
116
+ const cy = (minY + maxY) / 2;
117
+ ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
118
+ ctx.fillStyle = color;
119
+ ctx.globalAlpha = 0.05;
120
+ ctx.fill();
121
+ ctx.strokeStyle = color;
122
+ ctx.globalAlpha = 0.12;
123
+ ctx.lineWidth = 1;
124
+ ctx.setLineDash([4, 4]);
125
+ ctx.stroke();
126
+ ctx.setLineDash([]);
127
+ ctx.globalAlpha = 1;
128
+ }
129
+ }
82
130
  // Draw edges
83
131
  for (const edge of state.edges) {
84
132
  const source = state.nodeMap.get(edge.sourceId);
@@ -114,17 +162,19 @@ export function initCanvas(container, onNodeClick) {
114
162
  // Arrowhead
115
163
  drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
116
164
  // Edge label at midpoint
117
- const mx = (source.x + target.x) / 2;
118
- const my = (source.y + target.y) / 2;
119
- ctx.fillStyle = highlighted
120
- ? edgeLabelHighlight
121
- : edgeDimmed
122
- ? edgeLabelDim
123
- : edgeLabel;
124
- ctx.font = "9px system-ui, sans-serif";
125
- ctx.textAlign = "center";
126
- ctx.textBaseline = "bottom";
127
- ctx.fillText(edge.type, mx, my - 4);
165
+ if (showEdgeLabels) {
166
+ const mx = (source.x + target.x) / 2;
167
+ const my = (source.y + target.y) / 2;
168
+ ctx.fillStyle = highlighted
169
+ ? edgeLabelHighlight
170
+ : edgeDimmed
171
+ ? edgeLabelDim
172
+ : edgeLabel;
173
+ ctx.font = "9px system-ui, sans-serif";
174
+ ctx.textAlign = "center";
175
+ ctx.textBaseline = "bottom";
176
+ ctx.fillText(edge.type, mx, my - 4);
177
+ }
128
178
  }
129
179
  // Draw nodes
130
180
  for (const node of state.nodes) {
@@ -173,6 +223,87 @@ export function initCanvas(container, onNodeClick) {
173
223
  }
174
224
  ctx.restore();
175
225
  ctx.restore();
226
+ // Minimap
227
+ if (showMinimap && state.nodes.length > 1) {
228
+ drawMinimap();
229
+ }
230
+ }
231
+ function drawMinimap() {
232
+ if (!state)
233
+ return;
234
+ const mapW = 140;
235
+ const mapH = 100;
236
+ const mapPad = 8;
237
+ const mapX = canvas.clientWidth - mapW - 16;
238
+ const mapY = canvas.clientHeight - mapH - 16;
239
+ // Compute graph bounds
240
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
241
+ for (const n of state.nodes) {
242
+ if (n.x < minX)
243
+ minX = n.x;
244
+ if (n.y < minY)
245
+ minY = n.y;
246
+ if (n.x > maxX)
247
+ maxX = n.x;
248
+ if (n.y > maxY)
249
+ maxY = n.y;
250
+ }
251
+ const gw = maxX - minX || 1;
252
+ const gh = maxY - minY || 1;
253
+ const scale = Math.min((mapW - mapPad * 2) / gw, (mapH - mapPad * 2) / gh);
254
+ const offsetX = mapX + mapPad + ((mapW - mapPad * 2) - gw * scale) / 2;
255
+ const offsetY = mapY + mapPad + ((mapH - mapPad * 2) - gh * scale) / 2;
256
+ ctx.save();
257
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
258
+ // Background
259
+ ctx.fillStyle = cssVar("--bg-surface") || "#1a1a1a";
260
+ ctx.globalAlpha = 0.85;
261
+ ctx.beginPath();
262
+ ctx.roundRect(mapX, mapY, mapW, mapH, 8);
263
+ ctx.fill();
264
+ ctx.strokeStyle = cssVar("--border") || "#2a2a2a";
265
+ ctx.globalAlpha = 1;
266
+ ctx.lineWidth = 1;
267
+ ctx.stroke();
268
+ // Edges
269
+ ctx.globalAlpha = 0.15;
270
+ ctx.strokeStyle = cssVar("--canvas-edge") || "#555";
271
+ ctx.lineWidth = 0.5;
272
+ for (const edge of state.edges) {
273
+ const src = state.nodeMap.get(edge.sourceId);
274
+ const tgt = state.nodeMap.get(edge.targetId);
275
+ if (!src || !tgt || edge.sourceId === edge.targetId)
276
+ continue;
277
+ ctx.beginPath();
278
+ ctx.moveTo(offsetX + (src.x - minX) * scale, offsetY + (src.y - minY) * scale);
279
+ ctx.lineTo(offsetX + (tgt.x - minX) * scale, offsetY + (tgt.y - minY) * scale);
280
+ ctx.stroke();
281
+ }
282
+ // Nodes
283
+ ctx.globalAlpha = 0.8;
284
+ for (const node of state.nodes) {
285
+ const nx = offsetX + (node.x - minX) * scale;
286
+ const ny = offsetY + (node.y - minY) * scale;
287
+ ctx.beginPath();
288
+ ctx.arc(nx, ny, 2, 0, Math.PI * 2);
289
+ ctx.fillStyle = getColor(node.type);
290
+ ctx.fill();
291
+ }
292
+ // Viewport rectangle
293
+ const vx1 = camera.x;
294
+ const vy1 = camera.y;
295
+ const vx2 = camera.x + canvas.clientWidth / camera.scale;
296
+ const vy2 = camera.y + canvas.clientHeight / camera.scale;
297
+ const rx = offsetX + (vx1 - minX) * scale;
298
+ const ry = offsetY + (vy1 - minY) * scale;
299
+ const rw = (vx2 - vx1) * scale;
300
+ const rh = (vy2 - vy1) * scale;
301
+ ctx.globalAlpha = 0.3;
302
+ ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
303
+ ctx.lineWidth = 1.5;
304
+ ctx.strokeRect(Math.max(mapX, Math.min(rx, mapX + mapW)), Math.max(mapY, Math.min(ry, mapY + mapH)), Math.min(rw, mapW), Math.min(rh, mapH));
305
+ ctx.globalAlpha = 1;
306
+ ctx.restore();
176
307
  }
177
308
  function drawArrowhead(sx, sy, tx, ty, highlighted, arrowColor, arrowHighlight) {
178
309
  const angle = Math.atan2(ty - sy, tx - sx);
@@ -195,10 +326,12 @@ export function initCanvas(container, onNodeClick) {
195
326
  ctx.strokeStyle = highlighted ? edgeHighlight : edgeColor;
196
327
  ctx.lineWidth = highlighted ? 2.5 : 1.5;
197
328
  ctx.stroke();
198
- ctx.fillStyle = highlighted ? labelHighlight : labelColor;
199
- ctx.font = "9px system-ui, sans-serif";
200
- ctx.textAlign = "center";
201
- ctx.fillText(type, cx, cy - 18);
329
+ if (showEdgeLabels) {
330
+ ctx.fillStyle = highlighted ? labelHighlight : labelColor;
331
+ ctx.font = "9px system-ui, sans-serif";
332
+ ctx.textAlign = "center";
333
+ ctx.fillText(type, cx, cy - 18);
334
+ }
202
335
  }
203
336
  // --- Simulation loop ---
204
337
  function animatePan() {
@@ -313,6 +446,9 @@ export function initCanvas(container, onNodeClick) {
313
446
  let touches = [];
314
447
  let initialPinchDist = 0;
315
448
  let initialPinchScale = 1;
449
+ let touchStartX = 0;
450
+ let touchStartY = 0;
451
+ let touchDidMove = false;
316
452
  canvas.addEventListener("touchstart", (e) => {
317
453
  e.preventDefault();
318
454
  touches = Array.from(e.touches);
@@ -323,6 +459,9 @@ export function initCanvas(container, onNodeClick) {
323
459
  else if (touches.length === 1) {
324
460
  lastX = touches[0].clientX;
325
461
  lastY = touches[0].clientY;
462
+ touchStartX = touches[0].clientX;
463
+ touchStartY = touches[0].clientY;
464
+ touchDidMove = false;
326
465
  }
327
466
  }, { passive: false });
328
467
  canvas.addEventListener("touchmove", (e) => {
@@ -337,6 +476,10 @@ export function initCanvas(container, onNodeClick) {
337
476
  else if (current.length === 1) {
338
477
  const dx = current[0].clientX - lastX;
339
478
  const dy = current[0].clientY - lastY;
479
+ if (Math.abs(current[0].clientX - touchStartX) > 10 ||
480
+ Math.abs(current[0].clientY - touchStartY) > 10) {
481
+ touchDidMove = true;
482
+ }
340
483
  camera.x -= dx / camera.scale;
341
484
  camera.y -= dy / camera.scale;
342
485
  lastX = current[0].clientX;
@@ -345,6 +488,35 @@ export function initCanvas(container, onNodeClick) {
345
488
  }
346
489
  touches = current;
347
490
  }, { passive: false });
491
+ canvas.addEventListener("touchend", (e) => {
492
+ e.preventDefault();
493
+ if (touchDidMove || e.changedTouches.length !== 1)
494
+ return;
495
+ const t = e.changedTouches[0];
496
+ const rect = canvas.getBoundingClientRect();
497
+ const mx = t.clientX - rect.left;
498
+ const my = t.clientY - rect.top;
499
+ const hit = nodeAtScreen(mx, my);
500
+ if (hit) {
501
+ if (selectedNodeIds.size === 1 && selectedNodeIds.has(hit.id)) {
502
+ selectedNodeIds.clear();
503
+ }
504
+ else {
505
+ selectedNodeIds.clear();
506
+ selectedNodeIds.add(hit.id);
507
+ }
508
+ const ids = [...selectedNodeIds];
509
+ onNodeClick?.(ids.length > 0 ? ids : null);
510
+ }
511
+ else {
512
+ selectedNodeIds.clear();
513
+ onNodeClick?.(null);
514
+ }
515
+ render();
516
+ }, { passive: false });
517
+ // Prevent Safari page-level pinch zoom on the canvas
518
+ canvas.addEventListener("gesturestart", (e) => e.preventDefault());
519
+ canvas.addEventListener("gesturechange", (e) => e.preventDefault());
348
520
  function touchDistance(a, b) {
349
521
  const dx = a.clientX - b.clientX;
350
522
  const dy = a.clientY - b.clientY;
@@ -446,25 +618,112 @@ export function initCanvas(container, onNodeClick) {
446
618
  render();
447
619
  },
448
620
  panToNode(nodeId) {
449
- if (!state)
621
+ this.panToNodes([nodeId]);
622
+ },
623
+ panToNodes(nodeIds) {
624
+ if (!state || nodeIds.length === 0)
450
625
  return;
451
- const node = state.nodeMap.get(nodeId);
452
- if (!node)
626
+ const nodes = nodeIds.map((id) => state.nodeMap.get(id)).filter(Boolean);
627
+ if (nodes.length === 0)
453
628
  return;
454
- selectedNodeIds = new Set([nodeId]);
455
- onNodeClick?.([nodeId]);
629
+ selectedNodeIds = new Set(nodeIds);
630
+ onNodeClick?.(nodeIds);
456
631
  const w = canvas.clientWidth;
457
632
  const h = canvas.clientHeight;
458
- panStart = { x: camera.x, y: camera.y, time: performance.now() };
459
- panTarget = {
460
- x: node.x - w / (2 * camera.scale),
461
- y: node.y - h / (2 * camera.scale),
462
- };
633
+ if (nodes.length === 1) {
634
+ panStart = { x: camera.x, y: camera.y, time: performance.now() };
635
+ panTarget = {
636
+ x: nodes[0].x - w / (2 * camera.scale),
637
+ y: nodes[0].y - h / (2 * camera.scale),
638
+ };
639
+ }
640
+ else {
641
+ // Fit all nodes in view with padding
642
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
643
+ for (const n of nodes) {
644
+ if (n.x < minX)
645
+ minX = n.x;
646
+ if (n.y < minY)
647
+ minY = n.y;
648
+ if (n.x > maxX)
649
+ maxX = n.x;
650
+ if (n.y > maxY)
651
+ maxY = n.y;
652
+ }
653
+ const pad = NODE_RADIUS * 4;
654
+ const bw = maxX - minX + pad * 2;
655
+ const bh = maxY - minY + pad * 2;
656
+ const fitScale = Math.min(w / bw, h / bh, camera.scale);
657
+ camera.scale = fitScale;
658
+ const cx = (minX + maxX) / 2;
659
+ const cy = (minY + maxY) / 2;
660
+ panStart = { x: camera.x, y: camera.y, time: performance.now() };
661
+ panTarget = {
662
+ x: cx - w / (2 * camera.scale),
663
+ y: cy - h / (2 * camera.scale),
664
+ };
665
+ }
463
666
  animatePan();
464
667
  },
668
+ setEdgeLabels(visible) {
669
+ showEdgeLabels = visible;
670
+ render();
671
+ },
672
+ setTypeHulls(visible) {
673
+ showTypeHulls = visible;
674
+ render();
675
+ },
676
+ setMinimap(visible) {
677
+ showMinimap = visible;
678
+ render();
679
+ },
680
+ reheat() {
681
+ alpha = 0.5;
682
+ cancelAnimationFrame(animFrame);
683
+ simulate();
684
+ },
685
+ exportImage(format) {
686
+ if (!state)
687
+ return "";
688
+ // Use the actual canvas pixel dimensions (already scaled by dpr)
689
+ const pw = canvas.width;
690
+ const ph = canvas.height;
691
+ if (format === "png") {
692
+ const exportCanvas = document.createElement("canvas");
693
+ exportCanvas.width = pw;
694
+ exportCanvas.height = ph;
695
+ const ectx = exportCanvas.getContext("2d");
696
+ // Draw background
697
+ ectx.fillStyle = cssVar("--bg") || "#141414";
698
+ ectx.fillRect(0, 0, pw, ph);
699
+ // Copy current canvas pixels 1:1
700
+ ectx.drawImage(canvas, 0, 0);
701
+ // Watermark (scale font to match pixel density)
702
+ drawWatermark(ectx, pw, ph);
703
+ return exportCanvas.toDataURL("image/png");
704
+ }
705
+ // SVG: embed the canvas as a PNG image with text overlay
706
+ const dataUrl = canvas.toDataURL("image/png");
707
+ const fontSize = Math.max(16, Math.round(pw / 80));
708
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}">
709
+ <image href="${dataUrl}" width="${pw}" height="${ph}"/>
710
+ <text x="${pw - 20}" y="${ph - 16}" text-anchor="end" font-family="system-ui, sans-serif" font-size="${fontSize}" fill="#ffffff" opacity="0.4">backpackontology.com</text>
711
+ </svg>`;
712
+ return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
713
+ },
465
714
  destroy() {
466
715
  cancelAnimationFrame(animFrame);
467
716
  observer.disconnect();
468
717
  },
469
718
  };
719
+ function drawWatermark(ectx, w, h) {
720
+ const fontSize = Math.max(16, Math.round(w / 80));
721
+ ectx.save();
722
+ ectx.font = `${fontSize}px system-ui, sans-serif`;
723
+ ectx.fillStyle = "rgba(255, 255, 255, 0.4)";
724
+ ectx.textAlign = "right";
725
+ ectx.textBaseline = "bottom";
726
+ ectx.fillText("backpackontology.com", w - 20, h - 16);
727
+ ectx.restore();
728
+ }
470
729
  }
@@ -0,0 +1,4 @@
1
+ export declare function initEmptyState(container: HTMLElement): {
2
+ show(): void;
3
+ hide(): void;
4
+ };
@@ -0,0 +1,27 @@
1
+ export function initEmptyState(container) {
2
+ const el = document.createElement("div");
3
+ el.className = "empty-state";
4
+ el.innerHTML = `
5
+ <div class="empty-state-content">
6
+ <div class="empty-state-icon">
7
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
8
+ <path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 002 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0022 16z"/>
9
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
10
+ <line x1="12" y1="22.08" x2="12" y2="12"/>
11
+ </svg>
12
+ </div>
13
+ <h2 class="empty-state-title">No learning graphs yet</h2>
14
+ <p class="empty-state-desc">Connect Backpack to Claude, then start a conversation. Claude will build your first learning graph automatically.</p>
15
+ <div class="empty-state-setup">
16
+ <div class="empty-state-label">Add Backpack to Claude Code:</div>
17
+ <code class="empty-state-code">claude mcp add backpack-local -s user -- npx backpack-ontology@latest</code>
18
+ </div>
19
+ <p class="empty-state-hint">Press <kbd>?</kbd> for keyboard shortcuts</p>
20
+ </div>
21
+ `;
22
+ container.appendChild(el);
23
+ return {
24
+ show() { el.classList.remove("hidden"); },
25
+ hide() { el.classList.add("hidden"); },
26
+ };
27
+ }
@@ -0,0 +1,10 @@
1
+ import type { LearningGraphData } from "backpack-ontology";
2
+ export declare function createHistory(): {
3
+ /** Call before mutating the data to snapshot the current state. */
4
+ push(data: LearningGraphData): void;
5
+ undo(currentData: LearningGraphData): LearningGraphData | null;
6
+ redo(currentData: LearningGraphData): LearningGraphData | null;
7
+ canUndo(): boolean;
8
+ canRedo(): boolean;
9
+ clear(): void;
10
+ };
@@ -0,0 +1,36 @@
1
+ const MAX_HISTORY = 30;
2
+ export function createHistory() {
3
+ let undoStack = [];
4
+ let redoStack = [];
5
+ return {
6
+ /** Call before mutating the data to snapshot the current state. */
7
+ push(data) {
8
+ undoStack.push(JSON.stringify(data));
9
+ if (undoStack.length > MAX_HISTORY)
10
+ undoStack.shift();
11
+ redoStack = [];
12
+ },
13
+ undo(currentData) {
14
+ if (undoStack.length === 0)
15
+ return null;
16
+ redoStack.push(JSON.stringify(currentData));
17
+ return JSON.parse(undoStack.pop());
18
+ },
19
+ redo(currentData) {
20
+ if (redoStack.length === 0)
21
+ return null;
22
+ undoStack.push(JSON.stringify(currentData));
23
+ return JSON.parse(redoStack.pop());
24
+ },
25
+ canUndo() {
26
+ return undoStack.length > 0;
27
+ },
28
+ canRedo() {
29
+ return redoStack.length > 0;
30
+ },
31
+ clear() {
32
+ undoStack = [];
33
+ redoStack = [];
34
+ },
35
+ };
36
+ }
@@ -1,4 +1,4 @@
1
- import type { OntologyData } from "backpack-ontology";
1
+ import type { LearningGraphData } from "backpack-ontology";
2
2
  export interface EditCallbacks {
3
3
  onUpdateNode(nodeId: string, properties: Record<string, unknown>): void;
4
4
  onChangeNodeType(nodeId: string, newType: string): void;
@@ -7,7 +7,7 @@ export interface EditCallbacks {
7
7
  onAddProperty(nodeId: string, key: string, value: string): void;
8
8
  }
9
9
  export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void): {
10
- show(nodeIds: string[], data: OntologyData): void;
10
+ show(nodeIds: string[], data: LearningGraphData): void;
11
11
  hide: () => void;
12
12
  readonly visible: boolean;
13
13
  };
package/dist/layout.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OntologyData } from "backpack-ontology";
1
+ import type { LearningGraphData } from "backpack-ontology";
2
2
  export interface LayoutNode {
3
3
  id: string;
4
4
  x: number;
@@ -18,7 +18,14 @@ export interface LayoutState {
18
18
  edges: LayoutEdge[];
19
19
  nodeMap: Map<string, LayoutNode>;
20
20
  }
21
- /** Create a layout state from ontology data. Nodes start in a circle. */
22
- export declare function createLayout(data: OntologyData): LayoutState;
21
+ export interface LayoutParams {
22
+ clusterStrength: number;
23
+ spacing: number;
24
+ }
25
+ export declare const DEFAULT_LAYOUT_PARAMS: LayoutParams;
26
+ export declare function setLayoutParams(p: Partial<LayoutParams>): void;
27
+ export declare function getLayoutParams(): LayoutParams;
28
+ /** Create a layout state from ontology data. Nodes start grouped by type. */
29
+ export declare function createLayout(data: LearningGraphData): LayoutState;
23
30
  /** Run one tick of the force simulation. Returns new alpha. */
24
31
  export declare function tick(state: LayoutState, alpha: number): number;
package/dist/layout.js CHANGED
@@ -1,10 +1,27 @@
1
+ export const DEFAULT_LAYOUT_PARAMS = {
2
+ clusterStrength: 0.05,
3
+ spacing: 1,
4
+ };
1
5
  const REPULSION = 5000;
6
+ const CROSS_TYPE_REPULSION_BASE = 8000;
2
7
  const ATTRACTION = 0.005;
3
- const REST_LENGTH = 150;
8
+ const REST_LENGTH_SAME_BASE = 100;
9
+ const REST_LENGTH_CROSS_BASE = 250;
4
10
  const DAMPING = 0.9;
5
11
  const CENTER_GRAVITY = 0.01;
6
12
  const MIN_DISTANCE = 30;
7
13
  const MAX_VELOCITY = 50;
14
+ // Active params — mutated by setLayoutParams()
15
+ let params = { ...DEFAULT_LAYOUT_PARAMS };
16
+ export function setLayoutParams(p) {
17
+ if (p.clusterStrength !== undefined)
18
+ params.clusterStrength = p.clusterStrength;
19
+ if (p.spacing !== undefined)
20
+ params.spacing = p.spacing;
21
+ }
22
+ export function getLayoutParams() {
23
+ return { ...params };
24
+ }
8
25
  /** Extract a display label from a node — first string property value, fallback to id. */
9
26
  function nodeLabel(properties, id) {
10
27
  for (const value of Object.values(properties)) {
@@ -13,16 +30,31 @@ function nodeLabel(properties, id) {
13
30
  }
14
31
  return id;
15
32
  }
16
- /** Create a layout state from ontology data. Nodes start in a circle. */
33
+ /** Create a layout state from ontology data. Nodes start grouped by type. */
17
34
  export function createLayout(data) {
18
- const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
19
35
  const nodeMap = new Map();
20
- const nodes = data.nodes.map((n, i) => {
21
- const angle = (2 * Math.PI * i) / data.nodes.length;
36
+ // Group nodes by type for initial placement
37
+ const types = [...new Set(data.nodes.map((n) => n.type))];
38
+ const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
39
+ const typeCounters = new Map();
40
+ const typeSizes = new Map();
41
+ for (const n of data.nodes) {
42
+ typeSizes.set(n.type, (typeSizes.get(n.type) ?? 0) + 1);
43
+ }
44
+ const nodes = data.nodes.map((n) => {
45
+ const ti = types.indexOf(n.type);
46
+ const typeAngle = (2 * Math.PI * ti) / Math.max(types.length, 1);
47
+ const cx = Math.cos(typeAngle) * typeRadius;
48
+ const cy = Math.sin(typeAngle) * typeRadius;
49
+ const ni = typeCounters.get(n.type) ?? 0;
50
+ typeCounters.set(n.type, ni + 1);
51
+ const groupSize = typeSizes.get(n.type) ?? 1;
52
+ const nodeAngle = (2 * Math.PI * ni) / groupSize;
53
+ const nodeRadius = REST_LENGTH_SAME_BASE * 0.6;
22
54
  const node = {
23
55
  id: n.id,
24
- x: Math.cos(angle) * radius,
25
- y: Math.sin(angle) * radius,
56
+ x: cx + Math.cos(nodeAngle) * nodeRadius,
57
+ y: cy + Math.sin(nodeAngle) * nodeRadius,
26
58
  vx: 0,
27
59
  vy: 0,
28
60
  label: nodeLabel(n.properties, n.id),
@@ -41,7 +73,7 @@ export function createLayout(data) {
41
73
  /** Run one tick of the force simulation. Returns new alpha. */
42
74
  export function tick(state, alpha) {
43
75
  const { nodes, edges, nodeMap } = state;
44
- // Repulsion — all pairs
76
+ // Repulsion — all pairs (stronger between different types)
45
77
  for (let i = 0; i < nodes.length; i++) {
46
78
  for (let j = i + 1; j < nodes.length; j++) {
47
79
  const a = nodes[i];
@@ -51,7 +83,8 @@ export function tick(state, alpha) {
51
83
  let dist = Math.sqrt(dx * dx + dy * dy);
52
84
  if (dist < MIN_DISTANCE)
53
85
  dist = MIN_DISTANCE;
54
- const force = (REPULSION * alpha) / (dist * dist);
86
+ const rep = a.type === b.type ? REPULSION : CROSS_TYPE_REPULSION_BASE * params.spacing;
87
+ const force = (rep * alpha) / (dist * dist);
55
88
  const fx = (dx / dist) * force;
56
89
  const fy = (dy / dist) * force;
57
90
  a.vx -= fx;
@@ -60,7 +93,7 @@ export function tick(state, alpha) {
60
93
  b.vy += fy;
61
94
  }
62
95
  }
63
- // Attraction — along edges
96
+ // Attraction — along edges (shorter rest length within same type)
64
97
  for (const edge of edges) {
65
98
  const source = nodeMap.get(edge.sourceId);
66
99
  const target = nodeMap.get(edge.targetId);
@@ -71,7 +104,10 @@ export function tick(state, alpha) {
71
104
  const dist = Math.sqrt(dx * dx + dy * dy);
72
105
  if (dist === 0)
73
106
  continue;
74
- const force = ATTRACTION * (dist - REST_LENGTH) * alpha;
107
+ const restLen = source.type === target.type
108
+ ? REST_LENGTH_SAME_BASE * params.spacing
109
+ : REST_LENGTH_CROSS_BASE * params.spacing;
110
+ const force = ATTRACTION * (dist - restLen) * alpha;
75
111
  const fx = (dx / dist) * force;
76
112
  const fy = (dy / dist) * force;
77
113
  source.vx += fx;
@@ -84,6 +120,24 @@ export function tick(state, alpha) {
84
120
  node.vx -= node.x * CENTER_GRAVITY * alpha;
85
121
  node.vy -= node.y * CENTER_GRAVITY * alpha;
86
122
  }
123
+ // Cluster force — pull nodes toward their type centroid
124
+ const centroids = new Map();
125
+ for (const node of nodes) {
126
+ const c = centroids.get(node.type) ?? { x: 0, y: 0, count: 0 };
127
+ c.x += node.x;
128
+ c.y += node.y;
129
+ c.count++;
130
+ centroids.set(node.type, c);
131
+ }
132
+ for (const c of centroids.values()) {
133
+ c.x /= c.count;
134
+ c.y /= c.count;
135
+ }
136
+ for (const node of nodes) {
137
+ const c = centroids.get(node.type);
138
+ node.vx += (c.x - node.x) * params.clusterStrength * alpha;
139
+ node.vy += (c.y - node.y) * params.clusterStrength * alpha;
140
+ }
87
141
  // Integrate — update positions, apply damping, clamp velocity
88
142
  for (const node of nodes) {
89
143
  node.vx *= DAMPING;