backpack-viewer 0.2.14 → 0.2.16

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/main.js CHANGED
@@ -120,9 +120,60 @@ async function main() {
120
120
  },
121
121
  }, (nodeId) => {
122
122
  canvas.panToNode(nodeId);
123
+ }, (nodeIds) => {
124
+ toolsPane.addToFocusSet(nodeIds);
123
125
  });
124
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
+ }
125
175
  canvas = initCanvas(canvasContainer, (nodeIds) => {
176
+ currentSelection = nodeIds ?? [];
126
177
  if (nodeIds && nodeIds.length > 0 && currentData) {
127
178
  infoPanel.show(nodeIds, currentData);
128
179
  if (mobileQuery.matches)
@@ -134,6 +185,20 @@ async function main() {
134
185
  if (activeOntology)
135
186
  updateUrl(activeOntology);
136
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);
201
+ }
137
202
  });
138
203
  const search = initSearch(canvasContainer);
139
204
  const toolsPane = initToolsPane(canvasContainer, {
@@ -155,6 +220,15 @@ async function main() {
155
220
  if (currentData)
156
221
  infoPanel.show([nodeId], currentData);
157
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
+ },
158
232
  onRenameNodeType(oldType, newType) {
159
233
  if (!currentData)
160
234
  return;
@@ -235,6 +309,10 @@ async function main() {
235
309
  canvas.setFilteredNodeIds(ids);
236
310
  });
237
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
+ }
238
316
  canvas.panToNode(nodeId);
239
317
  if (currentData) {
240
318
  infoPanel.show([nodeId], currentData);
@@ -262,29 +340,47 @@ async function main() {
262
340
  const emptyState = initEmptyState(canvasContainer);
263
341
  // --- URL deep linking ---
264
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
+ }
265
352
  const hash = "#" + encodeURIComponent(name) +
266
- (nodeIds?.length ? "?node=" + nodeIds.map(encodeURIComponent).join(",") : "");
353
+ (parts.length ? "?" + parts.join("&") : "");
267
354
  history.replaceState(null, "", hash);
268
355
  }
269
356
  function parseUrl() {
270
357
  const hash = window.location.hash.slice(1);
271
358
  if (!hash)
272
- return { graph: null, nodes: [] };
359
+ return { graph: null, nodes: [], focus: [], hops: 1 };
273
360
  const [graphPart, queryPart] = hash.split("?");
274
361
  const graph = graphPart ? decodeURIComponent(graphPart) : null;
275
362
  let nodes = [];
363
+ let focus = [];
364
+ let hops = 1;
276
365
  if (queryPart) {
277
366
  const params = new URLSearchParams(queryPart);
278
367
  const nodeParam = params.get("node");
279
368
  if (nodeParam)
280
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);
281
376
  }
282
- return { graph, nodes };
377
+ return { graph, nodes, focus, hops };
283
378
  }
284
- async function selectGraph(name, focusNodeIds) {
379
+ async function selectGraph(name, panToNodeIds, focusSeedIds, focusHops) {
285
380
  activeOntology = name;
286
381
  sidebar.setActive(name);
287
382
  infoPanel.hide();
383
+ removeFocusIndicator();
288
384
  search.clear();
289
385
  undoHistory.clear();
290
386
  currentData = await loadOntology(name);
@@ -293,9 +389,19 @@ async function main() {
293
389
  toolsPane.setData(currentData);
294
390
  emptyState.hide();
295
391
  updateUrl(name);
296
- // Focus on specific nodes if requested
297
- if (focusNodeIds?.length && currentData) {
298
- const validIds = focusNodeIds.filter((id) => currentData.nodes.some((n) => n.id === id));
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));
299
405
  if (validIds.length) {
300
406
  setTimeout(() => {
301
407
  canvas.panToNodes(validIds);
@@ -317,7 +423,7 @@ async function main() {
317
423
  ? summaries[0].name
318
424
  : null;
319
425
  if (initialName) {
320
- await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined);
426
+ await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
321
427
  }
322
428
  else {
323
429
  emptyState.show();
@@ -346,20 +452,39 @@ async function main() {
346
452
  applyState(restored);
347
453
  }
348
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
+ }
349
464
  else if (e.key === "?") {
350
465
  shortcuts.show();
351
466
  }
352
467
  else if (e.key === "Escape") {
353
- shortcuts.hide();
468
+ if (canvas.isFocused()) {
469
+ toolsPane.clearFocusSet();
470
+ }
471
+ else {
472
+ shortcuts.hide();
473
+ }
354
474
  }
355
475
  });
356
476
  // Handle browser back/forward
357
477
  window.addEventListener("hashchange", () => {
358
478
  const url = parseUrl();
359
479
  if (url.graph && url.graph !== activeOntology) {
360
- selectGraph(url.graph, url.nodes.length ? url.nodes : undefined);
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);
361
484
  }
362
485
  else if (url.graph && url.nodes.length && currentData) {
486
+ if (canvas.isFocused())
487
+ canvas.exitFocus();
363
488
  const validIds = url.nodes.filter((id) => currentData.nodes.some((n) => n.id === id));
364
489
  if (validIds.length) {
365
490
  canvas.panToNodes(validIds);
package/dist/shortcuts.js CHANGED
@@ -3,7 +3,8 @@ const SHORTCUTS = [
3
3
  { key: "Ctrl+Z", description: "Undo" },
4
4
  { key: "Ctrl+Shift+Z", description: "Redo" },
5
5
  { key: "?", description: "Show this help" },
6
- { key: "Esc", description: "Close panel / clear search" },
6
+ { key: "F", description: "Focus on selected / exit focus" },
7
+ { key: "Esc", description: "Exit focus / close panel" },
7
8
  { key: "Click", description: "Select node" },
8
9
  { key: "Ctrl+Click", description: "Multi-select nodes" },
9
10
  { key: "Drag", description: "Pan canvas" },
package/dist/style.css CHANGED
@@ -274,6 +274,71 @@ body {
274
274
  justify-content: center;
275
275
  }
276
276
 
277
+ /* --- Focus Indicator --- */
278
+
279
+ .focus-indicator {
280
+ display: flex;
281
+ align-items: center;
282
+ gap: 2px;
283
+ background: var(--bg-surface);
284
+ border: 1px solid rgba(212, 162, 127, 0.4);
285
+ border-radius: 8px;
286
+ padding: 4px 6px 4px 10px;
287
+ box-shadow: 0 2px 8px var(--shadow);
288
+ }
289
+
290
+ .focus-indicator-label {
291
+ font-size: 11px;
292
+ color: var(--accent);
293
+ font-weight: 500;
294
+ white-space: nowrap;
295
+ margin-right: 4px;
296
+ }
297
+
298
+ .focus-indicator-hops {
299
+ font-size: 11px;
300
+ color: var(--text-muted);
301
+ font-family: monospace;
302
+ min-width: 12px;
303
+ text-align: center;
304
+ }
305
+
306
+ .focus-indicator-btn {
307
+ background: none;
308
+ border: none;
309
+ color: var(--text-muted);
310
+ font-size: 14px;
311
+ cursor: pointer;
312
+ padding: 2px 4px;
313
+ line-height: 1;
314
+ border-radius: 4px;
315
+ transition: color 0.15s, background 0.15s;
316
+ }
317
+
318
+ .focus-indicator-btn:hover:not(:disabled) {
319
+ color: var(--text);
320
+ background: var(--bg-hover);
321
+ }
322
+
323
+ .focus-indicator-btn:disabled {
324
+ color: var(--text-dim);
325
+ cursor: default;
326
+ opacity: 0.3;
327
+ }
328
+
329
+ .focus-indicator-exit {
330
+ font-size: 16px;
331
+ margin-left: 2px;
332
+ }
333
+
334
+ .focus-indicator-exit:hover {
335
+ color: #ef4444 !important;
336
+ }
337
+
338
+ .info-focus-btn {
339
+ font-size: 14px;
340
+ }
341
+
277
342
  /* --- Theme Toggle --- */
278
343
 
279
344
  .theme-toggle {
@@ -852,9 +917,14 @@ body {
852
917
  border-radius: 4px;
853
918
  padding: 3px 6px;
854
919
  font-size: 12px;
920
+ font-family: inherit;
855
921
  color: var(--text);
856
922
  flex: 1;
857
923
  min-width: 0;
924
+ resize: vertical;
925
+ overflow: hidden;
926
+ line-height: 1.4;
927
+ max-height: 300px;
858
928
  }
859
929
 
860
930
  .info-edit-input:focus {
@@ -997,9 +1067,9 @@ body {
997
1067
  position: absolute;
998
1068
  top: 56px;
999
1069
  left: 16px;
1070
+ bottom: 16px;
1000
1071
  z-index: 20;
1001
1072
  width: 200px;
1002
- max-height: calc(100vh - 72px);
1003
1073
  overflow-y: auto;
1004
1074
  background: var(--bg-surface);
1005
1075
  border: 1px solid var(--border);
@@ -1127,6 +1197,22 @@ body {
1127
1197
  color: var(--accent);
1128
1198
  }
1129
1199
 
1200
+ .tools-pane-focus-toggle {
1201
+ opacity: 0.4;
1202
+ font-size: 11px;
1203
+ }
1204
+
1205
+ .tools-pane-focus-active {
1206
+ opacity: 1 !important;
1207
+ color: var(--accent) !important;
1208
+ }
1209
+
1210
+ .tools-pane-focus-clear {
1211
+ margin-top: 4px;
1212
+ border-top: 1px solid var(--border);
1213
+ padding-top: 6px;
1214
+ }
1215
+
1130
1216
  .tools-pane-editing {
1131
1217
  background: none !important;
1132
1218
  }
@@ -1417,8 +1503,8 @@ body {
1417
1503
  .tools-pane-content {
1418
1504
  top: 48px;
1419
1505
  left: 8px;
1506
+ bottom: 80px;
1420
1507
  width: 160px;
1421
- max-height: calc(100% - 200px);
1422
1508
  max-width: calc(100vw - 24px);
1423
1509
  }
1424
1510
 
@@ -2,6 +2,7 @@ import type { LearningGraphData } from "backpack-ontology";
2
2
  interface ToolsPaneCallbacks {
3
3
  onFilterByType: (type: string | null) => void;
4
4
  onNavigateToNode: (nodeId: string) => void;
5
+ onFocusChange: (seedNodeIds: string[] | null) => void;
5
6
  onRenameNodeType: (oldType: string, newType: string) => void;
6
7
  onRenameEdgeType: (oldType: string, newType: string) => void;
7
8
  onToggleEdgeLabels: (visible: boolean) => void;
@@ -13,6 +14,8 @@ interface ToolsPaneCallbacks {
13
14
  }
14
15
  export declare function initToolsPane(container: HTMLElement, callbacks: ToolsPaneCallbacks): {
15
16
  collapse(): void;
17
+ addToFocusSet(nodeIds: string[]): void;
18
+ clearFocusSet(): void;
16
19
  setData(newData: LearningGraphData | null): void;
17
20
  };
18
21
  export {};
@@ -7,6 +7,39 @@ export function initToolsPane(container, callbacks) {
7
7
  let edgeLabelsVisible = true;
8
8
  let typeHullsVisible = true;
9
9
  let minimapVisible = true;
10
+ // Unified focus set — two layers that compose via union
11
+ const focusSet = {
12
+ types: new Set(), // toggled node types (dynamic — resolves to all nodes of type)
13
+ nodeIds: new Set(), // individually toggled node IDs
14
+ };
15
+ /** Resolve the focus set to a flat array of node IDs. */
16
+ function resolveFocusSet() {
17
+ if (!data)
18
+ return [];
19
+ const ids = new Set();
20
+ for (const node of data.nodes) {
21
+ if (focusSet.types.has(node.type))
22
+ ids.add(node.id);
23
+ }
24
+ for (const id of focusSet.nodeIds)
25
+ ids.add(id);
26
+ return [...ids];
27
+ }
28
+ /** Check if a node is in the focus set (directly or via its type). */
29
+ function isNodeFocused(nodeId) {
30
+ if (focusSet.nodeIds.has(nodeId))
31
+ return true;
32
+ const node = data?.nodes.find((n) => n.id === nodeId);
33
+ return node ? focusSet.types.has(node.type) : false;
34
+ }
35
+ function isFocusSetEmpty() {
36
+ return focusSet.types.size === 0 && focusSet.nodeIds.size === 0;
37
+ }
38
+ /** Emit the resolved focus set to the callback. */
39
+ function emitFocusChange() {
40
+ const resolved = resolveFocusSet();
41
+ callbacks.onFocusChange(resolved.length > 0 ? resolved : null);
42
+ }
10
43
  // --- DOM ---
11
44
  const toggle = document.createElement("button");
12
45
  toggle.className = "tools-pane-toggle hidden";
@@ -37,7 +70,7 @@ export function initToolsPane(container, callbacks) {
37
70
  `<span>${stats.edgeCount} edges</span><span class="tools-pane-sep">&middot;</span>` +
38
71
  `<span>${stats.types.length} types</span>`;
39
72
  content.appendChild(summary);
40
- // Node types — click to filter, double-click to rename
73
+ // Node types — click to filter, bullseye to toggle focus set, pencil to rename
41
74
  if (stats.types.length) {
42
75
  content.appendChild(makeSection("Node Types", (section) => {
43
76
  for (const t of stats.types) {
@@ -54,6 +87,14 @@ export function initToolsPane(container, callbacks) {
54
87
  const count = document.createElement("span");
55
88
  count.className = "tools-pane-count";
56
89
  count.textContent = String(t.count);
90
+ const focusBtn = document.createElement("button");
91
+ focusBtn.className = "tools-pane-edit tools-pane-focus-toggle";
92
+ if (focusSet.types.has(t.name))
93
+ focusBtn.classList.add("tools-pane-focus-active");
94
+ focusBtn.textContent = "\u25CE";
95
+ focusBtn.title = focusSet.types.has(t.name)
96
+ ? `Remove ${t.name} from focus`
97
+ : `Add ${t.name} to focus`;
57
98
  const editBtn = document.createElement("button");
58
99
  editBtn.className = "tools-pane-edit";
59
100
  editBtn.textContent = "\u270E";
@@ -61,6 +102,7 @@ export function initToolsPane(container, callbacks) {
61
102
  row.appendChild(dot);
62
103
  row.appendChild(name);
63
104
  row.appendChild(count);
105
+ row.appendChild(focusBtn);
64
106
  row.appendChild(editBtn);
65
107
  row.addEventListener("click", (e) => {
66
108
  if (e.target.closest(".tools-pane-edit"))
@@ -75,6 +117,17 @@ export function initToolsPane(container, callbacks) {
75
117
  }
76
118
  render();
77
119
  });
120
+ focusBtn.addEventListener("click", (e) => {
121
+ e.stopPropagation();
122
+ if (focusSet.types.has(t.name)) {
123
+ focusSet.types.delete(t.name);
124
+ }
125
+ else {
126
+ focusSet.types.add(t.name);
127
+ }
128
+ emitFocusChange();
129
+ render();
130
+ });
78
131
  editBtn.addEventListener("click", (e) => {
79
132
  e.stopPropagation();
80
133
  startInlineEdit(row, t.name, (newName) => {
@@ -85,6 +138,26 @@ export function initToolsPane(container, callbacks) {
85
138
  });
86
139
  section.appendChild(row);
87
140
  }
141
+ // Show clear button when types are focused
142
+ if (focusSet.types.size > 0) {
143
+ const clearRow = document.createElement("div");
144
+ clearRow.className = "tools-pane-row tools-pane-clickable tools-pane-focus-clear";
145
+ const label = document.createElement("span");
146
+ label.className = "tools-pane-name";
147
+ label.style.color = "var(--accent)";
148
+ label.textContent = `${focusSet.types.size} type${focusSet.types.size > 1 ? "s" : ""} focused`;
149
+ const clearBtn = document.createElement("span");
150
+ clearBtn.className = "tools-pane-badge";
151
+ clearBtn.textContent = "clear types";
152
+ clearRow.appendChild(label);
153
+ clearRow.appendChild(clearBtn);
154
+ clearRow.addEventListener("click", () => {
155
+ focusSet.types.clear();
156
+ emitFocusChange();
157
+ render();
158
+ });
159
+ section.appendChild(clearRow);
160
+ }
88
161
  }));
89
162
  }
90
163
  // Edge types — with rename
@@ -118,7 +191,7 @@ export function initToolsPane(container, callbacks) {
118
191
  }
119
192
  }));
120
193
  }
121
- // Most connected nodes — click to navigate
194
+ // Most connected nodes — click to navigate, focus button
122
195
  if (stats.mostConnected.length) {
123
196
  content.appendChild(makeSection("Most Connected", (section) => {
124
197
  for (const n of stats.mostConnected) {
@@ -133,12 +206,34 @@ export function initToolsPane(container, callbacks) {
133
206
  const count = document.createElement("span");
134
207
  count.className = "tools-pane-count";
135
208
  count.textContent = `${n.connections}`;
209
+ const focusBtn = document.createElement("button");
210
+ focusBtn.className = "tools-pane-edit tools-pane-focus-toggle";
211
+ if (isNodeFocused(n.id))
212
+ focusBtn.classList.add("tools-pane-focus-active");
213
+ focusBtn.textContent = "\u25CE";
214
+ focusBtn.title = isNodeFocused(n.id)
215
+ ? `Remove ${n.label} from focus`
216
+ : `Add ${n.label} to focus`;
136
217
  row.appendChild(dot);
137
218
  row.appendChild(name);
138
219
  row.appendChild(count);
139
- row.addEventListener("click", () => {
220
+ row.appendChild(focusBtn);
221
+ row.addEventListener("click", (e) => {
222
+ if (e.target.closest(".tools-pane-edit"))
223
+ return;
140
224
  callbacks.onNavigateToNode(n.id);
141
225
  });
226
+ focusBtn.addEventListener("click", (e) => {
227
+ e.stopPropagation();
228
+ if (focusSet.nodeIds.has(n.id)) {
229
+ focusSet.nodeIds.delete(n.id);
230
+ }
231
+ else {
232
+ focusSet.nodeIds.add(n.id);
233
+ }
234
+ emitFocusChange();
235
+ render();
236
+ });
142
237
  section.appendChild(row);
143
238
  }
144
239
  }));
@@ -153,7 +248,7 @@ export function initToolsPane(container, callbacks) {
153
248
  issues.push(`${stats.emptyNodes.length} empty node${stats.emptyNodes.length > 1 ? "s" : ""}`);
154
249
  if (issues.length) {
155
250
  content.appendChild(makeSection("Quality", (section) => {
156
- // Orphans — click to navigate
251
+ // Orphans — click to navigate, focus button
157
252
  for (const o of stats.orphans.slice(0, 5)) {
158
253
  const row = document.createElement("div");
159
254
  row.className = "tools-pane-row tools-pane-clickable tools-pane-issue";
@@ -166,12 +261,34 @@ export function initToolsPane(container, callbacks) {
166
261
  const badge = document.createElement("span");
167
262
  badge.className = "tools-pane-badge";
168
263
  badge.textContent = "orphan";
264
+ const focusBtn = document.createElement("button");
265
+ focusBtn.className = "tools-pane-edit tools-pane-focus-toggle";
266
+ if (isNodeFocused(o.id))
267
+ focusBtn.classList.add("tools-pane-focus-active");
268
+ focusBtn.textContent = "\u25CE";
269
+ focusBtn.title = isNodeFocused(o.id)
270
+ ? `Remove ${o.label} from focus`
271
+ : `Add ${o.label} to focus`;
169
272
  row.appendChild(dot);
170
273
  row.appendChild(name);
171
274
  row.appendChild(badge);
172
- row.addEventListener("click", () => {
275
+ row.appendChild(focusBtn);
276
+ row.addEventListener("click", (e) => {
277
+ if (e.target.closest(".tools-pane-edit"))
278
+ return;
173
279
  callbacks.onNavigateToNode(o.id);
174
280
  });
281
+ focusBtn.addEventListener("click", (e) => {
282
+ e.stopPropagation();
283
+ if (focusSet.nodeIds.has(o.id)) {
284
+ focusSet.nodeIds.delete(o.id);
285
+ }
286
+ else {
287
+ focusSet.nodeIds.add(o.id);
288
+ }
289
+ emitFocusChange();
290
+ render();
291
+ });
175
292
  section.appendChild(row);
176
293
  }
177
294
  if (stats.orphans.length > 5) {
@@ -200,6 +317,37 @@ export function initToolsPane(container, callbacks) {
200
317
  }
201
318
  }));
202
319
  }
320
+ // Unified focus summary (if anything from any section is focused)
321
+ if (!isFocusSetEmpty()) {
322
+ const resolved = resolveFocusSet();
323
+ const summaryParts = [];
324
+ if (focusSet.types.size > 0)
325
+ summaryParts.push(`${focusSet.types.size} type${focusSet.types.size > 1 ? "s" : ""}`);
326
+ if (focusSet.nodeIds.size > 0)
327
+ summaryParts.push(`${focusSet.nodeIds.size} node${focusSet.nodeIds.size > 1 ? "s" : ""}`);
328
+ content.appendChild(makeSection("Focus", (section) => {
329
+ const row = document.createElement("div");
330
+ row.className = "tools-pane-row";
331
+ const label = document.createElement("span");
332
+ label.className = "tools-pane-name";
333
+ label.style.color = "var(--accent)";
334
+ label.textContent = `${summaryParts.join(" + ")} (${resolved.length} total)`;
335
+ const clearBtn = document.createElement("button");
336
+ clearBtn.className = "tools-pane-edit tools-pane-focus-active";
337
+ clearBtn.style.opacity = "1";
338
+ clearBtn.textContent = "\u00d7";
339
+ clearBtn.title = "Clear all focus";
340
+ clearBtn.addEventListener("click", () => {
341
+ focusSet.types.clear();
342
+ focusSet.nodeIds.clear();
343
+ emitFocusChange();
344
+ render();
345
+ });
346
+ row.appendChild(label);
347
+ row.appendChild(clearBtn);
348
+ section.appendChild(row);
349
+ }));
350
+ }
203
351
  // Controls section
204
352
  content.appendChild(makeSection("Controls", (section) => {
205
353
  // Edge labels toggle
@@ -411,9 +559,23 @@ export function initToolsPane(container, callbacks) {
411
559
  content.classList.add("hidden");
412
560
  toggle.classList.remove("active");
413
561
  },
562
+ addToFocusSet(nodeIds) {
563
+ for (const id of nodeIds)
564
+ focusSet.nodeIds.add(id);
565
+ emitFocusChange();
566
+ render();
567
+ },
568
+ clearFocusSet() {
569
+ focusSet.types.clear();
570
+ focusSet.nodeIds.clear();
571
+ emitFocusChange();
572
+ render();
573
+ },
414
574
  setData(newData) {
415
575
  data = newData;
416
576
  activeTypeFilter = null;
577
+ focusSet.types.clear();
578
+ focusSet.nodeIds.clear();
417
579
  if (data && data.nodes.length > 0) {
418
580
  stats = deriveStats(data);
419
581
  toggle.classList.remove("hidden");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backpack-viewer",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Web-based graph visualizer for backpack-ontology — Canvas 2D, force-directed layout, live reload",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Noah Irzinger",