backpack-viewer 0.2.16 → 0.2.17

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
@@ -6,6 +6,18 @@ function cssVar(name) {
6
6
  }
7
7
  const NODE_RADIUS = 20;
8
8
  const ALPHA_MIN = 0.001;
9
+ // Level-of-detail thresholds based on camera scale
10
+ const LOD_HIDE_BADGES = 0.4; // hide type badges above nodes
11
+ const LOD_HIDE_LABELS = 0.25; // hide node labels below nodes
12
+ const LOD_HIDE_EDGE_LABELS = 0.35; // hide edge labels even if enabled
13
+ const LOD_SMALL_NODES = 0.2; // shrink nodes to half size
14
+ const LOD_HIDE_ARROWS = 0.15; // hide arrowheads, draw 1px edges
15
+ /** Check if a point is within the visible viewport (with padding). */
16
+ function isInViewport(x, y, camera, canvasW, canvasH, pad = 100) {
17
+ const sx = (x - camera.x) * camera.scale;
18
+ const sy = (y - camera.y) * camera.scale;
19
+ return sx >= -pad && sx <= canvasW + pad && sy >= -pad && sy <= canvasH + pad;
20
+ }
9
21
  export function initCanvas(container, onNodeClick, onFocusChange) {
10
22
  const canvas = container.querySelector("canvas");
11
23
  const ctx = canvas.getContext("2d");
@@ -16,6 +28,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
16
28
  let animFrame = 0;
17
29
  let selectedNodeIds = new Set();
18
30
  let filteredNodeIds = null; // null = no filter (show all)
31
+ let showEdges = true;
19
32
  let showEdgeLabels = true;
20
33
  let showTypeHulls = true;
21
34
  let showMinimap = true;
@@ -89,7 +102,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
89
102
  ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
90
103
  ctx.scale(camera.scale, camera.scale);
91
104
  // Draw type hulls (shaded regions behind same-type nodes)
92
- if (showTypeHulls) {
105
+ if (showTypeHulls && camera.scale >= LOD_SMALL_NODES) {
93
106
  const typeGroups = new Map();
94
107
  for (const node of state.nodes) {
95
108
  if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
@@ -134,56 +147,66 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
134
147
  }
135
148
  }
136
149
  // Draw edges
137
- for (const edge of state.edges) {
138
- const source = state.nodeMap.get(edge.sourceId);
139
- const target = state.nodeMap.get(edge.targetId);
140
- if (!source || !target)
141
- continue;
142
- const sourceMatch = filteredNodeIds === null || filteredNodeIds.has(edge.sourceId);
143
- const targetMatch = filteredNodeIds === null || filteredNodeIds.has(edge.targetId);
144
- const bothMatch = sourceMatch && targetMatch;
145
- // Hide edges where neither endpoint matches the filter
146
- if (filteredNodeIds !== null && !sourceMatch && !targetMatch)
147
- continue;
148
- const isConnected = selectedNodeIds.size > 0 &&
149
- (selectedNodeIds.has(edge.sourceId) || selectedNodeIds.has(edge.targetId));
150
- const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
151
- const edgeDimmed = filteredNodeIds !== null && !bothMatch;
152
- // Self-loop
153
- if (edge.sourceId === edge.targetId) {
154
- drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
155
- continue;
156
- }
157
- // Line
158
- ctx.beginPath();
159
- ctx.moveTo(source.x, source.y);
160
- ctx.lineTo(target.x, target.y);
161
- ctx.strokeStyle = highlighted
162
- ? edgeHighlight
163
- : edgeDimmed
164
- ? edgeDimColor
165
- : edgeColor;
166
- ctx.lineWidth = highlighted ? 2.5 : 1.5;
167
- ctx.stroke();
168
- // Arrowhead
169
- drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
170
- // Edge label at midpoint
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
150
+ if (showEdges)
151
+ for (const edge of state.edges) {
152
+ const source = state.nodeMap.get(edge.sourceId);
153
+ const target = state.nodeMap.get(edge.targetId);
154
+ if (!source || !target)
155
+ continue;
156
+ // Viewport culling skip if both endpoints are off-screen
157
+ if (!isInViewport(source.x, source.y, camera, canvas.clientWidth, canvas.clientHeight, 200) &&
158
+ !isInViewport(target.x, target.y, camera, canvas.clientWidth, canvas.clientHeight, 200))
159
+ continue;
160
+ const sourceMatch = filteredNodeIds === null || filteredNodeIds.has(edge.sourceId);
161
+ const targetMatch = filteredNodeIds === null || filteredNodeIds.has(edge.targetId);
162
+ const bothMatch = sourceMatch && targetMatch;
163
+ // Hide edges where neither endpoint matches the filter
164
+ if (filteredNodeIds !== null && !sourceMatch && !targetMatch)
165
+ continue;
166
+ const isConnected = selectedNodeIds.size > 0 &&
167
+ (selectedNodeIds.has(edge.sourceId) || selectedNodeIds.has(edge.targetId));
168
+ const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
169
+ const edgeDimmed = filteredNodeIds !== null && !bothMatch;
170
+ // Self-loop
171
+ if (edge.sourceId === edge.targetId) {
172
+ drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
173
+ continue;
174
+ }
175
+ // Line
176
+ ctx.beginPath();
177
+ ctx.moveTo(source.x, source.y);
178
+ ctx.lineTo(target.x, target.y);
179
+ ctx.strokeStyle = highlighted
180
+ ? edgeHighlight
176
181
  : 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);
182
+ ? edgeDimColor
183
+ : edgeColor;
184
+ ctx.lineWidth = camera.scale < LOD_HIDE_ARROWS ? 1 : highlighted ? 2.5 : 1.5;
185
+ ctx.stroke();
186
+ // Arrowhead
187
+ if (camera.scale >= LOD_HIDE_ARROWS) {
188
+ drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
189
+ }
190
+ // Edge label at midpoint
191
+ if (showEdgeLabels && camera.scale >= LOD_HIDE_EDGE_LABELS) {
192
+ const mx = (source.x + target.x) / 2;
193
+ const my = (source.y + target.y) / 2;
194
+ ctx.fillStyle = highlighted
195
+ ? edgeLabelHighlight
196
+ : edgeDimmed
197
+ ? edgeLabelDim
198
+ : edgeLabel;
199
+ ctx.font = "9px system-ui, sans-serif";
200
+ ctx.textAlign = "center";
201
+ ctx.textBaseline = "bottom";
202
+ ctx.fillText(edge.type, mx, my - 4);
203
+ }
183
204
  }
184
- }
185
205
  // Draw nodes
186
206
  for (const node of state.nodes) {
207
+ // Viewport culling
208
+ if (!isInViewport(node.x, node.y, camera, canvas.clientWidth, canvas.clientHeight))
209
+ continue;
187
210
  const color = getColor(node.type);
188
211
  const isSelected = selectedNodeIds.has(node.id);
189
212
  const isNeighbor = selectedNodeIds.size > 0 &&
@@ -192,13 +215,14 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
192
215
  const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
193
216
  const dimmed = filteredOut ||
194
217
  (selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
218
+ const r = camera.scale < LOD_SMALL_NODES ? NODE_RADIUS * 0.5 : NODE_RADIUS;
195
219
  // Glow for selected node
196
220
  if (isSelected) {
197
221
  ctx.save();
198
222
  ctx.shadowColor = color;
199
223
  ctx.shadowBlur = 20;
200
224
  ctx.beginPath();
201
- ctx.arc(node.x, node.y, NODE_RADIUS + 3, 0, Math.PI * 2);
225
+ ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
202
226
  ctx.fillStyle = color;
203
227
  ctx.globalAlpha = 0.3;
204
228
  ctx.fill();
@@ -206,7 +230,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
206
230
  }
207
231
  // Circle
208
232
  ctx.beginPath();
209
- ctx.arc(node.x, node.y, NODE_RADIUS, 0, Math.PI * 2);
233
+ ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
210
234
  ctx.fillStyle = color;
211
235
  ctx.globalAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
212
236
  ctx.fill();
@@ -214,17 +238,21 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
214
238
  ctx.lineWidth = isSelected ? 3 : 1.5;
215
239
  ctx.stroke();
216
240
  // Label below
217
- const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
218
- ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
219
- ctx.font = "11px system-ui, sans-serif";
220
- ctx.textAlign = "center";
221
- ctx.textBaseline = "top";
222
- ctx.fillText(label, node.x, node.y + NODE_RADIUS + 4);
241
+ if (camera.scale >= LOD_HIDE_LABELS) {
242
+ const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
243
+ ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
244
+ ctx.font = "11px system-ui, sans-serif";
245
+ ctx.textAlign = "center";
246
+ ctx.textBaseline = "top";
247
+ ctx.fillText(label, node.x, node.y + r + 4);
248
+ }
223
249
  // Type badge above
224
- ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
225
- ctx.font = "9px system-ui, sans-serif";
226
- ctx.textBaseline = "bottom";
227
- ctx.fillText(node.type, node.x, node.y - NODE_RADIUS - 3);
250
+ if (camera.scale >= LOD_HIDE_BADGES) {
251
+ ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
252
+ ctx.font = "9px system-ui, sans-serif";
253
+ ctx.textBaseline = "bottom";
254
+ ctx.fillText(node.type, node.x, node.y - r - 3);
255
+ }
228
256
  ctx.globalAlpha = 1;
229
257
  }
230
258
  ctx.restore();
@@ -676,6 +704,10 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
676
704
  }
677
705
  animatePan();
678
706
  },
707
+ setEdges(visible) {
708
+ showEdges = visible;
709
+ render();
710
+ },
679
711
  setEdgeLabels(visible) {
680
712
  showEdgeLabels = visible;
681
713
  render();
@@ -688,6 +720,41 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
688
720
  showMinimap = visible;
689
721
  render();
690
722
  },
723
+ centerView() {
724
+ if (!state)
725
+ return;
726
+ camera = { x: 0, y: 0, scale: 1 };
727
+ if (state.nodes.length > 0) {
728
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
729
+ for (const n of state.nodes) {
730
+ if (n.x < minX)
731
+ minX = n.x;
732
+ if (n.y < minY)
733
+ minY = n.y;
734
+ if (n.x > maxX)
735
+ maxX = n.x;
736
+ if (n.y > maxY)
737
+ maxY = n.y;
738
+ }
739
+ camera.x = (minX + maxX) / 2 - canvas.clientWidth / 2;
740
+ camera.y = (minY + maxY) / 2 - canvas.clientHeight / 2;
741
+ }
742
+ render();
743
+ },
744
+ panBy(dx, dy) {
745
+ camera.x += dx / camera.scale;
746
+ camera.y += dy / camera.scale;
747
+ render();
748
+ },
749
+ zoomBy(factor) {
750
+ const cx = canvas.clientWidth / 2;
751
+ const cy = canvas.clientHeight / 2;
752
+ const [wx, wy] = screenToWorld(cx, cy);
753
+ camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
754
+ camera.x = wx - cx / camera.scale;
755
+ camera.y = wy - cy / camera.scale;
756
+ render();
757
+ },
691
758
  reheat() {
692
759
  alpha = 0.5;
693
760
  cancelAnimationFrame(animFrame);
@@ -790,6 +857,18 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
790
857
  totalNodes: state.nodes.length,
791
858
  };
792
859
  },
860
+ /** Get all node IDs in the current layout (subgraph if focused, full graph otherwise). Seed nodes first. */
861
+ getNodeIds() {
862
+ if (!state)
863
+ return [];
864
+ if (focusSeedIds) {
865
+ const seedSet = new Set(focusSeedIds);
866
+ const seeds = state.nodes.filter((n) => seedSet.has(n.id)).map((n) => n.id);
867
+ const rest = state.nodes.filter((n) => !seedSet.has(n.id)).map((n) => n.id);
868
+ return [...seeds, ...rest];
869
+ }
870
+ return state.nodes.map((n) => n.id);
871
+ },
793
872
  destroy() {
794
873
  cancelAnimationFrame(animFrame);
795
874
  observer.disconnect();
@@ -0,0 +1,4 @@
1
+ import defaultConfig from "./default-config.json";
2
+ export type ViewerConfig = typeof defaultConfig;
3
+ export type KeybindingMap = typeof defaultConfig.keybindings;
4
+ export declare function loadViewerConfig(): ViewerConfig;
package/dist/config.js ADDED
@@ -0,0 +1,31 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import defaultConfig from "./default-config.json" with { type: "json" };
5
+ function viewerConfigDir() {
6
+ if (process.env.BACKPACK_DIR) {
7
+ return path.join(process.env.BACKPACK_DIR, "config");
8
+ }
9
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
10
+ return path.join(xdgConfig, "backpack");
11
+ }
12
+ function viewerConfigFile() {
13
+ return path.join(viewerConfigDir(), "viewer.json");
14
+ }
15
+ export function loadViewerConfig() {
16
+ const filePath = viewerConfigFile();
17
+ try {
18
+ const raw = fs.readFileSync(filePath, "utf-8");
19
+ const userConfig = JSON.parse(raw);
20
+ return {
21
+ ...defaultConfig,
22
+ keybindings: {
23
+ ...defaultConfig.keybindings,
24
+ ...(userConfig.keybindings ?? {}),
25
+ },
26
+ };
27
+ }
28
+ catch {
29
+ return defaultConfig;
30
+ }
31
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "keybindings": {
3
+ "search": "/",
4
+ "searchAlt": "ctrl+k",
5
+ "undo": "ctrl+z",
6
+ "redo": "ctrl+shift+z",
7
+ "help": "?",
8
+ "escape": "Escape",
9
+ "focus": "f",
10
+ "toggleEdges": "e",
11
+ "center": "c",
12
+ "nextNode": ".",
13
+ "prevNode": ",",
14
+ "nextConnection": ">",
15
+ "prevConnection": "<",
16
+ "historyBack": "(",
17
+ "historyForward": ")",
18
+ "hopsIncrease": "=",
19
+ "hopsDecrease": "-",
20
+ "panLeft": "h",
21
+ "panDown": "j",
22
+ "panUp": "k",
23
+ "panRight": "l",
24
+ "panFastLeft": "H",
25
+ "zoomOut": "J",
26
+ "zoomIn": "K",
27
+ "panFastRight": "L",
28
+ "spacingDecrease": "[",
29
+ "spacingIncrease": "]",
30
+ "clusteringDecrease": "{",
31
+ "clusteringIncrease": "}"
32
+ }
33
+ }
@@ -9,5 +9,9 @@ export interface EditCallbacks {
9
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
+ goBack: () => void;
13
+ goForward: () => void;
14
+ cycleConnection(direction: 1 | -1): string | null;
15
+ setFocusDisabled(disabled: boolean): void;
12
16
  readonly visible: boolean;
13
17
  };
@@ -20,6 +20,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
20
20
  let navigatingHistory = false;
21
21
  let lastData = null;
22
22
  let currentNodeIds = [];
23
+ let focusDisabled = false;
24
+ let connectionNodeIds = []; // other-end node IDs for each connection
25
+ let activeConnectionIndex = -1;
23
26
  function hide() {
24
27
  panel.classList.add("hidden");
25
28
  panel.classList.remove("info-panel-maximized");
@@ -43,19 +46,23 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
43
46
  navigatingHistory = false;
44
47
  }
45
48
  function goBack() {
46
- if (historyIndex <= 0 || !lastData || !onNavigateToNode)
49
+ if (historyIndex <= 0 || !lastData)
47
50
  return;
48
51
  historyIndex--;
49
52
  navigatingHistory = true;
50
- onNavigateToNode(history[historyIndex]);
53
+ const nodeId = history[historyIndex];
54
+ onNavigateToNode?.(nodeId);
55
+ showSingle(nodeId, lastData);
51
56
  navigatingHistory = false;
52
57
  }
53
58
  function goForward() {
54
- if (historyIndex >= history.length - 1 || !lastData || !onNavigateToNode)
59
+ if (historyIndex >= history.length - 1 || !lastData)
55
60
  return;
56
61
  historyIndex++;
57
62
  navigatingHistory = true;
58
- onNavigateToNode(history[historyIndex]);
63
+ const nodeId = history[historyIndex];
64
+ onNavigateToNode?.(nodeId);
65
+ showSingle(nodeId, lastData);
59
66
  navigatingHistory = false;
60
67
  }
61
68
  function createToolbar() {
@@ -83,8 +90,12 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
83
90
  focusBtn.className = "info-toolbar-btn info-focus-btn";
84
91
  focusBtn.textContent = "\u25CE"; // bullseye
85
92
  focusBtn.title = "Focus on neighborhood (F)";
93
+ focusBtn.disabled = focusDisabled;
94
+ if (focusDisabled)
95
+ focusBtn.style.opacity = "0.3";
86
96
  focusBtn.addEventListener("click", () => {
87
- onFocus(currentNodeIds);
97
+ if (!focusDisabled)
98
+ onFocus(currentNodeIds);
88
99
  });
89
100
  toolbar.appendChild(focusBtn);
90
101
  }
@@ -114,6 +125,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
114
125
  if (!node)
115
126
  return;
116
127
  const connectedEdges = data.edges.filter((e) => e.sourceId === nodeId || e.targetId === nodeId);
128
+ // Store connection targets for keyboard cycling
129
+ connectionNodeIds = connectedEdges.map((e) => e.sourceId === nodeId ? e.targetId : e.sourceId);
130
+ activeConnectionIndex = -1;
117
131
  panel.innerHTML = "";
118
132
  panel.classList.remove("hidden");
119
133
  if (maximized)
@@ -521,6 +535,39 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
521
535
  }
522
536
  },
523
537
  hide,
538
+ goBack,
539
+ goForward,
540
+ cycleConnection(direction) {
541
+ if (connectionNodeIds.length === 0)
542
+ return null;
543
+ if (activeConnectionIndex === -1) {
544
+ activeConnectionIndex = direction === 1 ? 0 : connectionNodeIds.length - 1;
545
+ }
546
+ else {
547
+ activeConnectionIndex += direction;
548
+ if (activeConnectionIndex >= connectionNodeIds.length)
549
+ activeConnectionIndex = 0;
550
+ if (activeConnectionIndex < 0)
551
+ activeConnectionIndex = connectionNodeIds.length - 1;
552
+ }
553
+ // Highlight active row in the panel
554
+ const items = panel.querySelectorAll(".info-connection");
555
+ items.forEach((el, i) => {
556
+ el.classList.toggle("info-connection-active", i === activeConnectionIndex);
557
+ });
558
+ if (activeConnectionIndex >= 0 && items[activeConnectionIndex]) {
559
+ items[activeConnectionIndex].scrollIntoView({ block: "nearest" });
560
+ }
561
+ return connectionNodeIds[activeConnectionIndex] ?? null;
562
+ },
563
+ setFocusDisabled(disabled) {
564
+ focusDisabled = disabled;
565
+ const btn = panel.querySelector(".info-focus-btn");
566
+ if (btn) {
567
+ btn.disabled = disabled;
568
+ btn.style.opacity = disabled ? "0.3" : "";
569
+ }
570
+ },
524
571
  get visible() {
525
572
  return !panel.classList.contains("hidden");
526
573
  },
@@ -0,0 +1,6 @@
1
+ export type KeybindingAction = "search" | "searchAlt" | "undo" | "redo" | "help" | "escape" | "focus" | "toggleEdges" | "center" | "nextNode" | "prevNode" | "nextConnection" | "prevConnection" | "historyBack" | "historyForward" | "hopsIncrease" | "hopsDecrease" | "panLeft" | "panDown" | "panUp" | "panRight" | "panFastLeft" | "zoomOut" | "zoomIn" | "panFastRight" | "spacingDecrease" | "spacingIncrease" | "clusteringDecrease" | "clusteringIncrease";
2
+ export type KeybindingMap = Record<KeybindingAction, string>;
3
+ /** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
4
+ export declare function matchKey(e: KeyboardEvent, binding: string): boolean;
5
+ /** Build a reverse map: for each action, store its binding string. Used by the help modal. */
6
+ export declare function actionDescriptions(): Record<KeybindingAction, string>;
@@ -0,0 +1,58 @@
1
+ /** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
2
+ export function matchKey(e, binding) {
3
+ const parts = binding.toLowerCase().split("+");
4
+ const key = parts.pop();
5
+ const needCtrl = parts.includes("ctrl") || parts.includes("cmd") || parts.includes("meta");
6
+ const needShift = parts.includes("shift");
7
+ const needAlt = parts.includes("alt");
8
+ // Modifier checks
9
+ if (needCtrl !== (e.ctrlKey || e.metaKey))
10
+ return false;
11
+ if (needShift !== e.shiftKey)
12
+ return false;
13
+ if (needAlt !== e.altKey)
14
+ return false;
15
+ // For plain keys (no modifiers required), reject if ctrl/meta is held
16
+ if (!needCtrl && (e.ctrlKey || e.metaKey))
17
+ return false;
18
+ // Key match — case-sensitive for single chars, case-insensitive for named keys
19
+ if (key === "escape")
20
+ return e.key === "Escape";
21
+ if (key.length === 1)
22
+ return e.key === binding.split("+").pop(); // preserve original case
23
+ return e.key.toLowerCase() === key;
24
+ }
25
+ /** Build a reverse map: for each action, store its binding string. Used by the help modal. */
26
+ export function actionDescriptions() {
27
+ return {
28
+ search: "Focus search",
29
+ searchAlt: "Focus search (alt)",
30
+ undo: "Undo",
31
+ redo: "Redo",
32
+ help: "Toggle help",
33
+ escape: "Exit focus / close panel",
34
+ focus: "Focus on selected / exit focus",
35
+ toggleEdges: "Toggle edges on/off",
36
+ center: "Center view on graph",
37
+ nextNode: "Next node in view",
38
+ prevNode: "Previous node in view",
39
+ nextConnection: "Next connection",
40
+ prevConnection: "Previous connection",
41
+ historyBack: "Node history back",
42
+ historyForward: "Node history forward",
43
+ hopsIncrease: "Increase hops",
44
+ hopsDecrease: "Decrease hops",
45
+ panLeft: "Pan left",
46
+ panDown: "Pan down",
47
+ panUp: "Pan up",
48
+ panRight: "Pan right",
49
+ panFastLeft: "Pan fast left",
50
+ zoomOut: "Zoom out",
51
+ zoomIn: "Zoom in",
52
+ panFastRight: "Pan fast right",
53
+ spacingDecrease: "Decrease spacing",
54
+ spacingIncrease: "Increase spacing",
55
+ clusteringDecrease: "Decrease clustering",
56
+ clusteringIncrease: "Increase clustering",
57
+ };
58
+ }
package/dist/layout.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface LayoutParams {
25
25
  export declare const DEFAULT_LAYOUT_PARAMS: LayoutParams;
26
26
  export declare function setLayoutParams(p: Partial<LayoutParams>): void;
27
27
  export declare function getLayoutParams(): LayoutParams;
28
+ /** Compute sensible default layout params based on graph size. */
29
+ export declare function autoLayoutParams(nodeCount: number): LayoutParams;
28
30
  /** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
29
31
  export declare function extractSubgraph(data: LearningGraphData, seedIds: string[], hops: number): LearningGraphData;
30
32
  /** Create a layout state from ontology data. Nodes start grouped by type. */
package/dist/layout.js CHANGED
@@ -1,12 +1,12 @@
1
1
  export const DEFAULT_LAYOUT_PARAMS = {
2
- clusterStrength: 0.05,
3
- spacing: 1,
2
+ clusterStrength: 0.08,
3
+ spacing: 1.5,
4
4
  };
5
- const REPULSION = 5000;
6
- const CROSS_TYPE_REPULSION_BASE = 8000;
7
- const ATTRACTION = 0.005;
8
- const REST_LENGTH_SAME_BASE = 100;
9
- const REST_LENGTH_CROSS_BASE = 250;
5
+ const REPULSION = 6000;
6
+ const CROSS_TYPE_REPULSION_BASE = 12000;
7
+ const ATTRACTION = 0.004;
8
+ const REST_LENGTH_SAME_BASE = 140;
9
+ const REST_LENGTH_CROSS_BASE = 350;
10
10
  const DAMPING = 0.9;
11
11
  const CENTER_GRAVITY = 0.01;
12
12
  const MIN_DISTANCE = 30;
@@ -22,6 +22,16 @@ export function setLayoutParams(p) {
22
22
  export function getLayoutParams() {
23
23
  return { ...params };
24
24
  }
25
+ /** Compute sensible default layout params based on graph size. */
26
+ export function autoLayoutParams(nodeCount) {
27
+ if (nodeCount <= 30)
28
+ return { ...DEFAULT_LAYOUT_PARAMS };
29
+ const scale = Math.log2(nodeCount / 30);
30
+ return {
31
+ clusterStrength: Math.min(0.5, 0.08 + 0.06 * scale),
32
+ spacing: Math.min(15, 1.5 + 1.2 * scale),
33
+ };
34
+ }
25
35
  /** Extract a display label from a node — first string property value, fallback to id. */
26
36
  function nodeLabel(properties, id) {
27
37
  for (const value of Object.values(properties)) {
@@ -61,7 +71,7 @@ export function createLayout(data) {
61
71
  const nodeMap = new Map();
62
72
  // Group nodes by type for initial placement
63
73
  const types = [...new Set(data.nodes.map((n) => n.type))];
64
- const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
74
+ const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6 * Math.max(1, params.spacing);
65
75
  const typeCounters = new Map();
66
76
  const typeSizes = new Map();
67
77
  for (const n of data.nodes) {