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/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,57 @@ 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
+ /** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
34
+ export function extractSubgraph(data, seedIds, hops) {
35
+ const visited = new Set(seedIds);
36
+ let frontier = new Set(seedIds);
37
+ for (let h = 0; h < hops; h++) {
38
+ const next = new Set();
39
+ for (const edge of data.edges) {
40
+ if (frontier.has(edge.sourceId) && !visited.has(edge.targetId)) {
41
+ next.add(edge.targetId);
42
+ }
43
+ if (frontier.has(edge.targetId) && !visited.has(edge.sourceId)) {
44
+ next.add(edge.sourceId);
45
+ }
46
+ }
47
+ for (const id of next)
48
+ visited.add(id);
49
+ frontier = next;
50
+ if (next.size === 0)
51
+ break;
52
+ }
53
+ return {
54
+ nodes: data.nodes.filter((n) => visited.has(n.id)),
55
+ edges: data.edges.filter((e) => visited.has(e.sourceId) && visited.has(e.targetId)),
56
+ metadata: data.metadata,
57
+ };
58
+ }
59
+ /** Create a layout state from ontology data. Nodes start grouped by type. */
17
60
  export function createLayout(data) {
18
- const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
19
61
  const nodeMap = new Map();
20
- const nodes = data.nodes.map((n, i) => {
21
- const angle = (2 * Math.PI * i) / data.nodes.length;
62
+ // Group nodes by type for initial placement
63
+ const types = [...new Set(data.nodes.map((n) => n.type))];
64
+ const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
65
+ const typeCounters = new Map();
66
+ const typeSizes = new Map();
67
+ for (const n of data.nodes) {
68
+ typeSizes.set(n.type, (typeSizes.get(n.type) ?? 0) + 1);
69
+ }
70
+ const nodes = data.nodes.map((n) => {
71
+ const ti = types.indexOf(n.type);
72
+ const typeAngle = (2 * Math.PI * ti) / Math.max(types.length, 1);
73
+ const cx = Math.cos(typeAngle) * typeRadius;
74
+ const cy = Math.sin(typeAngle) * typeRadius;
75
+ const ni = typeCounters.get(n.type) ?? 0;
76
+ typeCounters.set(n.type, ni + 1);
77
+ const groupSize = typeSizes.get(n.type) ?? 1;
78
+ const nodeAngle = (2 * Math.PI * ni) / groupSize;
79
+ const nodeRadius = REST_LENGTH_SAME_BASE * 0.6;
22
80
  const node = {
23
81
  id: n.id,
24
- x: Math.cos(angle) * radius,
25
- y: Math.sin(angle) * radius,
82
+ x: cx + Math.cos(nodeAngle) * nodeRadius,
83
+ y: cy + Math.sin(nodeAngle) * nodeRadius,
26
84
  vx: 0,
27
85
  vy: 0,
28
86
  label: nodeLabel(n.properties, n.id),
@@ -41,7 +99,7 @@ export function createLayout(data) {
41
99
  /** Run one tick of the force simulation. Returns new alpha. */
42
100
  export function tick(state, alpha) {
43
101
  const { nodes, edges, nodeMap } = state;
44
- // Repulsion — all pairs
102
+ // Repulsion — all pairs (stronger between different types)
45
103
  for (let i = 0; i < nodes.length; i++) {
46
104
  for (let j = i + 1; j < nodes.length; j++) {
47
105
  const a = nodes[i];
@@ -51,7 +109,8 @@ export function tick(state, alpha) {
51
109
  let dist = Math.sqrt(dx * dx + dy * dy);
52
110
  if (dist < MIN_DISTANCE)
53
111
  dist = MIN_DISTANCE;
54
- const force = (REPULSION * alpha) / (dist * dist);
112
+ const rep = a.type === b.type ? REPULSION : CROSS_TYPE_REPULSION_BASE * params.spacing;
113
+ const force = (rep * alpha) / (dist * dist);
55
114
  const fx = (dx / dist) * force;
56
115
  const fy = (dy / dist) * force;
57
116
  a.vx -= fx;
@@ -60,7 +119,7 @@ export function tick(state, alpha) {
60
119
  b.vy += fy;
61
120
  }
62
121
  }
63
- // Attraction — along edges
122
+ // Attraction — along edges (shorter rest length within same type)
64
123
  for (const edge of edges) {
65
124
  const source = nodeMap.get(edge.sourceId);
66
125
  const target = nodeMap.get(edge.targetId);
@@ -71,7 +130,10 @@ export function tick(state, alpha) {
71
130
  const dist = Math.sqrt(dx * dx + dy * dy);
72
131
  if (dist === 0)
73
132
  continue;
74
- const force = ATTRACTION * (dist - REST_LENGTH) * alpha;
133
+ const restLen = source.type === target.type
134
+ ? REST_LENGTH_SAME_BASE * params.spacing
135
+ : REST_LENGTH_CROSS_BASE * params.spacing;
136
+ const force = ATTRACTION * (dist - restLen) * alpha;
75
137
  const fx = (dx / dist) * force;
76
138
  const fy = (dy / dist) * force;
77
139
  source.vx += fx;
@@ -84,6 +146,24 @@ export function tick(state, alpha) {
84
146
  node.vx -= node.x * CENTER_GRAVITY * alpha;
85
147
  node.vy -= node.y * CENTER_GRAVITY * alpha;
86
148
  }
149
+ // Cluster force — pull nodes toward their type centroid
150
+ const centroids = new Map();
151
+ for (const node of nodes) {
152
+ const c = centroids.get(node.type) ?? { x: 0, y: 0, count: 0 };
153
+ c.x += node.x;
154
+ c.y += node.y;
155
+ c.count++;
156
+ centroids.set(node.type, c);
157
+ }
158
+ for (const c of centroids.values()) {
159
+ c.x /= c.count;
160
+ c.y /= c.count;
161
+ }
162
+ for (const node of nodes) {
163
+ const c = centroids.get(node.type);
164
+ node.vx += (c.x - node.x) * params.clusterStrength * alpha;
165
+ node.vy += (c.y - node.y) * params.clusterStrength * alpha;
166
+ }
87
167
  // Integrate — update positions, apply damping, clamp velocity
88
168
  for (const node of nodes) {
89
169
  node.vx *= DAMPING;
package/dist/main.js CHANGED
@@ -3,6 +3,11 @@ import { initSidebar } from "./sidebar";
3
3
  import { initCanvas } from "./canvas";
4
4
  import { initInfoPanel } from "./info-panel";
5
5
  import { initSearch } from "./search";
6
+ import { initToolsPane } from "./tools-pane";
7
+ import { setLayoutParams } from "./layout";
8
+ import { initShortcuts } from "./shortcuts";
9
+ import { initEmptyState } from "./empty-state";
10
+ import { createHistory } from "./history";
6
11
  import "./style.css";
7
12
  let activeOntology = "";
8
13
  let currentData = null;
@@ -25,6 +30,8 @@ async function main() {
25
30
  themeBtn.textContent = next === "light" ? "\u263E" : "\u263C";
26
31
  });
27
32
  canvasContainer.appendChild(themeBtn);
33
+ // --- Undo/redo ---
34
+ const undoHistory = createHistory();
28
35
  // --- Save and re-render helper ---
29
36
  async function save() {
30
37
  if (!activeOntology || !currentData)
@@ -33,10 +40,26 @@ async function main() {
33
40
  await saveOntology(activeOntology, currentData);
34
41
  canvas.loadGraph(currentData);
35
42
  search.setLearningGraphData(currentData);
43
+ toolsPane.setData(currentData);
36
44
  // Refresh sidebar counts
37
45
  const updated = await listOntologies();
38
46
  sidebar.setSummaries(updated);
39
47
  }
48
+ /** Snapshot current state, then save. Call this instead of save() for undoable actions. */
49
+ async function undoableSave() {
50
+ if (currentData)
51
+ undoHistory.push(currentData);
52
+ await save();
53
+ }
54
+ async function applyState(data) {
55
+ currentData = data;
56
+ await saveOntology(activeOntology, currentData);
57
+ canvas.loadGraph(currentData);
58
+ search.setLearningGraphData(currentData);
59
+ toolsPane.setData(currentData);
60
+ const updated = await listOntologies();
61
+ sidebar.setSummaries(updated);
62
+ }
40
63
  // --- Info panel with edit callbacks ---
41
64
  // canvas is used inside the navigate callback but declared below —
42
65
  // that's fine because the callback is only invoked after setup completes.
@@ -45,6 +68,7 @@ async function main() {
45
68
  onUpdateNode(nodeId, properties) {
46
69
  if (!currentData)
47
70
  return;
71
+ undoHistory.push(currentData);
48
72
  const node = currentData.nodes.find((n) => n.id === nodeId);
49
73
  if (!node)
50
74
  return;
@@ -55,6 +79,7 @@ async function main() {
55
79
  onChangeNodeType(nodeId, newType) {
56
80
  if (!currentData)
57
81
  return;
82
+ undoHistory.push(currentData);
58
83
  const node = currentData.nodes.find((n) => n.id === nodeId);
59
84
  if (!node)
60
85
  return;
@@ -65,6 +90,7 @@ async function main() {
65
90
  onDeleteNode(nodeId) {
66
91
  if (!currentData)
67
92
  return;
93
+ undoHistory.push(currentData);
68
94
  currentData.nodes = currentData.nodes.filter((n) => n.id !== nodeId);
69
95
  currentData.edges = currentData.edges.filter((e) => e.sourceId !== nodeId && e.targetId !== nodeId);
70
96
  save();
@@ -72,6 +98,7 @@ async function main() {
72
98
  onDeleteEdge(edgeId) {
73
99
  if (!currentData)
74
100
  return;
101
+ undoHistory.push(currentData);
75
102
  const selectedNodeId = currentData.edges.find((e) => e.id === edgeId)?.sourceId;
76
103
  currentData.edges = currentData.edges.filter((e) => e.id !== edgeId);
77
104
  save().then(() => {
@@ -83,6 +110,7 @@ async function main() {
83
110
  onAddProperty(nodeId, key, value) {
84
111
  if (!currentData)
85
112
  return;
113
+ undoHistory.push(currentData);
86
114
  const node = currentData.nodes.find((n) => n.id === nodeId);
87
115
  if (!node)
88
116
  return;
@@ -92,35 +120,206 @@ async function main() {
92
120
  },
93
121
  }, (nodeId) => {
94
122
  canvas.panToNode(nodeId);
123
+ }, (nodeIds) => {
124
+ toolsPane.addToFocusSet(nodeIds);
95
125
  });
126
+ const mobileQuery = window.matchMedia("(max-width: 768px)");
127
+ // Track current selection for keyboard shortcuts
128
+ let currentSelection = [];
129
+ // --- Focus indicator (top bar pill) ---
130
+ let focusIndicator = null;
131
+ function buildFocusIndicator(info) {
132
+ if (focusIndicator)
133
+ focusIndicator.remove();
134
+ focusIndicator = document.createElement("div");
135
+ focusIndicator.className = "focus-indicator";
136
+ const label = document.createElement("span");
137
+ label.className = "focus-indicator-label";
138
+ label.textContent = `Focused: ${info.totalNodes} nodes`;
139
+ const hopsLabel = document.createElement("span");
140
+ hopsLabel.className = "focus-indicator-hops";
141
+ hopsLabel.textContent = `${info.hops}`;
142
+ const minus = document.createElement("button");
143
+ minus.className = "focus-indicator-btn";
144
+ minus.textContent = "\u2212";
145
+ minus.title = "Fewer hops";
146
+ minus.disabled = info.hops === 0;
147
+ minus.addEventListener("click", () => {
148
+ canvas.enterFocus(info.seedNodeIds, Math.max(0, info.hops - 1));
149
+ });
150
+ const plus = document.createElement("button");
151
+ plus.className = "focus-indicator-btn";
152
+ plus.textContent = "+";
153
+ plus.title = "More hops";
154
+ plus.disabled = false;
155
+ plus.addEventListener("click", () => {
156
+ canvas.enterFocus(info.seedNodeIds, info.hops + 1);
157
+ });
158
+ const exit = document.createElement("button");
159
+ exit.className = "focus-indicator-btn focus-indicator-exit";
160
+ exit.textContent = "\u00d7";
161
+ exit.title = "Exit focus (Esc)";
162
+ exit.addEventListener("click", () => toolsPane.clearFocusSet());
163
+ focusIndicator.appendChild(label);
164
+ focusIndicator.appendChild(minus);
165
+ focusIndicator.appendChild(hopsLabel);
166
+ focusIndicator.appendChild(plus);
167
+ focusIndicator.appendChild(exit);
168
+ }
169
+ function removeFocusIndicator() {
170
+ if (focusIndicator) {
171
+ focusIndicator.remove();
172
+ focusIndicator = null;
173
+ }
174
+ }
96
175
  canvas = initCanvas(canvasContainer, (nodeIds) => {
176
+ currentSelection = nodeIds ?? [];
97
177
  if (nodeIds && nodeIds.length > 0 && currentData) {
98
178
  infoPanel.show(nodeIds, currentData);
179
+ if (mobileQuery.matches)
180
+ toolsPane.collapse();
181
+ updateUrl(activeOntology, nodeIds);
99
182
  }
100
183
  else {
101
184
  infoPanel.hide();
185
+ if (activeOntology)
186
+ updateUrl(activeOntology);
187
+ }
188
+ }, (focus) => {
189
+ if (focus) {
190
+ buildFocusIndicator(focus);
191
+ // Insert into top-left, after tools toggle
192
+ const topLeft = canvasContainer.querySelector(".canvas-top-left");
193
+ if (topLeft && focusIndicator)
194
+ topLeft.appendChild(focusIndicator);
195
+ updateUrl(activeOntology, focus.seedNodeIds);
196
+ }
197
+ else {
198
+ removeFocusIndicator();
199
+ if (activeOntology)
200
+ updateUrl(activeOntology);
102
201
  }
103
202
  });
104
203
  const search = initSearch(canvasContainer);
204
+ const toolsPane = initToolsPane(canvasContainer, {
205
+ onFilterByType(type) {
206
+ if (!currentData)
207
+ return;
208
+ if (type === null) {
209
+ canvas.setFilteredNodeIds(null);
210
+ }
211
+ else {
212
+ const ids = new Set((currentData?.nodes ?? [])
213
+ .filter((n) => n.type === type)
214
+ .map((n) => n.id));
215
+ canvas.setFilteredNodeIds(ids);
216
+ }
217
+ },
218
+ onNavigateToNode(nodeId) {
219
+ canvas.panToNode(nodeId);
220
+ if (currentData)
221
+ infoPanel.show([nodeId], currentData);
222
+ },
223
+ onFocusChange(seedNodeIds) {
224
+ if (seedNodeIds && seedNodeIds.length > 0) {
225
+ canvas.enterFocus(seedNodeIds, 1);
226
+ }
227
+ else {
228
+ if (canvas.isFocused())
229
+ canvas.exitFocus();
230
+ }
231
+ },
232
+ onRenameNodeType(oldType, newType) {
233
+ if (!currentData)
234
+ return;
235
+ undoHistory.push(currentData);
236
+ for (const node of currentData.nodes) {
237
+ if (node.type === oldType) {
238
+ node.type = newType;
239
+ node.updatedAt = new Date().toISOString();
240
+ }
241
+ }
242
+ save();
243
+ },
244
+ onRenameEdgeType(oldType, newType) {
245
+ if (!currentData)
246
+ return;
247
+ undoHistory.push(currentData);
248
+ for (const edge of currentData.edges) {
249
+ if (edge.type === oldType) {
250
+ edge.type = newType;
251
+ }
252
+ }
253
+ save();
254
+ },
255
+ onToggleEdgeLabels(visible) {
256
+ canvas.setEdgeLabels(visible);
257
+ },
258
+ onToggleTypeHulls(visible) {
259
+ canvas.setTypeHulls(visible);
260
+ },
261
+ onToggleMinimap(visible) {
262
+ canvas.setMinimap(visible);
263
+ },
264
+ onLayoutChange(param, value) {
265
+ setLayoutParams({ [param]: value });
266
+ canvas.reheat();
267
+ },
268
+ onExport(format) {
269
+ const dataUrl = canvas.exportImage(format);
270
+ if (!dataUrl)
271
+ return;
272
+ const link = document.createElement("a");
273
+ link.download = `${activeOntology || "graph"}.${format}`;
274
+ link.href = dataUrl;
275
+ link.click();
276
+ },
277
+ onOpen() {
278
+ if (mobileQuery.matches)
279
+ infoPanel.hide();
280
+ },
281
+ });
282
+ // --- Top bar: flex container for all top controls ---
283
+ const topBar = document.createElement("div");
284
+ topBar.className = "canvas-top-bar";
285
+ const topLeft = document.createElement("div");
286
+ topLeft.className = "canvas-top-left";
287
+ const topCenter = document.createElement("div");
288
+ topCenter.className = "canvas-top-center";
289
+ const topRight = document.createElement("div");
290
+ topRight.className = "canvas-top-right";
291
+ // Move tools toggle into left slot
292
+ const toolsToggle = canvasContainer.querySelector(".tools-pane-toggle");
293
+ if (toolsToggle)
294
+ topLeft.appendChild(toolsToggle);
295
+ // Move search overlay into center slot
296
+ const searchOverlay = canvasContainer.querySelector(".search-overlay");
297
+ if (searchOverlay)
298
+ topCenter.appendChild(searchOverlay);
299
+ // Move zoom controls and theme toggle into right slot
300
+ const zoomControls = canvasContainer.querySelector(".zoom-controls");
301
+ if (zoomControls)
302
+ topRight.appendChild(zoomControls);
303
+ topRight.appendChild(themeBtn);
304
+ topBar.appendChild(topLeft);
305
+ topBar.appendChild(topCenter);
306
+ topBar.appendChild(topRight);
307
+ canvasContainer.appendChild(topBar);
105
308
  search.onFilterChange((ids) => {
106
309
  canvas.setFilteredNodeIds(ids);
107
310
  });
108
311
  search.onNodeSelect((nodeId) => {
312
+ // If focused and the node isn't in the subgraph, exit focus first
313
+ if (canvas.isFocused()) {
314
+ toolsPane.clearFocusSet();
315
+ }
109
316
  canvas.panToNode(nodeId);
110
317
  if (currentData) {
111
318
  infoPanel.show([nodeId], currentData);
112
319
  }
113
320
  });
114
321
  const sidebar = initSidebar(document.getElementById("sidebar"), {
115
- onSelect: async (name) => {
116
- activeOntology = name;
117
- sidebar.setActive(name);
118
- infoPanel.hide();
119
- search.clear();
120
- currentData = await loadOntology(name);
121
- canvas.loadGraph(currentData);
122
- search.setLearningGraphData(currentData);
123
- },
322
+ onSelect: (name) => selectGraph(name),
124
323
  onRename: async (oldName, newName) => {
125
324
  await renameOntology(oldName, newName);
126
325
  if (activeOntology === oldName) {
@@ -133,21 +332,103 @@ async function main() {
133
332
  currentData = await loadOntology(newName);
134
333
  canvas.loadGraph(currentData);
135
334
  search.setLearningGraphData(currentData);
335
+ toolsPane.setData(currentData);
136
336
  }
137
337
  },
138
338
  });
339
+ const shortcuts = initShortcuts(canvasContainer);
340
+ const emptyState = initEmptyState(canvasContainer);
341
+ // --- URL deep linking ---
342
+ function updateUrl(name, nodeIds) {
343
+ const parts = [];
344
+ if (nodeIds?.length) {
345
+ parts.push("node=" + nodeIds.map(encodeURIComponent).join(","));
346
+ }
347
+ const focusInfo = canvas.getFocusInfo();
348
+ if (focusInfo) {
349
+ parts.push("focus=" + focusInfo.seedNodeIds.map(encodeURIComponent).join(","));
350
+ parts.push("hops=" + focusInfo.hops);
351
+ }
352
+ const hash = "#" + encodeURIComponent(name) +
353
+ (parts.length ? "?" + parts.join("&") : "");
354
+ history.replaceState(null, "", hash);
355
+ }
356
+ function parseUrl() {
357
+ const hash = window.location.hash.slice(1);
358
+ if (!hash)
359
+ return { graph: null, nodes: [], focus: [], hops: 1 };
360
+ const [graphPart, queryPart] = hash.split("?");
361
+ const graph = graphPart ? decodeURIComponent(graphPart) : null;
362
+ let nodes = [];
363
+ let focus = [];
364
+ let hops = 1;
365
+ if (queryPart) {
366
+ const params = new URLSearchParams(queryPart);
367
+ const nodeParam = params.get("node");
368
+ if (nodeParam)
369
+ nodes = nodeParam.split(",").map(decodeURIComponent);
370
+ const focusParam = params.get("focus");
371
+ if (focusParam)
372
+ focus = focusParam.split(",").map(decodeURIComponent);
373
+ const hopsParam = params.get("hops");
374
+ if (hopsParam)
375
+ hops = Math.max(0, parseInt(hopsParam, 10) || 1);
376
+ }
377
+ return { graph, nodes, focus, hops };
378
+ }
379
+ async function selectGraph(name, panToNodeIds, focusSeedIds, focusHops) {
380
+ activeOntology = name;
381
+ sidebar.setActive(name);
382
+ infoPanel.hide();
383
+ removeFocusIndicator();
384
+ search.clear();
385
+ undoHistory.clear();
386
+ currentData = await loadOntology(name);
387
+ canvas.loadGraph(currentData);
388
+ search.setLearningGraphData(currentData);
389
+ toolsPane.setData(currentData);
390
+ emptyState.hide();
391
+ updateUrl(name);
392
+ // Restore focus mode if requested
393
+ if (focusSeedIds?.length && currentData) {
394
+ const validFocus = focusSeedIds.filter((id) => currentData.nodes.some((n) => n.id === id));
395
+ if (validFocus.length) {
396
+ setTimeout(() => {
397
+ canvas.enterFocus(validFocus, focusHops ?? 1);
398
+ }, 500);
399
+ return; // enterFocus handles the URL update
400
+ }
401
+ }
402
+ // Pan to specific nodes if requested
403
+ if (panToNodeIds?.length && currentData) {
404
+ const validIds = panToNodeIds.filter((id) => currentData.nodes.some((n) => n.id === id));
405
+ if (validIds.length) {
406
+ setTimeout(() => {
407
+ canvas.panToNodes(validIds);
408
+ if (currentData)
409
+ infoPanel.show(validIds, currentData);
410
+ updateUrl(name, validIds);
411
+ }, 500);
412
+ }
413
+ }
414
+ }
139
415
  // Load ontology list
140
416
  const summaries = await listOntologies();
141
417
  sidebar.setSummaries(summaries);
142
- // Auto-load first ontology
143
- if (summaries.length > 0) {
144
- activeOntology = summaries[0].name;
145
- sidebar.setActive(activeOntology);
146
- currentData = await loadOntology(activeOntology);
147
- canvas.loadGraph(currentData);
148
- search.setLearningGraphData(currentData);
418
+ // Auto-load from URL hash, or first graph
419
+ const initialUrl = parseUrl();
420
+ const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
421
+ ? initialUrl.graph
422
+ : summaries.length > 0
423
+ ? summaries[0].name
424
+ : null;
425
+ if (initialName) {
426
+ await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
149
427
  }
150
- // Keyboard shortcut: / or Ctrl+K to focus search
428
+ else {
429
+ emptyState.show();
430
+ }
431
+ // Keyboard shortcuts
151
432
  document.addEventListener("keydown", (e) => {
152
433
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
153
434
  return;
@@ -155,22 +436,88 @@ async function main() {
155
436
  e.preventDefault();
156
437
  search.focus();
157
438
  }
439
+ else if (e.key === "z" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
440
+ e.preventDefault();
441
+ if (currentData) {
442
+ const restored = undoHistory.redo(currentData);
443
+ if (restored)
444
+ applyState(restored);
445
+ }
446
+ }
447
+ else if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
448
+ e.preventDefault();
449
+ if (currentData) {
450
+ const restored = undoHistory.undo(currentData);
451
+ if (restored)
452
+ applyState(restored);
453
+ }
454
+ }
455
+ else if (e.key === "f" || e.key === "F") {
456
+ // Toggle focus mode on current selection
457
+ if (canvas.isFocused()) {
458
+ toolsPane.clearFocusSet();
459
+ }
460
+ else if (currentSelection.length > 0) {
461
+ toolsPane.addToFocusSet(currentSelection);
462
+ }
463
+ }
464
+ else if (e.key === "?") {
465
+ shortcuts.show();
466
+ }
467
+ else if (e.key === "Escape") {
468
+ if (canvas.isFocused()) {
469
+ toolsPane.clearFocusSet();
470
+ }
471
+ else {
472
+ shortcuts.hide();
473
+ }
474
+ }
475
+ });
476
+ // Handle browser back/forward
477
+ window.addEventListener("hashchange", () => {
478
+ const url = parseUrl();
479
+ if (url.graph && url.graph !== activeOntology) {
480
+ selectGraph(url.graph, url.nodes.length ? url.nodes : undefined, url.focus.length ? url.focus : undefined, url.hops);
481
+ }
482
+ else if (url.graph && url.focus.length && currentData) {
483
+ canvas.enterFocus(url.focus, url.hops);
484
+ }
485
+ else if (url.graph && url.nodes.length && currentData) {
486
+ if (canvas.isFocused())
487
+ canvas.exitFocus();
488
+ const validIds = url.nodes.filter((id) => currentData.nodes.some((n) => n.id === id));
489
+ if (validIds.length) {
490
+ canvas.panToNodes(validIds);
491
+ infoPanel.show(validIds, currentData);
492
+ }
493
+ }
158
494
  });
159
495
  // Live reload — when Claude adds nodes via MCP, re-fetch and re-render
160
496
  if (import.meta.hot) {
161
497
  import.meta.hot.on("ontology-change", async () => {
162
498
  const updated = await listOntologies();
163
499
  sidebar.setSummaries(updated);
500
+ if (updated.length > 0)
501
+ emptyState.hide();
164
502
  if (activeOntology) {
165
503
  try {
166
504
  currentData = await loadOntology(activeOntology);
167
505
  canvas.loadGraph(currentData);
168
506
  search.setLearningGraphData(currentData);
507
+ toolsPane.setData(currentData);
169
508
  }
170
509
  catch {
171
510
  // Ontology may have been deleted
172
511
  }
173
512
  }
513
+ else if (updated.length > 0) {
514
+ activeOntology = updated[0].name;
515
+ sidebar.setActive(activeOntology);
516
+ currentData = await loadOntology(activeOntology);
517
+ canvas.loadGraph(currentData);
518
+ search.setLearningGraphData(currentData);
519
+ toolsPane.setData(currentData);
520
+ }
174
521
  });
175
522
  }
176
523
  }
@@ -0,0 +1,4 @@
1
+ export declare function initShortcuts(container: HTMLElement): {
2
+ show: () => void;
3
+ hide: () => void;
4
+ };