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/README.md +2 -0
- package/bin/serve.js +122 -1
- package/dist/api.d.ts +13 -0
- package/dist/api.js +12 -0
- package/dist/app/assets/index-CBjy2b6N.js +34 -0
- package/dist/app/assets/index-CvkozBSE.css +1 -0
- package/dist/app/assets/layout-worker-BZXiBoiC.js +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +2 -0
- package/dist/canvas.js +473 -161
- package/dist/empty-state.js +13 -0
- package/dist/info-panel.js +2 -2
- package/dist/label-cache.d.ts +14 -0
- package/dist/label-cache.js +54 -0
- package/dist/layout-worker.d.ts +17 -0
- package/dist/layout-worker.js +78 -0
- package/dist/layout.js +73 -18
- package/dist/main.js +47 -14
- package/dist/quadtree.d.ts +43 -0
- package/dist/quadtree.js +147 -0
- package/dist/sidebar.d.ts +2 -0
- package/dist/sidebar.js +90 -1
- package/dist/spatial-hash.d.ts +22 -0
- package/dist/spatial-hash.js +67 -0
- package/dist/style.css +193 -0
- package/dist/tools-pane.d.ts +1 -0
- package/dist/tools-pane.js +109 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BBfZ1JvO.js +0 -21
- package/dist/app/assets/index-DNiYjxNx.css +0 -1
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
|
-
|
|
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 {
|
package/dist/tools-pane.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools-pane.js
CHANGED
|
@@ -76,6 +76,37 @@ export function initToolsPane(container, callbacks) {
|
|
|
76
76
|
`<span>${stats.edgeCount} edges</span><span class="tools-pane-sep">·</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.
|
|
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.
|
|
26
|
+
"backpack-ontology": "^0.3.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^25.5.0",
|