backpack-viewer 0.2.13 → 0.2.15

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
@@ -1,4 +1,4 @@
1
- import { createLayout, tick } from "./layout";
1
+ import { createLayout, extractSubgraph, tick } from "./layout";
2
2
  import { getColor } from "./colors";
3
3
  /** Read a CSS custom property from :root. */
4
4
  function cssVar(name) {
@@ -6,7 +6,7 @@ function cssVar(name) {
6
6
  }
7
7
  const NODE_RADIUS = 20;
8
8
  const ALPHA_MIN = 0.001;
9
- export function initCanvas(container, onNodeClick) {
9
+ export function initCanvas(container, onNodeClick, onFocusChange) {
10
10
  const canvas = container.querySelector("canvas");
11
11
  const ctx = canvas.getContext("2d");
12
12
  const dpr = window.devicePixelRatio || 1;
@@ -16,6 +16,15 @@ 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;
22
+ // Focus mode state
23
+ let lastLoadedData = null;
24
+ let focusSeedIds = null;
25
+ let focusHops = 1;
26
+ let savedFullState = null;
27
+ let savedFullCamera = null;
19
28
  // Pan animation state
20
29
  let panTarget = null;
21
30
  let panStart = null;
@@ -79,6 +88,51 @@ export function initCanvas(container, onNodeClick) {
79
88
  ctx.save();
80
89
  ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
81
90
  ctx.scale(camera.scale, camera.scale);
91
+ // Draw type hulls (shaded regions behind same-type nodes)
92
+ if (showTypeHulls) {
93
+ const typeGroups = new Map();
94
+ for (const node of state.nodes) {
95
+ if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
96
+ continue;
97
+ const group = typeGroups.get(node.type) ?? [];
98
+ group.push(node);
99
+ typeGroups.set(node.type, group);
100
+ }
101
+ for (const [type, nodes] of typeGroups) {
102
+ if (nodes.length < 2)
103
+ continue;
104
+ const color = getColor(type);
105
+ const padding = NODE_RADIUS * 2.5;
106
+ // Compute bounding box
107
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
108
+ for (const n of nodes) {
109
+ if (n.x < minX)
110
+ minX = n.x;
111
+ if (n.y < minY)
112
+ minY = n.y;
113
+ if (n.x > maxX)
114
+ maxX = n.x;
115
+ if (n.y > maxY)
116
+ maxY = n.y;
117
+ }
118
+ ctx.beginPath();
119
+ const rx = (maxX - minX) / 2 + padding;
120
+ const ry = (maxY - minY) / 2 + padding;
121
+ const cx = (minX + maxX) / 2;
122
+ const cy = (minY + maxY) / 2;
123
+ ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
124
+ ctx.fillStyle = color;
125
+ ctx.globalAlpha = 0.05;
126
+ ctx.fill();
127
+ ctx.strokeStyle = color;
128
+ ctx.globalAlpha = 0.12;
129
+ ctx.lineWidth = 1;
130
+ ctx.setLineDash([4, 4]);
131
+ ctx.stroke();
132
+ ctx.setLineDash([]);
133
+ ctx.globalAlpha = 1;
134
+ }
135
+ }
82
136
  // Draw edges
83
137
  for (const edge of state.edges) {
84
138
  const source = state.nodeMap.get(edge.sourceId);
@@ -114,17 +168,19 @@ export function initCanvas(container, onNodeClick) {
114
168
  // Arrowhead
115
169
  drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
116
170
  // 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);
171
+ if (showEdgeLabels) {
172
+ const mx = (source.x + target.x) / 2;
173
+ const my = (source.y + target.y) / 2;
174
+ ctx.fillStyle = highlighted
175
+ ? edgeLabelHighlight
176
+ : edgeDimmed
177
+ ? edgeLabelDim
178
+ : edgeLabel;
179
+ ctx.font = "9px system-ui, sans-serif";
180
+ ctx.textAlign = "center";
181
+ ctx.textBaseline = "bottom";
182
+ ctx.fillText(edge.type, mx, my - 4);
183
+ }
128
184
  }
129
185
  // Draw nodes
130
186
  for (const node of state.nodes) {
@@ -173,6 +229,87 @@ export function initCanvas(container, onNodeClick) {
173
229
  }
174
230
  ctx.restore();
175
231
  ctx.restore();
232
+ // Minimap
233
+ if (showMinimap && state.nodes.length > 1) {
234
+ drawMinimap();
235
+ }
236
+ }
237
+ function drawMinimap() {
238
+ if (!state)
239
+ return;
240
+ const mapW = 140;
241
+ const mapH = 100;
242
+ const mapPad = 8;
243
+ const mapX = canvas.clientWidth - mapW - 16;
244
+ const mapY = canvas.clientHeight - mapH - 16;
245
+ // Compute graph bounds
246
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
247
+ for (const n of state.nodes) {
248
+ if (n.x < minX)
249
+ minX = n.x;
250
+ if (n.y < minY)
251
+ minY = n.y;
252
+ if (n.x > maxX)
253
+ maxX = n.x;
254
+ if (n.y > maxY)
255
+ maxY = n.y;
256
+ }
257
+ const gw = maxX - minX || 1;
258
+ const gh = maxY - minY || 1;
259
+ const scale = Math.min((mapW - mapPad * 2) / gw, (mapH - mapPad * 2) / gh);
260
+ const offsetX = mapX + mapPad + ((mapW - mapPad * 2) - gw * scale) / 2;
261
+ const offsetY = mapY + mapPad + ((mapH - mapPad * 2) - gh * scale) / 2;
262
+ ctx.save();
263
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
264
+ // Background
265
+ ctx.fillStyle = cssVar("--bg-surface") || "#1a1a1a";
266
+ ctx.globalAlpha = 0.85;
267
+ ctx.beginPath();
268
+ ctx.roundRect(mapX, mapY, mapW, mapH, 8);
269
+ ctx.fill();
270
+ ctx.strokeStyle = cssVar("--border") || "#2a2a2a";
271
+ ctx.globalAlpha = 1;
272
+ ctx.lineWidth = 1;
273
+ ctx.stroke();
274
+ // Edges
275
+ ctx.globalAlpha = 0.15;
276
+ ctx.strokeStyle = cssVar("--canvas-edge") || "#555";
277
+ ctx.lineWidth = 0.5;
278
+ for (const edge of state.edges) {
279
+ const src = state.nodeMap.get(edge.sourceId);
280
+ const tgt = state.nodeMap.get(edge.targetId);
281
+ if (!src || !tgt || edge.sourceId === edge.targetId)
282
+ continue;
283
+ ctx.beginPath();
284
+ ctx.moveTo(offsetX + (src.x - minX) * scale, offsetY + (src.y - minY) * scale);
285
+ ctx.lineTo(offsetX + (tgt.x - minX) * scale, offsetY + (tgt.y - minY) * scale);
286
+ ctx.stroke();
287
+ }
288
+ // Nodes
289
+ ctx.globalAlpha = 0.8;
290
+ for (const node of state.nodes) {
291
+ const nx = offsetX + (node.x - minX) * scale;
292
+ const ny = offsetY + (node.y - minY) * scale;
293
+ ctx.beginPath();
294
+ ctx.arc(nx, ny, 2, 0, Math.PI * 2);
295
+ ctx.fillStyle = getColor(node.type);
296
+ ctx.fill();
297
+ }
298
+ // Viewport rectangle
299
+ const vx1 = camera.x;
300
+ const vy1 = camera.y;
301
+ const vx2 = camera.x + canvas.clientWidth / camera.scale;
302
+ const vy2 = camera.y + canvas.clientHeight / camera.scale;
303
+ const rx = offsetX + (vx1 - minX) * scale;
304
+ const ry = offsetY + (vy1 - minY) * scale;
305
+ const rw = (vx2 - vx1) * scale;
306
+ const rh = (vy2 - vy1) * scale;
307
+ ctx.globalAlpha = 0.3;
308
+ ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
309
+ ctx.lineWidth = 1.5;
310
+ 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));
311
+ ctx.globalAlpha = 1;
312
+ ctx.restore();
176
313
  }
177
314
  function drawArrowhead(sx, sy, tx, ty, highlighted, arrowColor, arrowHighlight) {
178
315
  const angle = Math.atan2(ty - sy, tx - sx);
@@ -195,10 +332,12 @@ export function initCanvas(container, onNodeClick) {
195
332
  ctx.strokeStyle = highlighted ? edgeHighlight : edgeColor;
196
333
  ctx.lineWidth = highlighted ? 2.5 : 1.5;
197
334
  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);
335
+ if (showEdgeLabels) {
336
+ ctx.fillStyle = highlighted ? labelHighlight : labelColor;
337
+ ctx.font = "9px system-ui, sans-serif";
338
+ ctx.textAlign = "center";
339
+ ctx.fillText(type, cx, cy - 18);
340
+ }
202
341
  }
203
342
  // --- Simulation loop ---
204
343
  function animatePan() {
@@ -453,6 +592,11 @@ export function initCanvas(container, onNodeClick) {
453
592
  return {
454
593
  loadGraph(data) {
455
594
  cancelAnimationFrame(animFrame);
595
+ lastLoadedData = data;
596
+ // Exit any active focus when full graph reloads
597
+ focusSeedIds = null;
598
+ savedFullState = null;
599
+ savedFullCamera = null;
456
600
  state = createLayout(data);
457
601
  alpha = 1;
458
602
  selectedNodeIds = new Set();
@@ -485,25 +629,180 @@ export function initCanvas(container, onNodeClick) {
485
629
  render();
486
630
  },
487
631
  panToNode(nodeId) {
488
- if (!state)
632
+ this.panToNodes([nodeId]);
633
+ },
634
+ panToNodes(nodeIds) {
635
+ if (!state || nodeIds.length === 0)
489
636
  return;
490
- const node = state.nodeMap.get(nodeId);
491
- if (!node)
637
+ const nodes = nodeIds.map((id) => state.nodeMap.get(id)).filter(Boolean);
638
+ if (nodes.length === 0)
492
639
  return;
493
- selectedNodeIds = new Set([nodeId]);
494
- onNodeClick?.([nodeId]);
640
+ selectedNodeIds = new Set(nodeIds);
641
+ onNodeClick?.(nodeIds);
495
642
  const w = canvas.clientWidth;
496
643
  const h = canvas.clientHeight;
497
- panStart = { x: camera.x, y: camera.y, time: performance.now() };
498
- panTarget = {
499
- x: node.x - w / (2 * camera.scale),
500
- y: node.y - h / (2 * camera.scale),
501
- };
644
+ if (nodes.length === 1) {
645
+ panStart = { x: camera.x, y: camera.y, time: performance.now() };
646
+ panTarget = {
647
+ x: nodes[0].x - w / (2 * camera.scale),
648
+ y: nodes[0].y - h / (2 * camera.scale),
649
+ };
650
+ }
651
+ else {
652
+ // Fit all nodes in view with padding
653
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
654
+ for (const n of nodes) {
655
+ if (n.x < minX)
656
+ minX = n.x;
657
+ if (n.y < minY)
658
+ minY = n.y;
659
+ if (n.x > maxX)
660
+ maxX = n.x;
661
+ if (n.y > maxY)
662
+ maxY = n.y;
663
+ }
664
+ const pad = NODE_RADIUS * 4;
665
+ const bw = maxX - minX + pad * 2;
666
+ const bh = maxY - minY + pad * 2;
667
+ const fitScale = Math.min(w / bw, h / bh, camera.scale);
668
+ camera.scale = fitScale;
669
+ const cx = (minX + maxX) / 2;
670
+ const cy = (minY + maxY) / 2;
671
+ panStart = { x: camera.x, y: camera.y, time: performance.now() };
672
+ panTarget = {
673
+ x: cx - w / (2 * camera.scale),
674
+ y: cy - h / (2 * camera.scale),
675
+ };
676
+ }
502
677
  animatePan();
503
678
  },
679
+ setEdgeLabels(visible) {
680
+ showEdgeLabels = visible;
681
+ render();
682
+ },
683
+ setTypeHulls(visible) {
684
+ showTypeHulls = visible;
685
+ render();
686
+ },
687
+ setMinimap(visible) {
688
+ showMinimap = visible;
689
+ render();
690
+ },
691
+ reheat() {
692
+ alpha = 0.5;
693
+ cancelAnimationFrame(animFrame);
694
+ simulate();
695
+ },
696
+ exportImage(format) {
697
+ if (!state)
698
+ return "";
699
+ // Use the actual canvas pixel dimensions (already scaled by dpr)
700
+ const pw = canvas.width;
701
+ const ph = canvas.height;
702
+ if (format === "png") {
703
+ const exportCanvas = document.createElement("canvas");
704
+ exportCanvas.width = pw;
705
+ exportCanvas.height = ph;
706
+ const ectx = exportCanvas.getContext("2d");
707
+ // Draw background
708
+ ectx.fillStyle = cssVar("--bg") || "#141414";
709
+ ectx.fillRect(0, 0, pw, ph);
710
+ // Copy current canvas pixels 1:1
711
+ ectx.drawImage(canvas, 0, 0);
712
+ // Watermark (scale font to match pixel density)
713
+ drawWatermark(ectx, pw, ph);
714
+ return exportCanvas.toDataURL("image/png");
715
+ }
716
+ // SVG: embed the canvas as a PNG image with text overlay
717
+ const dataUrl = canvas.toDataURL("image/png");
718
+ const fontSize = Math.max(16, Math.round(pw / 80));
719
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}">
720
+ <image href="${dataUrl}" width="${pw}" height="${ph}"/>
721
+ <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>
722
+ </svg>`;
723
+ return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
724
+ },
725
+ enterFocus(seedNodeIds, hops) {
726
+ if (!lastLoadedData || !state)
727
+ return;
728
+ // Save current full-graph state
729
+ if (!focusSeedIds) {
730
+ savedFullState = state;
731
+ savedFullCamera = { ...camera };
732
+ }
733
+ focusSeedIds = seedNodeIds;
734
+ focusHops = hops;
735
+ const subgraph = extractSubgraph(lastLoadedData, seedNodeIds, hops);
736
+ cancelAnimationFrame(animFrame);
737
+ state = createLayout(subgraph);
738
+ alpha = 1;
739
+ selectedNodeIds = new Set(seedNodeIds);
740
+ filteredNodeIds = null;
741
+ // Center camera
742
+ camera = { x: 0, y: 0, scale: 1 };
743
+ if (state.nodes.length > 0) {
744
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
745
+ for (const n of state.nodes) {
746
+ if (n.x < minX)
747
+ minX = n.x;
748
+ if (n.y < minY)
749
+ minY = n.y;
750
+ if (n.x > maxX)
751
+ maxX = n.x;
752
+ if (n.y > maxY)
753
+ maxY = n.y;
754
+ }
755
+ const cx = (minX + maxX) / 2;
756
+ const cy = (minY + maxY) / 2;
757
+ camera.x = cx - canvas.clientWidth / 2;
758
+ camera.y = cy - canvas.clientHeight / 2;
759
+ }
760
+ simulate();
761
+ onFocusChange?.({
762
+ seedNodeIds,
763
+ hops,
764
+ totalNodes: subgraph.nodes.length,
765
+ });
766
+ },
767
+ exitFocus() {
768
+ if (!focusSeedIds || !savedFullState)
769
+ return;
770
+ cancelAnimationFrame(animFrame);
771
+ state = savedFullState;
772
+ camera = savedFullCamera ?? { x: 0, y: 0, scale: 1 };
773
+ focusSeedIds = null;
774
+ savedFullState = null;
775
+ savedFullCamera = null;
776
+ selectedNodeIds = new Set();
777
+ filteredNodeIds = null;
778
+ render();
779
+ onFocusChange?.(null);
780
+ },
781
+ isFocused() {
782
+ return focusSeedIds !== null;
783
+ },
784
+ getFocusInfo() {
785
+ if (!focusSeedIds || !state)
786
+ return null;
787
+ return {
788
+ seedNodeIds: focusSeedIds,
789
+ hops: focusHops,
790
+ totalNodes: state.nodes.length,
791
+ };
792
+ },
504
793
  destroy() {
505
794
  cancelAnimationFrame(animFrame);
506
795
  observer.disconnect();
507
796
  },
508
797
  };
798
+ function drawWatermark(ectx, w, h) {
799
+ const fontSize = Math.max(16, Math.round(w / 80));
800
+ ectx.save();
801
+ ectx.font = `${fontSize}px system-ui, sans-serif`;
802
+ ectx.fillStyle = "rgba(255, 255, 255, 0.4)";
803
+ ectx.textAlign = "right";
804
+ ectx.textBaseline = "bottom";
805
+ ectx.fillText("backpackontology.com", w - 20, h - 16);
806
+ ectx.restore();
807
+ }
509
808
  }
@@ -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
+ }
@@ -6,7 +6,7 @@ export interface EditCallbacks {
6
6
  onDeleteEdge(edgeId: string): void;
7
7
  onAddProperty(nodeId: string, key: string, value: string): void;
8
8
  }
9
- export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void): {
9
+ export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void, onFocus?: (nodeIds: string[]) => void): {
10
10
  show(nodeIds: string[], data: LearningGraphData): void;
11
11
  hide: () => void;
12
12
  readonly visible: boolean;
@@ -8,7 +8,7 @@ function nodeLabel(node) {
8
8
  return node.id;
9
9
  }
10
10
  const EDIT_ICON = '\u270E'; // pencil
11
- export function initInfoPanel(container, callbacks, onNavigateToNode) {
11
+ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
12
12
  const panel = document.createElement("div");
13
13
  panel.id = "info-panel";
14
14
  panel.className = "info-panel hidden";
@@ -19,6 +19,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
19
19
  let historyIndex = -1;
20
20
  let navigatingHistory = false;
21
21
  let lastData = null;
22
+ let currentNodeIds = [];
22
23
  function hide() {
23
24
  panel.classList.add("hidden");
24
25
  panel.classList.remove("info-panel-maximized");
@@ -76,6 +77,17 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
76
77
  fwdBtn.disabled = historyIndex >= history.length - 1;
77
78
  fwdBtn.addEventListener("click", goForward);
78
79
  toolbar.appendChild(fwdBtn);
80
+ // Focus
81
+ if (onFocus && currentNodeIds.length > 0) {
82
+ const focusBtn = document.createElement("button");
83
+ focusBtn.className = "info-toolbar-btn info-focus-btn";
84
+ focusBtn.textContent = "\u25CE"; // bullseye
85
+ focusBtn.title = "Focus on neighborhood (F)";
86
+ focusBtn.addEventListener("click", () => {
87
+ onFocus(currentNodeIds);
88
+ });
89
+ toolbar.appendChild(focusBtn);
90
+ }
79
91
  // Maximize/restore
80
92
  const maxBtn = document.createElement("button");
81
93
  maxBtn.className = "info-toolbar-btn";
@@ -174,22 +186,26 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
174
186
  const dd = document.createElement("dd");
175
187
  if (callbacks) {
176
188
  const valueStr = formatValue(node.properties[key]);
177
- const input = document.createElement("input");
178
- input.type = "text";
179
- input.className = "info-edit-input";
180
- input.value = valueStr;
181
- input.addEventListener("keydown", (e) => {
182
- if (e.key === "Enter") {
183
- input.blur();
189
+ const textarea = document.createElement("textarea");
190
+ textarea.className = "info-edit-input";
191
+ textarea.value = valueStr;
192
+ textarea.rows = 1;
193
+ textarea.addEventListener("input", () => autoResize(textarea));
194
+ textarea.addEventListener("keydown", (e) => {
195
+ if (e.key === "Enter" && !e.shiftKey) {
196
+ e.preventDefault();
197
+ textarea.blur();
184
198
  }
185
199
  });
186
- input.addEventListener("blur", () => {
187
- const newVal = input.value;
200
+ textarea.addEventListener("blur", () => {
201
+ const newVal = textarea.value;
188
202
  if (newVal !== valueStr) {
189
203
  callbacks.onUpdateNode(nodeId, { [key]: tryParseValue(newVal) });
190
204
  }
191
205
  });
192
- dd.appendChild(input);
206
+ dd.appendChild(textarea);
207
+ // Auto-size after append
208
+ requestAnimationFrame(() => autoResize(textarea));
193
209
  // Delete property button
194
210
  const delProp = document.createElement("button");
195
211
  delProp.className = "info-delete-prop";
@@ -484,6 +500,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
484
500
  return {
485
501
  show(nodeIds, data) {
486
502
  lastData = data;
503
+ currentNodeIds = nodeIds;
487
504
  // Track history for single-node views
488
505
  if (nodeIds.length === 1 && !navigatingHistory) {
489
506
  const nodeId = nodeIds[0];
@@ -568,6 +585,10 @@ function tryParseValue(str) {
568
585
  }
569
586
  return str;
570
587
  }
588
+ function autoResize(textarea) {
589
+ textarea.style.height = "auto";
590
+ textarea.style.height = textarea.scrollHeight + "px";
591
+ }
571
592
  function formatTimestamp(iso) {
572
593
  try {
573
594
  const d = new Date(iso);
package/dist/layout.d.ts CHANGED
@@ -18,7 +18,16 @@ 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. */
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
+ /** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
29
+ export declare function extractSubgraph(data: LearningGraphData, seedIds: string[], hops: number): LearningGraphData;
30
+ /** Create a layout state from ontology data. Nodes start grouped by type. */
22
31
  export declare function createLayout(data: LearningGraphData): LayoutState;
23
32
  /** Run one tick of the force simulation. Returns new alpha. */
24
33
  export declare function tick(state: LayoutState, alpha: number): number;