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.
@@ -0,0 +1,598 @@
1
+ import { getColor } from "./colors";
2
+ export function initToolsPane(container, callbacks) {
3
+ let data = null;
4
+ let stats = null;
5
+ let collapsed = true;
6
+ let activeTypeFilter = null;
7
+ let edgeLabelsVisible = true;
8
+ let typeHullsVisible = true;
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
+ }
43
+ // --- DOM ---
44
+ const toggle = document.createElement("button");
45
+ toggle.className = "tools-pane-toggle hidden";
46
+ toggle.title = "Graph Inspector";
47
+ toggle.innerHTML =
48
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h10"/></svg>';
49
+ const content = document.createElement("div");
50
+ content.className = "tools-pane-content hidden";
51
+ container.appendChild(toggle);
52
+ container.appendChild(content);
53
+ toggle.addEventListener("click", () => {
54
+ collapsed = !collapsed;
55
+ content.classList.toggle("hidden", collapsed);
56
+ toggle.classList.toggle("active", !collapsed);
57
+ if (!collapsed)
58
+ callbacks.onOpen?.();
59
+ });
60
+ // --- Render ---
61
+ function render() {
62
+ content.innerHTML = "";
63
+ if (!stats)
64
+ return;
65
+ // Graph stats summary
66
+ const summary = document.createElement("div");
67
+ summary.className = "tools-pane-summary";
68
+ summary.innerHTML =
69
+ `<span>${stats.nodeCount} nodes</span><span class="tools-pane-sep">&middot;</span>` +
70
+ `<span>${stats.edgeCount} edges</span><span class="tools-pane-sep">&middot;</span>` +
71
+ `<span>${stats.types.length} types</span>`;
72
+ content.appendChild(summary);
73
+ // Node types — click to filter, bullseye to toggle focus set, pencil to rename
74
+ if (stats.types.length) {
75
+ content.appendChild(makeSection("Node Types", (section) => {
76
+ for (const t of stats.types) {
77
+ const row = document.createElement("div");
78
+ row.className = "tools-pane-row tools-pane-clickable";
79
+ if (activeTypeFilter === t.name)
80
+ row.classList.add("active");
81
+ const dot = document.createElement("span");
82
+ dot.className = "tools-pane-dot";
83
+ dot.style.backgroundColor = getColor(t.name);
84
+ const name = document.createElement("span");
85
+ name.className = "tools-pane-name";
86
+ name.textContent = t.name;
87
+ const count = document.createElement("span");
88
+ count.className = "tools-pane-count";
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`;
98
+ const editBtn = document.createElement("button");
99
+ editBtn.className = "tools-pane-edit";
100
+ editBtn.textContent = "\u270E";
101
+ editBtn.title = `Rename all ${t.name} nodes`;
102
+ row.appendChild(dot);
103
+ row.appendChild(name);
104
+ row.appendChild(count);
105
+ row.appendChild(focusBtn);
106
+ row.appendChild(editBtn);
107
+ row.addEventListener("click", (e) => {
108
+ if (e.target.closest(".tools-pane-edit"))
109
+ return;
110
+ if (activeTypeFilter === t.name) {
111
+ activeTypeFilter = null;
112
+ callbacks.onFilterByType(null);
113
+ }
114
+ else {
115
+ activeTypeFilter = t.name;
116
+ callbacks.onFilterByType(t.name);
117
+ }
118
+ render();
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
+ });
131
+ editBtn.addEventListener("click", (e) => {
132
+ e.stopPropagation();
133
+ startInlineEdit(row, t.name, (newName) => {
134
+ if (newName && newName !== t.name) {
135
+ callbacks.onRenameNodeType(t.name, newName);
136
+ }
137
+ });
138
+ });
139
+ section.appendChild(row);
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
+ }
161
+ }));
162
+ }
163
+ // Edge types — with rename
164
+ if (stats.edgeTypes.length) {
165
+ content.appendChild(makeSection("Edge Types", (section) => {
166
+ for (const t of stats.edgeTypes) {
167
+ const row = document.createElement("div");
168
+ row.className = "tools-pane-row tools-pane-clickable";
169
+ const name = document.createElement("span");
170
+ name.className = "tools-pane-name";
171
+ name.textContent = t.name;
172
+ const count = document.createElement("span");
173
+ count.className = "tools-pane-count";
174
+ count.textContent = String(t.count);
175
+ const editBtn = document.createElement("button");
176
+ editBtn.className = "tools-pane-edit";
177
+ editBtn.textContent = "\u270E";
178
+ editBtn.title = `Rename all ${t.name} edges`;
179
+ row.appendChild(name);
180
+ row.appendChild(count);
181
+ row.appendChild(editBtn);
182
+ editBtn.addEventListener("click", (e) => {
183
+ e.stopPropagation();
184
+ startInlineEdit(row, t.name, (newName) => {
185
+ if (newName && newName !== t.name) {
186
+ callbacks.onRenameEdgeType(t.name, newName);
187
+ }
188
+ });
189
+ });
190
+ section.appendChild(row);
191
+ }
192
+ }));
193
+ }
194
+ // Most connected nodes — click to navigate, focus button
195
+ if (stats.mostConnected.length) {
196
+ content.appendChild(makeSection("Most Connected", (section) => {
197
+ for (const n of stats.mostConnected) {
198
+ const row = document.createElement("div");
199
+ row.className = "tools-pane-row tools-pane-clickable";
200
+ const dot = document.createElement("span");
201
+ dot.className = "tools-pane-dot";
202
+ dot.style.backgroundColor = getColor(n.type);
203
+ const name = document.createElement("span");
204
+ name.className = "tools-pane-name";
205
+ name.textContent = n.label;
206
+ const count = document.createElement("span");
207
+ count.className = "tools-pane-count";
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`;
217
+ row.appendChild(dot);
218
+ row.appendChild(name);
219
+ row.appendChild(count);
220
+ row.appendChild(focusBtn);
221
+ row.addEventListener("click", (e) => {
222
+ if (e.target.closest(".tools-pane-edit"))
223
+ return;
224
+ callbacks.onNavigateToNode(n.id);
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
+ });
237
+ section.appendChild(row);
238
+ }
239
+ }));
240
+ }
241
+ // Quality issues
242
+ const issues = [];
243
+ if (stats.orphans.length)
244
+ issues.push(`${stats.orphans.length} orphan${stats.orphans.length > 1 ? "s" : ""}`);
245
+ if (stats.singletons.length)
246
+ issues.push(`${stats.singletons.length} singleton type${stats.singletons.length > 1 ? "s" : ""}`);
247
+ if (stats.emptyNodes.length)
248
+ issues.push(`${stats.emptyNodes.length} empty node${stats.emptyNodes.length > 1 ? "s" : ""}`);
249
+ if (issues.length) {
250
+ content.appendChild(makeSection("Quality", (section) => {
251
+ // Orphans — click to navigate, focus button
252
+ for (const o of stats.orphans.slice(0, 5)) {
253
+ const row = document.createElement("div");
254
+ row.className = "tools-pane-row tools-pane-clickable tools-pane-issue";
255
+ const dot = document.createElement("span");
256
+ dot.className = "tools-pane-dot";
257
+ dot.style.backgroundColor = getColor(o.type);
258
+ const name = document.createElement("span");
259
+ name.className = "tools-pane-name";
260
+ name.textContent = o.label;
261
+ const badge = document.createElement("span");
262
+ badge.className = "tools-pane-badge";
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`;
272
+ row.appendChild(dot);
273
+ row.appendChild(name);
274
+ row.appendChild(badge);
275
+ row.appendChild(focusBtn);
276
+ row.addEventListener("click", (e) => {
277
+ if (e.target.closest(".tools-pane-edit"))
278
+ return;
279
+ callbacks.onNavigateToNode(o.id);
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
+ });
292
+ section.appendChild(row);
293
+ }
294
+ if (stats.orphans.length > 5) {
295
+ const more = document.createElement("div");
296
+ more.className = "tools-pane-more";
297
+ more.textContent = `+ ${stats.orphans.length - 5} more orphans`;
298
+ section.appendChild(more);
299
+ }
300
+ // Singleton types
301
+ for (const s of stats.singletons.slice(0, 5)) {
302
+ const row = document.createElement("div");
303
+ row.className = "tools-pane-row tools-pane-issue";
304
+ const dot = document.createElement("span");
305
+ dot.className = "tools-pane-dot";
306
+ dot.style.backgroundColor = getColor(s.name);
307
+ const name = document.createElement("span");
308
+ name.className = "tools-pane-name";
309
+ name.textContent = s.name;
310
+ const badge = document.createElement("span");
311
+ badge.className = "tools-pane-badge";
312
+ badge.textContent = "1 node";
313
+ row.appendChild(dot);
314
+ row.appendChild(name);
315
+ row.appendChild(badge);
316
+ section.appendChild(row);
317
+ }
318
+ }));
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
+ }
351
+ // Controls section
352
+ content.appendChild(makeSection("Controls", (section) => {
353
+ // Edge labels toggle
354
+ const labelRow = document.createElement("div");
355
+ labelRow.className = "tools-pane-row tools-pane-clickable";
356
+ const labelCheck = document.createElement("input");
357
+ labelCheck.type = "checkbox";
358
+ labelCheck.checked = edgeLabelsVisible;
359
+ labelCheck.className = "tools-pane-checkbox";
360
+ const labelText = document.createElement("span");
361
+ labelText.className = "tools-pane-name";
362
+ labelText.textContent = "Edge labels";
363
+ labelRow.appendChild(labelCheck);
364
+ labelRow.appendChild(labelText);
365
+ labelRow.addEventListener("click", (e) => {
366
+ if (e.target !== labelCheck)
367
+ labelCheck.checked = !labelCheck.checked;
368
+ edgeLabelsVisible = labelCheck.checked;
369
+ callbacks.onToggleEdgeLabels(edgeLabelsVisible);
370
+ });
371
+ section.appendChild(labelRow);
372
+ // Type hulls toggle
373
+ const hullRow = document.createElement("div");
374
+ hullRow.className = "tools-pane-row tools-pane-clickable";
375
+ const hullCheck = document.createElement("input");
376
+ hullCheck.type = "checkbox";
377
+ hullCheck.checked = typeHullsVisible;
378
+ hullCheck.className = "tools-pane-checkbox";
379
+ const hullText = document.createElement("span");
380
+ hullText.className = "tools-pane-name";
381
+ hullText.textContent = "Type regions";
382
+ hullRow.appendChild(hullCheck);
383
+ hullRow.appendChild(hullText);
384
+ hullRow.addEventListener("click", (e) => {
385
+ if (e.target !== hullCheck)
386
+ hullCheck.checked = !hullCheck.checked;
387
+ typeHullsVisible = hullCheck.checked;
388
+ callbacks.onToggleTypeHulls(typeHullsVisible);
389
+ });
390
+ section.appendChild(hullRow);
391
+ // Minimap toggle
392
+ const mapRow = document.createElement("div");
393
+ mapRow.className = "tools-pane-row tools-pane-clickable";
394
+ const mapCheck = document.createElement("input");
395
+ mapCheck.type = "checkbox";
396
+ mapCheck.checked = minimapVisible;
397
+ mapCheck.className = "tools-pane-checkbox";
398
+ const mapText = document.createElement("span");
399
+ mapText.className = "tools-pane-name";
400
+ mapText.textContent = "Minimap";
401
+ mapRow.appendChild(mapCheck);
402
+ mapRow.appendChild(mapText);
403
+ mapRow.addEventListener("click", (e) => {
404
+ if (e.target !== mapCheck)
405
+ mapCheck.checked = !mapCheck.checked;
406
+ minimapVisible = mapCheck.checked;
407
+ callbacks.onToggleMinimap(minimapVisible);
408
+ });
409
+ section.appendChild(mapRow);
410
+ // Layout sliders
411
+ section.appendChild(makeSlider("Clustering", 0, 0.15, 0.01, 0.05, (v) => {
412
+ callbacks.onLayoutChange("clusterStrength", v);
413
+ }));
414
+ section.appendChild(makeSlider("Spacing", 0.5, 3, 0.1, 1, (v) => {
415
+ callbacks.onLayoutChange("spacing", v);
416
+ }));
417
+ // Export buttons
418
+ const exportRow = document.createElement("div");
419
+ exportRow.className = "tools-pane-export-row";
420
+ const pngBtn = document.createElement("button");
421
+ pngBtn.className = "tools-pane-export-btn";
422
+ pngBtn.textContent = "Export PNG";
423
+ pngBtn.addEventListener("click", () => callbacks.onExport("png"));
424
+ const svgBtn = document.createElement("button");
425
+ svgBtn.className = "tools-pane-export-btn";
426
+ svgBtn.textContent = "Export SVG";
427
+ svgBtn.addEventListener("click", () => callbacks.onExport("svg"));
428
+ exportRow.appendChild(pngBtn);
429
+ exportRow.appendChild(svgBtn);
430
+ section.appendChild(exportRow);
431
+ }));
432
+ }
433
+ function makeSlider(label, min, max, step, initial, onChange) {
434
+ const row = document.createElement("div");
435
+ row.className = "tools-pane-slider-row";
436
+ const lbl = document.createElement("span");
437
+ lbl.className = "tools-pane-slider-label";
438
+ lbl.textContent = label;
439
+ const input = document.createElement("input");
440
+ input.type = "range";
441
+ input.className = "tools-pane-slider";
442
+ input.min = String(min);
443
+ input.max = String(max);
444
+ input.step = String(step);
445
+ input.value = String(initial);
446
+ const val = document.createElement("span");
447
+ val.className = "tools-pane-slider-value";
448
+ val.textContent = String(initial);
449
+ input.addEventListener("input", () => {
450
+ const v = parseFloat(input.value);
451
+ val.textContent = v % 1 === 0 ? String(v) : v.toFixed(2);
452
+ onChange(v);
453
+ });
454
+ row.appendChild(lbl);
455
+ row.appendChild(input);
456
+ row.appendChild(val);
457
+ return row;
458
+ }
459
+ function makeSection(title, build) {
460
+ const section = document.createElement("div");
461
+ section.className = "tools-pane-section";
462
+ const heading = document.createElement("div");
463
+ heading.className = "tools-pane-heading";
464
+ heading.textContent = title;
465
+ section.appendChild(heading);
466
+ build(section);
467
+ return section;
468
+ }
469
+ // --- Inline editing ---
470
+ function startInlineEdit(row, currentValue, onCommit) {
471
+ const input = document.createElement("input");
472
+ input.className = "tools-pane-inline-input";
473
+ input.value = currentValue;
474
+ input.type = "text";
475
+ // Replace row content with input
476
+ const original = row.innerHTML;
477
+ row.innerHTML = "";
478
+ row.classList.add("tools-pane-editing");
479
+ row.appendChild(input);
480
+ input.focus();
481
+ input.select();
482
+ function commit() {
483
+ const newValue = input.value.trim();
484
+ row.classList.remove("tools-pane-editing");
485
+ if (newValue && newValue !== currentValue) {
486
+ onCommit(newValue);
487
+ }
488
+ else {
489
+ row.innerHTML = original;
490
+ }
491
+ }
492
+ input.addEventListener("keydown", (e) => {
493
+ if (e.key === "Enter") {
494
+ e.preventDefault();
495
+ commit();
496
+ }
497
+ if (e.key === "Escape") {
498
+ row.innerHTML = original;
499
+ row.classList.remove("tools-pane-editing");
500
+ }
501
+ });
502
+ input.addEventListener("blur", commit);
503
+ }
504
+ // --- Derive stats from graph data ---
505
+ function deriveStats(graphData) {
506
+ const typeCounts = new Map();
507
+ const edgeTypeCounts = new Map();
508
+ const connectionCounts = new Map();
509
+ const connectedNodes = new Set();
510
+ for (const node of graphData.nodes) {
511
+ typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
512
+ }
513
+ for (const edge of graphData.edges) {
514
+ edgeTypeCounts.set(edge.type, (edgeTypeCounts.get(edge.type) ?? 0) + 1);
515
+ connectionCounts.set(edge.sourceId, (connectionCounts.get(edge.sourceId) ?? 0) + 1);
516
+ connectionCounts.set(edge.targetId, (connectionCounts.get(edge.targetId) ?? 0) + 1);
517
+ connectedNodes.add(edge.sourceId);
518
+ connectedNodes.add(edge.targetId);
519
+ }
520
+ const nodeLabel = (n) => firstStringValue(n.properties) ?? n.id;
521
+ const orphans = graphData.nodes
522
+ .filter((n) => !connectedNodes.has(n.id))
523
+ .map((n) => ({ id: n.id, label: nodeLabel(n), type: n.type }));
524
+ const singletons = [...typeCounts.entries()]
525
+ .filter(([, count]) => count === 1)
526
+ .map(([name]) => ({ name }));
527
+ const emptyNodes = graphData.nodes
528
+ .filter((n) => Object.keys(n.properties).length === 0)
529
+ .map((n) => ({ id: n.id, label: n.id, type: n.type }));
530
+ const mostConnected = graphData.nodes
531
+ .map((n) => ({
532
+ id: n.id,
533
+ label: nodeLabel(n),
534
+ type: n.type,
535
+ connections: connectionCounts.get(n.id) ?? 0,
536
+ }))
537
+ .filter((n) => n.connections > 0)
538
+ .sort((a, b) => b.connections - a.connections)
539
+ .slice(0, 5);
540
+ return {
541
+ nodeCount: graphData.nodes.length,
542
+ edgeCount: graphData.edges.length,
543
+ types: [...typeCounts.entries()]
544
+ .sort((a, b) => b[1] - a[1])
545
+ .map(([name, count]) => ({ name, count })),
546
+ edgeTypes: [...edgeTypeCounts.entries()]
547
+ .sort((a, b) => b[1] - a[1])
548
+ .map(([name, count]) => ({ name, count })),
549
+ orphans,
550
+ singletons,
551
+ emptyNodes,
552
+ mostConnected,
553
+ };
554
+ }
555
+ // --- Public API ---
556
+ return {
557
+ collapse() {
558
+ collapsed = true;
559
+ content.classList.add("hidden");
560
+ toggle.classList.remove("active");
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
+ },
574
+ setData(newData) {
575
+ data = newData;
576
+ activeTypeFilter = null;
577
+ focusSet.types.clear();
578
+ focusSet.nodeIds.clear();
579
+ if (data && data.nodes.length > 0) {
580
+ stats = deriveStats(data);
581
+ toggle.classList.remove("hidden");
582
+ render();
583
+ }
584
+ else {
585
+ stats = null;
586
+ toggle.classList.add("hidden");
587
+ content.classList.add("hidden");
588
+ }
589
+ },
590
+ };
591
+ }
592
+ function firstStringValue(properties) {
593
+ for (const value of Object.values(properties)) {
594
+ if (typeof value === "string")
595
+ return value;
596
+ }
597
+ return null;
598
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backpack-viewer",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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",
@@ -1 +0,0 @@
1
- *{margin:0;padding:0;box-sizing:border-box}:root{--bg: #141414;--bg-surface: #1a1a1a;--bg-hover: #222222;--bg-active: #2a2a2a;--bg-elevated: #1e1e1e;--bg-inset: #111111;--border: #2a2a2a;--text: #d4d4d4;--text-strong: #e5e5e5;--text-muted: #737373;--text-dim: #525252;--accent: #d4a27f;--accent-hover: #e8b898;--badge-text: #141414;--glass-bg: rgba(20, 20, 20, .85);--glass-border: rgba(255, 255, 255, .08);--chip-bg: rgba(42, 42, 42, .7);--chip-bg-active: rgba(42, 42, 42, .9);--chip-bg-hover: rgba(50, 50, 50, .9);--chip-border-active: rgba(255, 255, 255, .06);--shadow: rgba(0, 0, 0, .6);--shadow-strong: rgba(0, 0, 0, .5);--canvas-edge: rgba(255, 255, 255, .08);--canvas-edge-highlight: rgba(212, 162, 127, .5);--canvas-edge-dim: rgba(255, 255, 255, .03);--canvas-edge-label: rgba(255, 255, 255, .2);--canvas-edge-label-highlight: rgba(212, 162, 127, .7);--canvas-edge-label-dim: rgba(255, 255, 255, .05);--canvas-arrow: rgba(255, 255, 255, .12);--canvas-arrow-highlight: rgba(212, 162, 127, .5);--canvas-node-label: #a3a3a3;--canvas-node-label-dim: rgba(212, 212, 212, .2);--canvas-type-badge: rgba(115, 115, 115, .5);--canvas-type-badge-dim: rgba(115, 115, 115, .15);--canvas-selection-border: #d4d4d4;--canvas-node-border: rgba(255, 255, 255, .15)}[data-theme=light]{--bg: #f5f5f4;--bg-surface: #fafaf9;--bg-hover: #f0efee;--bg-active: #e7e5e4;--bg-elevated: #f0efee;--bg-inset: #e7e5e4;--border: #d6d3d1;--text: #292524;--text-strong: #1c1917;--text-muted: #78716c;--text-dim: #a8a29e;--accent: #c17856;--accent-hover: #b07a5e;--badge-text: #fafaf9;--glass-bg: rgba(250, 250, 249, .85);--glass-border: rgba(0, 0, 0, .08);--chip-bg: rgba(214, 211, 209, .5);--chip-bg-active: rgba(214, 211, 209, .8);--chip-bg-hover: rgba(200, 197, 195, .8);--chip-border-active: rgba(0, 0, 0, .08);--shadow: rgba(0, 0, 0, .1);--shadow-strong: rgba(0, 0, 0, .15);--canvas-edge: rgba(0, 0, 0, .1);--canvas-edge-highlight: rgba(193, 120, 86, .6);--canvas-edge-dim: rgba(0, 0, 0, .03);--canvas-edge-label: rgba(0, 0, 0, .25);--canvas-edge-label-highlight: rgba(193, 120, 86, .8);--canvas-edge-label-dim: rgba(0, 0, 0, .06);--canvas-arrow: rgba(0, 0, 0, .15);--canvas-arrow-highlight: rgba(193, 120, 86, .6);--canvas-node-label: #57534e;--canvas-node-label-dim: rgba(87, 83, 78, .2);--canvas-type-badge: rgba(87, 83, 78, .5);--canvas-type-badge-dim: rgba(87, 83, 78, .15);--canvas-selection-border: #292524;--canvas-node-border: rgba(0, 0, 0, .1)}body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);overflow:hidden}#app{display:flex;height:100vh;width:100vw}#sidebar{width:280px;min-width:280px;background:var(--bg-surface);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:16px;overflow-y:auto}#sidebar h2{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:14px}#sidebar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;outline:none;margin-bottom:12px}#sidebar input:focus{border-color:var(--accent)}#sidebar input::placeholder{color:var(--text-dim)}#ontology-list{list-style:none;display:flex;flex-direction:column;gap:2px}.ontology-item{padding:10px 12px;border-radius:6px;cursor:pointer;transition:background .15s}.ontology-item:hover{background:var(--bg-hover)}.ontology-item.active{background:var(--bg-active)}.ontology-item .name{display:block;font-size:13px;font-weight:500;color:var(--text)}.ontology-item .stats{display:block;font-size:11px;color:var(--text-dim);margin-top:2px}.sidebar-edit-btn{position:absolute;right:8px;top:10px;background:none;border:none;color:var(--text-dim);font-size:11px;cursor:pointer;opacity:0;transition:opacity .1s}.ontology-item{position:relative}.ontology-item:hover .sidebar-edit-btn{opacity:.7}.sidebar-edit-btn:hover{opacity:1!important;color:var(--text)}.sidebar-rename-input{background:transparent;border:none;border-bottom:1px solid var(--accent);color:var(--text);font:inherit;font-size:13px;font-weight:500;outline:none;width:100%;padding:0}.sidebar-footer{margin-top:auto;padding-top:16px;border-top:1px solid var(--border);text-align:center}.sidebar-footer a{display:block;font-size:12px;font-weight:500;color:var(--accent);text-decoration:none;margin-bottom:4px}.sidebar-footer a:hover{color:var(--accent-hover)}.sidebar-footer span{display:block;font-size:10px;color:var(--text-dim)}.theme-toggle{position:absolute;top:16px;right:16px;z-index:30;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text-muted);font-size:18px;cursor:pointer;padding:6px 10px;line-height:1;transition:color .15s,border-color .15s,background .15s;box-shadow:0 2px 8px var(--shadow)}.theme-toggle:hover{color:var(--text);border-color:var(--text-muted);background:var(--bg-hover)}.zoom-controls{position:absolute;top:16px;right:16px;z-index:30;display:flex;gap:4px;transition:right .2s ease}.theme-toggle~.zoom-controls{right:58px}.zoom-btn{background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text-muted);font-size:18px;cursor:pointer;padding:6px 10px;line-height:1;transition:color .15s,border-color .15s,background .15s;box-shadow:0 2px 8px var(--shadow)}.zoom-btn:hover{color:var(--text);border-color:var(--text-muted);background:var(--bg-hover)}#canvas-container{flex:1;position:relative;overflow:hidden;touch-action:none}#graph-canvas{position:absolute;top:0;left:0;touch-action:none;width:100%;height:100%;cursor:grab}#graph-canvas:active{cursor:grabbing}.search-overlay{position:absolute;top:16px;left:50%;transform:translate(-50%);z-index:20;display:flex;flex-direction:column;align-items:center;gap:8px;max-height:calc(100vh - 48px);pointer-events:none}.search-overlay>*{pointer-events:auto}.search-overlay.hidden{display:none}.search-input-wrap{position:relative;display:flex;align-items:center;gap:6px;width:380px;max-width:calc(100vw - 340px)}.search-input{flex:1;min-width:0;padding:10px 36px 10px 16px;border:1px solid var(--glass-border);border-radius:10px;background:var(--glass-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);color:var(--text);font-size:14px;outline:none;transition:border-color .15s,box-shadow .15s}.search-input:focus{border-color:#d4a27f66;box-shadow:0 0 0 3px #d4a27f1a}.search-input::placeholder{color:var(--text-dim)}.search-kbd{position:absolute;right:10px;top:50%;transform:translateY(-50%);padding:2px 7px;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-dim);font-size:11px;font-family:monospace;pointer-events:none}.search-kbd.hidden{display:none}.search-results{list-style:none;width:380px;max-width:calc(100vw - 340px);background:var(--glass-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:10px;overflow:hidden;box-shadow:0 8px 32px var(--shadow-strong)}.search-results.hidden{display:none}.search-result-item{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;transition:background .1s}.search-result-item:hover{background:var(--bg-hover)}.search-result-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.search-result-label{font-size:13px;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.search-result-type{font-size:11px;color:var(--text-dim);flex-shrink:0}.chip-toggle{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border:1px solid var(--glass-border);border-radius:10px;background:var(--glass-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);color:var(--text-dim);cursor:pointer;transition:border-color .15s,color .15s;pointer-events:auto}.chip-toggle:hover{border-color:#d4a27f4d;color:var(--text)}.chip-toggle.active{border-color:#d4a27f66;color:var(--accent)}.type-chips{display:flex;flex-wrap:wrap;gap:4px;justify-content:center;max-width:500px;max-height:200px;overflow-y:auto;padding:4px;border-radius:10px}.type-chips.hidden{display:none}.type-chip{display:flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid transparent;border-radius:12px;background:var(--chip-bg);color:var(--text-dim);font-size:11px;cursor:pointer;transition:all .15s;white-space:nowrap}.type-chip.active{background:var(--chip-bg-active);color:var(--text-muted);border-color:var(--chip-border-active)}.type-chip:hover{background:var(--chip-bg-hover)}.type-chip-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.type-chip:not(.active) .type-chip-dot{opacity:.3}.info-panel{position:absolute;top:56px;right:16px;width:360px;max-height:calc(100vh - 72px);background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;overflow-y:auto;padding:20px;z-index:10;box-shadow:0 8px 32px var(--shadow);transition:top .25s ease,right .25s ease,bottom .25s ease,left .25s ease,width .25s ease,max-height .25s ease,border-radius .25s ease}.info-panel.hidden{display:none}.info-panel.info-panel-maximized{top:0;right:0;bottom:0;left:0;width:auto;max-height:none;border-radius:0;z-index:40}.info-panel-toolbar{position:absolute;top:12px;right:14px;display:flex;align-items:center;gap:2px;z-index:1}.info-toolbar-btn{background:none;border:none;color:var(--text-muted);font-size:16px;cursor:pointer;padding:4px 6px;line-height:1;border-radius:4px;transition:color .15s,background .15s}.info-toolbar-btn:hover:not(:disabled){color:var(--text);background:var(--bg-hover)}.info-toolbar-btn:disabled{color:var(--text-dim);cursor:default;opacity:.3}.info-close-btn{font-size:20px}.info-connection-link{cursor:pointer;transition:background .15s}.info-connection-link:hover{background:var(--bg-active)}.info-connection-link .info-target{color:var(--accent);text-decoration:underline;text-decoration-color:transparent;transition:text-decoration-color .15s}.info-connection-link:hover .info-target{text-decoration-color:var(--accent)}.info-header{margin-bottom:16px}.info-type-badge{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;color:var(--badge-text);margin-bottom:8px}.info-label{font-size:18px;font-weight:600;color:var(--text-strong);margin-bottom:4px;word-break:break-word}.info-id{display:block;font-size:11px;color:var(--text-dim);font-family:monospace}.info-section{margin-bottom:16px}.info-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border)}.info-props{display:grid;grid-template-columns:auto 1fr;gap:4px 12px}.info-props dt{font-size:12px;color:var(--text-muted);padding-top:2px}.info-props dd{font-size:12px;color:var(--text);word-break:break-word;display:flex;align-items:center;gap:4px}.info-value{white-space:pre-wrap}.info-array{display:flex;flex-wrap:wrap;gap:4px}.info-tag{display:inline-block;padding:2px 8px;background:var(--bg-hover);border-radius:4px;font-size:11px;color:var(--text-muted)}.info-json{font-size:11px;font-family:monospace;color:var(--text-muted);background:var(--bg-inset);padding:6px 8px;border-radius:4px;overflow-x:auto;white-space:pre}.info-connections{list-style:none;display:flex;flex-direction:column;gap:6px}.info-connection{display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--bg-elevated);border-radius:6px;font-size:12px;flex-wrap:wrap}.info-target-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.info-arrow{color:var(--text-dim);font-size:14px;flex-shrink:0}.info-edge-type{color:var(--text-muted);font-size:11px;font-weight:500}.info-target{color:var(--text);font-weight:500}.info-edge-props{width:100%;padding-top:4px;padding-left:20px}.info-edge-prop{display:block;font-size:11px;color:var(--text-dim)}.info-editable{cursor:default;position:relative}.info-inline-edit{background:none;border:none;color:var(--badge-text);opacity:0;font-size:10px;cursor:pointer;margin-left:4px;transition:opacity .15s}.info-editable:hover .info-inline-edit{opacity:.8}.info-inline-edit:hover{opacity:1!important}.info-edit-inline-input{background:transparent;border:none;border-bottom:1px solid var(--accent);color:var(--badge-text);font:inherit;font-size:inherit;outline:none;width:100%;padding:0}.info-edit-input{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--text);flex:1;min-width:0}.info-edit-input:focus{outline:none;border-color:var(--accent)}.info-delete-prop{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 2px;flex-shrink:0;opacity:0;transition:opacity .1s,color .1s}.info-props dd:hover .info-delete-prop{opacity:1}.info-delete-prop:hover{color:#ef4444}.info-add-btn{background:none;border:1px dashed var(--border);border-radius:4px;padding:6px 10px;font-size:12px;color:var(--text-dim);cursor:pointer;width:100%;margin-top:8px;transition:border-color .15s,color .15s}.info-add-btn:hover{border-color:var(--accent);color:var(--text)}.info-add-row{display:flex;gap:4px;margin-top:6px}.info-add-save{background:var(--accent);border:none;border-radius:4px;padding:3px 10px;font-size:12px;color:var(--badge-text);cursor:pointer;flex-shrink:0}.info-add-save:hover{background:var(--accent-hover)}.info-delete-edge{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;margin-left:auto;padding:0 2px;opacity:0;transition:opacity .1s,color .1s}.info-connection:hover .info-delete-edge{opacity:1}.info-delete-edge:hover{color:#ef4444}.info-danger{margin-top:8px;padding-top:12px;border-top:1px solid var(--border)}.info-delete-node{background:none;border:1px solid rgba(239,68,68,.3);border-radius:6px;padding:6px 12px;font-size:12px;color:#ef4444;cursor:pointer;width:100%;transition:background .15s}.info-delete-node:hover{background:#ef44441a}@media(max-width:768px){.info-panel{top:auto;bottom:72px;right:8px;left:8px;width:auto;max-height:calc(100% - 200px);overflow-y:auto}.info-panel.info-panel-maximized{bottom:0;left:0;right:0}}