backpack-viewer 0.2.21 → 0.3.0

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/sidebar.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import { showConfirm, showPrompt } from "./dialog";
2
+ function formatTokenCount(n) {
3
+ if (n >= 1000)
4
+ return `${(n / 1000).toFixed(1)}k tokens`;
5
+ return `${n} tokens`;
6
+ }
7
+ function estimateTokensFromCounts(nodeCount, edgeCount) {
8
+ return nodeCount * 50 + edgeCount * 25 + 50; // rough: 50 tok/node, 25 tok/edge, 50 metadata
9
+ }
2
10
  export function initSidebar(container, onSelectOrCallbacks) {
3
11
  const cbs = typeof onSelectOrCallbacks === "function"
4
12
  ? { onSelect: onSelectOrCallbacks }
@@ -12,6 +20,14 @@ export function initSidebar(container, onSelectOrCallbacks) {
12
20
  input.id = "filter";
13
21
  const list = document.createElement("ul");
14
22
  list.id = "ontology-list";
23
+ const remoteHeading = document.createElement("h3");
24
+ remoteHeading.className = "sidebar-section-heading";
25
+ remoteHeading.textContent = "REMOTE GRAPHS";
26
+ remoteHeading.hidden = true;
27
+ const remoteList = document.createElement("ul");
28
+ remoteList.id = "remote-list";
29
+ remoteList.className = "remote-list";
30
+ remoteList.hidden = true;
15
31
  const footer = document.createElement("div");
16
32
  footer.className = "sidebar-footer";
17
33
  footer.innerHTML =
@@ -43,8 +59,11 @@ export function initSidebar(container, onSelectOrCallbacks) {
43
59
  expandBtn.addEventListener("click", toggleSidebar);
44
60
  container.appendChild(input);
45
61
  container.appendChild(list);
62
+ container.appendChild(remoteHeading);
63
+ container.appendChild(remoteList);
46
64
  container.appendChild(footer);
47
65
  let items = [];
66
+ let remoteItems = [];
48
67
  let activeName = "";
49
68
  let activeBranchName = "main";
50
69
  // Filter
@@ -54,10 +73,19 @@ export function initSidebar(container, onSelectOrCallbacks) {
54
73
  const name = item.dataset.name ?? "";
55
74
  item.style.display = name.includes(query) ? "" : "none";
56
75
  }
76
+ for (const item of remoteItems) {
77
+ const name = item.dataset.name ?? "";
78
+ item.style.display = name.includes(query) ? "" : "none";
79
+ }
57
80
  });
58
81
  return {
59
82
  setSummaries(summaries) {
60
83
  list.innerHTML = "";
84
+ // Fetch all locks in one batch request, then distribute to items
85
+ // as they render. One HTTP roundtrip per sidebar refresh, not N.
86
+ const lockBatchPromise = fetch("/api/locks")
87
+ .then((r) => r.json())
88
+ .catch(() => ({}));
61
89
  items = summaries.map((s) => {
62
90
  const li = document.createElement("li");
63
91
  li.className = "ontology-item";
@@ -67,12 +95,30 @@ export function initSidebar(container, onSelectOrCallbacks) {
67
95
  nameSpan.textContent = s.name;
68
96
  const statsSpan = document.createElement("span");
69
97
  statsSpan.className = "stats";
70
- statsSpan.textContent = `${s.nodeCount} nodes, ${s.edgeCount} edges`;
98
+ const tokens = estimateTokensFromCounts(s.nodeCount, s.edgeCount);
99
+ statsSpan.textContent = `${s.nodeCount} nodes, ${s.edgeCount} edges · ~${formatTokenCount(tokens)}`;
71
100
  const branchSpan = document.createElement("span");
72
101
  branchSpan.className = "sidebar-branch";
73
102
  branchSpan.dataset.graph = s.name;
103
+ // Lock heartbeat badge — populated from the batched fetch above
104
+ const lockBadge = document.createElement("span");
105
+ lockBadge.className = "sidebar-lock-badge";
106
+ lockBadge.dataset.graph = s.name;
107
+ lockBatchPromise.then((locks) => {
108
+ // Bail if this badge has been detached from the DOM (sidebar
109
+ // re-rendered before the batch resolved)
110
+ if (!lockBadge.isConnected)
111
+ return;
112
+ const lock = locks[s.name];
113
+ if (lock && typeof lock === "object" && lock.author) {
114
+ lockBadge.textContent = `editing: ${lock.author}`;
115
+ lockBadge.title = `Last activity: ${lock.lastActivity ?? ""}`;
116
+ lockBadge.classList.add("active");
117
+ }
118
+ });
74
119
  li.appendChild(nameSpan);
75
120
  li.appendChild(statsSpan);
121
+ li.appendChild(lockBadge);
76
122
  li.appendChild(branchSpan);
77
123
  if (cbs.onRename) {
78
124
  const editBtn = document.createElement("button");
@@ -127,6 +173,49 @@ export function initSidebar(container, onSelectOrCallbacks) {
127
173
  for (const item of items) {
128
174
  item.classList.toggle("active", item.dataset.name === name);
129
175
  }
176
+ for (const item of remoteItems) {
177
+ item.classList.toggle("active", item.dataset.name === name);
178
+ }
179
+ },
180
+ setRemotes(remotes) {
181
+ remoteList.replaceChildren();
182
+ remoteItems = remotes.map((r) => {
183
+ const li = document.createElement("li");
184
+ li.className = "ontology-item ontology-item-remote";
185
+ li.dataset.name = r.name;
186
+ const nameRow = document.createElement("div");
187
+ nameRow.className = "remote-name-row";
188
+ const nameSpan = document.createElement("span");
189
+ nameSpan.className = "name";
190
+ nameSpan.textContent = r.name;
191
+ const badge = document.createElement("span");
192
+ badge.className = "remote-badge";
193
+ badge.textContent = r.pinned ? "remote · pinned" : "remote";
194
+ badge.title = `Source: ${r.source ?? r.url}`;
195
+ nameRow.appendChild(nameSpan);
196
+ nameRow.appendChild(badge);
197
+ const statsSpan = document.createElement("span");
198
+ statsSpan.className = "stats";
199
+ const tokens = estimateTokensFromCounts(r.nodeCount, r.edgeCount);
200
+ statsSpan.textContent = `${r.nodeCount} nodes, ${r.edgeCount} edges · ~${formatTokenCount(tokens)}`;
201
+ const sourceSpan = document.createElement("span");
202
+ sourceSpan.className = "remote-source";
203
+ sourceSpan.textContent = r.source ?? new URL(r.url).hostname;
204
+ sourceSpan.title = r.url;
205
+ li.appendChild(nameRow);
206
+ li.appendChild(statsSpan);
207
+ li.appendChild(sourceSpan);
208
+ li.addEventListener("click", () => cbs.onSelect(r.name));
209
+ remoteList.appendChild(li);
210
+ return li;
211
+ });
212
+ const visible = remotes.length > 0;
213
+ remoteHeading.hidden = !visible;
214
+ remoteList.hidden = !visible;
215
+ // Re-apply active state in case the active graph is a remote
216
+ if (activeName) {
217
+ this.setActive(activeName);
218
+ }
130
219
  },
131
220
  setActiveBranch(graphName, branchName, allBranches) {
132
221
  activeBranchName = branchName;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Grid-based spatial hash for O(1) average-case point queries on nodes.
3
+ * Cell size should be >= 2× the node radius so a node overlaps at most 4 cells.
4
+ */
5
+ export interface Positioned {
6
+ x: number;
7
+ y: number;
8
+ }
9
+ export declare class SpatialHash<T extends Positioned> {
10
+ private cells;
11
+ private cellSize;
12
+ private invCell;
13
+ constructor(cellSize: number);
14
+ private key;
15
+ clear(): void;
16
+ /** Insert an item at its current position. */
17
+ insert(item: T): void;
18
+ /** Rebuild from an array of items. */
19
+ rebuild(items: T[]): void;
20
+ /** Find the nearest item within `radius` of (x, y), or null. */
21
+ query(x: number, y: number, radius: number): T | null;
22
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Grid-based spatial hash for O(1) average-case point queries on nodes.
3
+ * Cell size should be >= 2× the node radius so a node overlaps at most 4 cells.
4
+ */
5
+ export class SpatialHash {
6
+ cells = new Map();
7
+ cellSize;
8
+ invCell;
9
+ constructor(cellSize) {
10
+ this.cellSize = cellSize;
11
+ this.invCell = 1 / cellSize;
12
+ }
13
+ key(cx, cy) {
14
+ // Cantor-like hash combining two integers — fast and good enough for grid coords.
15
+ // Shift to positive range first to avoid issues with negative coordinates.
16
+ const a = (cx + 0x8000) | 0;
17
+ const b = (cy + 0x8000) | 0;
18
+ return (a * 73856093) ^ (b * 19349663);
19
+ }
20
+ clear() {
21
+ this.cells.clear();
22
+ }
23
+ /** Insert an item at its current position. */
24
+ insert(item) {
25
+ const cx = Math.floor(item.x * this.invCell);
26
+ const cy = Math.floor(item.y * this.invCell);
27
+ const k = this.key(cx, cy);
28
+ const bucket = this.cells.get(k);
29
+ if (bucket)
30
+ bucket.push(item);
31
+ else
32
+ this.cells.set(k, [item]);
33
+ }
34
+ /** Rebuild from an array of items. */
35
+ rebuild(items) {
36
+ this.cells.clear();
37
+ for (const item of items)
38
+ this.insert(item);
39
+ }
40
+ /** Find the nearest item within `radius` of (x, y), or null. */
41
+ query(x, y, radius) {
42
+ const r2 = radius * radius;
43
+ const cxMin = Math.floor((x - radius) * this.invCell);
44
+ const cxMax = Math.floor((x + radius) * this.invCell);
45
+ const cyMin = Math.floor((y - radius) * this.invCell);
46
+ const cyMax = Math.floor((y + radius) * this.invCell);
47
+ let best = null;
48
+ let bestDist = r2;
49
+ for (let cx = cxMin; cx <= cxMax; cx++) {
50
+ for (let cy = cyMin; cy <= cyMax; cy++) {
51
+ const bucket = this.cells.get(this.key(cx, cy));
52
+ if (!bucket)
53
+ continue;
54
+ for (const item of bucket) {
55
+ const dx = item.x - x;
56
+ const dy = item.y - y;
57
+ const d2 = dx * dx + dy * dy;
58
+ if (d2 <= bestDist) {
59
+ bestDist = d2;
60
+ best = item;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return best;
66
+ }
67
+ }
package/dist/style.css CHANGED
@@ -237,6 +237,54 @@ body {
237
237
  opacity: 0.7;
238
238
  }
239
239
 
240
+ /* --- Remote graphs section in sidebar --- */
241
+
242
+ .sidebar-section-heading {
243
+ font-size: 10px;
244
+ font-weight: 600;
245
+ color: var(--text-dim);
246
+ letter-spacing: 0.08em;
247
+ margin: 16px 12px 6px;
248
+ }
249
+
250
+ .remote-list {
251
+ list-style: none;
252
+ padding: 0;
253
+ margin: 0;
254
+ }
255
+
256
+ .ontology-item-remote .name {
257
+ display: inline;
258
+ }
259
+
260
+ .remote-name-row {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 8px;
264
+ }
265
+
266
+ .remote-badge {
267
+ font-size: 9px;
268
+ font-weight: 600;
269
+ color: var(--text-dim);
270
+ background: var(--bg-hover);
271
+ padding: 1px 6px;
272
+ border-radius: 4px;
273
+ text-transform: uppercase;
274
+ letter-spacing: 0.04em;
275
+ border: 1px solid var(--border);
276
+ }
277
+
278
+ .remote-source {
279
+ display: block;
280
+ font-size: 10px;
281
+ color: var(--text-dim);
282
+ margin-top: 1px;
283
+ overflow: hidden;
284
+ text-overflow: ellipsis;
285
+ white-space: nowrap;
286
+ }
287
+
240
288
  .sidebar-edit-btn:hover {
241
289
  opacity: 1 !important;
242
290
  color: var(--text);
@@ -267,6 +315,22 @@ body {
267
315
  opacity: 1;
268
316
  }
269
317
 
318
+ .sidebar-lock-badge {
319
+ font-size: 10px;
320
+ color: #c08c00;
321
+ display: none;
322
+ margin-top: 2px;
323
+ }
324
+
325
+ .sidebar-lock-badge.active {
326
+ display: block;
327
+ }
328
+
329
+ .sidebar-lock-badge.active::before {
330
+ content: "● ";
331
+ color: #c08c00;
332
+ }
333
+
270
334
  .branch-picker {
271
335
  background: var(--bg-surface);
272
336
  border: 1px solid var(--border);
@@ -543,6 +607,23 @@ body {
543
607
  background: var(--bg-hover);
544
608
  }
545
609
 
610
+ /* --- Node Tooltip --- */
611
+
612
+ .node-tooltip {
613
+ position: absolute;
614
+ pointer-events: none;
615
+ background: var(--bg);
616
+ color: var(--text);
617
+ border: 1px solid var(--border);
618
+ border-radius: 6px;
619
+ padding: 4px 8px;
620
+ font-size: 12px;
621
+ white-space: nowrap;
622
+ z-index: 20;
623
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
624
+ opacity: 0.95;
625
+ }
626
+
546
627
  /* --- Canvas --- */
547
628
 
548
629
  #canvas-container {
@@ -905,6 +986,25 @@ body {
905
986
  margin-bottom: 8px;
906
987
  }
907
988
 
989
+ .info-badge-row {
990
+ display: flex;
991
+ flex-wrap: wrap;
992
+ gap: 4px;
993
+ margin-top: 6px;
994
+ }
995
+
996
+ .info-empty-message {
997
+ font-size: 12px;
998
+ color: var(--text-dim);
999
+ }
1000
+
1001
+ .share-list-message {
1002
+ font-size: 13px;
1003
+ color: var(--text-dim);
1004
+ text-align: center;
1005
+ padding: 12px;
1006
+ }
1007
+
908
1008
  .info-label {
909
1009
  font-size: 18px;
910
1010
  font-weight: 600;
@@ -1324,6 +1424,41 @@ body {
1324
1424
  color: var(--text-dim);
1325
1425
  }
1326
1426
 
1427
+ .tools-pane-token-card {
1428
+ padding: 8px 10px;
1429
+ margin-bottom: 10px;
1430
+ border: 1px solid var(--border);
1431
+ border-radius: 6px;
1432
+ background: var(--bg-hover);
1433
+ font-size: 11px;
1434
+ }
1435
+
1436
+ .token-card-label {
1437
+ font-weight: 600;
1438
+ color: var(--text);
1439
+ margin-bottom: 4px;
1440
+ }
1441
+
1442
+ .token-card-stat {
1443
+ color: var(--text-muted);
1444
+ margin-bottom: 4px;
1445
+ }
1446
+
1447
+ .token-card-bar {
1448
+ height: 4px;
1449
+ background: var(--border);
1450
+ border-radius: 2px;
1451
+ margin: 6px 0;
1452
+ overflow: hidden;
1453
+ }
1454
+
1455
+ .token-card-bar-fill {
1456
+ height: 100%;
1457
+ background: var(--accent);
1458
+ border-radius: 2px;
1459
+ transition: width 0.3s ease;
1460
+ }
1461
+
1327
1462
  .tools-pane-clickable {
1328
1463
  cursor: pointer;
1329
1464
  border-radius: 4px;
@@ -1378,6 +1513,28 @@ body {
1378
1513
  color: var(--accent);
1379
1514
  }
1380
1515
 
1516
+ .tools-pane-actions {
1517
+ display: flex;
1518
+ gap: 6px;
1519
+ padding-top: 4px;
1520
+ }
1521
+
1522
+ .tools-pane-action-btn {
1523
+ background: none;
1524
+ border: 1px solid var(--border);
1525
+ color: var(--text-muted);
1526
+ font-size: 10px;
1527
+ padding: 2px 8px;
1528
+ border-radius: 3px;
1529
+ cursor: pointer;
1530
+ transition: color 0.1s, border-color 0.1s;
1531
+ }
1532
+
1533
+ .tools-pane-action-btn:hover {
1534
+ color: var(--accent);
1535
+ border-color: var(--accent);
1536
+ }
1537
+
1381
1538
  .tools-pane-focus-toggle {
1382
1539
  opacity: 0.4;
1383
1540
  font-size: 11px;
@@ -1556,16 +1713,52 @@ body {
1556
1713
  justify-content: center;
1557
1714
  z-index: 5;
1558
1715
  pointer-events: none;
1716
+ overflow: hidden;
1559
1717
  }
1560
1718
 
1561
1719
  .empty-state.hidden {
1562
1720
  display: none;
1563
1721
  }
1564
1722
 
1723
+ .empty-state-bg {
1724
+ position: absolute;
1725
+ inset: 0;
1726
+ overflow: hidden;
1727
+ }
1728
+
1729
+ .empty-state-circle {
1730
+ position: absolute;
1731
+ border-radius: 50%;
1732
+ background: var(--accent);
1733
+ opacity: 0.07;
1734
+ }
1735
+
1736
+ .empty-state-circle.c1 { width: 80px; height: 80px; left: 20%; top: 15%; animation: float-circle 8s ease-in-out infinite; }
1737
+ .empty-state-circle.c2 { width: 50px; height: 50px; right: 25%; top: 25%; animation: float-circle 6s ease-in-out 1s infinite; }
1738
+ .empty-state-circle.c3 { width: 65px; height: 65px; left: 55%; bottom: 20%; animation: float-circle 7s ease-in-out 2s infinite; }
1739
+ .empty-state-circle.c4 { width: 40px; height: 40px; left: 15%; bottom: 30%; animation: float-circle 9s ease-in-out 0.5s infinite; }
1740
+ .empty-state-circle.c5 { width: 55px; height: 55px; right: 15%; bottom: 35%; animation: float-circle 7.5s ease-in-out 1.5s infinite; }
1741
+
1742
+ .empty-state-lines {
1743
+ position: absolute;
1744
+ inset: 0;
1745
+ width: 100%;
1746
+ height: 100%;
1747
+ color: var(--text-dim);
1748
+ animation: float-circle 10s ease-in-out infinite;
1749
+ }
1750
+
1751
+ @keyframes float-circle {
1752
+ 0%, 100% { transform: translateY(0); }
1753
+ 50% { transform: translateY(-12px); }
1754
+ }
1755
+
1565
1756
  .empty-state-content {
1566
1757
  text-align: center;
1567
1758
  max-width: 420px;
1568
1759
  padding: 40px 24px;
1760
+ position: relative;
1761
+ z-index: 1;
1569
1762
  }
1570
1763
 
1571
1764
  .empty-state-icon {
@@ -6,6 +6,7 @@ interface ToolsPaneCallbacks {
6
6
  onWalkTrailRemove?: (nodeId: string) => void;
7
7
  onWalkIsolate?: () => void;
8
8
  onWalkSaveSnippet?: (label: string) => void;
9
+ onStarredSaveSnippet?: (label: string, nodeIds: string[]) => void;
9
10
  onRenameNodeType: (oldType: string, newType: string) => void;
10
11
  onRenameEdgeType: (oldType: string, newType: string) => void;
11
12
  onToggleEdgeLabels: (visible: boolean) => void;
@@ -76,6 +76,37 @@ export function initToolsPane(container, callbacks) {
76
76
  `<span>${stats.edgeCount} edges</span><span class="tools-pane-sep">&middot;</span>` +
77
77
  `<span>${stats.types.length} types</span>`;
78
78
  content.appendChild(summary);
79
+ // Token efficiency card
80
+ if (data && stats.nodeCount > 0) {
81
+ const graphTokens = Math.ceil(JSON.stringify(data).length / 4);
82
+ // Estimate a typical search response: ~5 NodeSummary results, each ~30 chars
83
+ const avgNodeTokens = Math.round(graphTokens / stats.nodeCount);
84
+ const searchTokens = Math.max(10, Math.round(avgNodeTokens * 0.3) * Math.min(5, stats.nodeCount));
85
+ const percent = graphTokens > searchTokens ? Math.round((1 - searchTokens / graphTokens) * 100) : 0;
86
+ const tokenCard = document.createElement("div");
87
+ tokenCard.className = "tools-pane-token-card";
88
+ const barWidth = Math.min(100, percent);
89
+ const label = document.createElement("div");
90
+ label.className = "token-card-label";
91
+ label.textContent = "Token Efficiency";
92
+ tokenCard.appendChild(label);
93
+ const storedStat = document.createElement("div");
94
+ storedStat.className = "token-card-stat";
95
+ storedStat.textContent = `~${graphTokens.toLocaleString()} tokens stored`;
96
+ tokenCard.appendChild(storedStat);
97
+ const bar = document.createElement("div");
98
+ bar.className = "token-card-bar";
99
+ const barFill = document.createElement("div");
100
+ barFill.className = "token-card-bar-fill";
101
+ barFill.style.width = `${barWidth}%`;
102
+ bar.appendChild(barFill);
103
+ tokenCard.appendChild(bar);
104
+ const reductionStat = document.createElement("div");
105
+ reductionStat.className = "token-card-stat";
106
+ reductionStat.textContent = `A search returns ~${searchTokens} tokens instead of ~${graphTokens.toLocaleString()} (${percent}% reduction)`;
107
+ tokenCard.appendChild(reductionStat);
108
+ content.appendChild(tokenCard);
109
+ }
79
110
  // Tab bar
80
111
  const tabBar = document.createElement("div");
81
112
  tabBar.className = "tools-pane-tabs";
@@ -442,6 +473,80 @@ export function initToolsPane(container, callbacks) {
442
473
  if (!stats)
443
474
  return;
444
475
  const qq = qualitySearch.toLowerCase();
476
+ // Starred nodes — click to navigate, focus button
477
+ const filteredStarred = stats.starred.filter((n) => !qq || n.label.toLowerCase().includes(qq) || n.type.toLowerCase().includes(qq));
478
+ if (filteredStarred.length) {
479
+ target.appendChild(makeSection("\u2605 Starred", (section) => {
480
+ for (const n of filteredStarred) {
481
+ const row = document.createElement("div");
482
+ row.className = "tools-pane-row tools-pane-clickable";
483
+ const dot = document.createElement("span");
484
+ dot.className = "tools-pane-dot";
485
+ dot.style.backgroundColor = getColor(n.type);
486
+ const name = document.createElement("span");
487
+ name.className = "tools-pane-name";
488
+ name.textContent = n.label;
489
+ const focusBtn = document.createElement("button");
490
+ focusBtn.className = "tools-pane-edit tools-pane-focus-toggle";
491
+ if (isNodeFocused(n.id))
492
+ focusBtn.classList.add("tools-pane-focus-active");
493
+ focusBtn.textContent = "\u25CE";
494
+ focusBtn.title = isNodeFocused(n.id)
495
+ ? `Remove ${n.label} from focus`
496
+ : `Add ${n.label} to focus`;
497
+ row.appendChild(dot);
498
+ row.appendChild(name);
499
+ row.appendChild(focusBtn);
500
+ row.addEventListener("click", (e) => {
501
+ if (e.target.closest(".tools-pane-edit"))
502
+ return;
503
+ callbacks.onNavigateToNode(n.id);
504
+ });
505
+ focusBtn.addEventListener("click", (e) => {
506
+ e.stopPropagation();
507
+ if (focusSet.nodeIds.has(n.id)) {
508
+ focusSet.nodeIds.delete(n.id);
509
+ }
510
+ else {
511
+ focusSet.nodeIds.add(n.id);
512
+ }
513
+ emitFocusChange();
514
+ render();
515
+ });
516
+ section.appendChild(row);
517
+ }
518
+ // Action buttons row
519
+ const actions = document.createElement("div");
520
+ actions.className = "tools-pane-row tools-pane-actions";
521
+ const focusAllBtn = document.createElement("button");
522
+ focusAllBtn.className = "tools-pane-action-btn";
523
+ focusAllBtn.textContent = "Focus all";
524
+ focusAllBtn.title = "Enter focus mode with all starred nodes";
525
+ focusAllBtn.addEventListener("click", () => {
526
+ focusSet.nodeIds.clear();
527
+ focusSet.types.clear();
528
+ for (const n of stats.starred)
529
+ focusSet.nodeIds.add(n.id);
530
+ emitFocusChange();
531
+ render();
532
+ });
533
+ actions.appendChild(focusAllBtn);
534
+ if (callbacks.onStarredSaveSnippet) {
535
+ const saveBtn = document.createElement("button");
536
+ saveBtn.className = "tools-pane-action-btn";
537
+ saveBtn.textContent = "Save as snippet";
538
+ saveBtn.title = "Save starred nodes as a reusable snippet";
539
+ saveBtn.addEventListener("click", async () => {
540
+ const label = await showPrompt("Snippet name", "starred");
541
+ if (label) {
542
+ callbacks.onStarredSaveSnippet(label, stats.starred.map((n) => n.id));
543
+ }
544
+ });
545
+ actions.appendChild(saveBtn);
546
+ }
547
+ section.appendChild(actions);
548
+ }));
549
+ }
445
550
  // Most connected nodes — click to navigate, focus button
446
551
  const filteredConnected = stats.mostConnected.filter((n) => !qq || n.label.toLowerCase().includes(qq) || n.type.toLowerCase().includes(qq));
447
552
  if (filteredConnected.length) {
@@ -838,6 +943,9 @@ export function initToolsPane(container, callbacks) {
838
943
  connectedNodes.add(edge.targetId);
839
944
  }
840
945
  const nodeLabel = (n) => firstStringValue(n.properties) ?? n.id;
946
+ const starred = graphData.nodes
947
+ .filter((n) => n.properties._starred === true)
948
+ .map((n) => ({ id: n.id, label: nodeLabel(n), type: n.type }));
841
949
  const orphans = graphData.nodes
842
950
  .filter((n) => !connectedNodes.has(n.id))
843
951
  .map((n) => ({ id: n.id, label: nodeLabel(n), type: n.type }));
@@ -866,6 +974,7 @@ export function initToolsPane(container, callbacks) {
866
974
  edgeTypes: [...edgeTypeCounts.entries()]
867
975
  .sort((a, b) => b[1] - a[1])
868
976
  .map(([name, count]) => ({ name, count })),
977
+ starred,
869
978
  orphans,
870
979
  singletons,
871
980
  emptyNodes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backpack-viewer",
3
- "version": "0.2.21",
3
+ "version": "0.3.0",
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",
@@ -23,7 +23,7 @@
23
23
  "release:major": "npm version major && git push && git push --tags"
24
24
  },
25
25
  "dependencies": {
26
- "backpack-ontology": "^0.2.24"
26
+ "backpack-ontology": "^0.3.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.5.0",