backpack-viewer 0.2.11 → 0.2.14
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 +6 -6
- package/dist/api.d.ts +4 -4
- package/dist/app/assets/index-CR8Iepyw.js +21 -0
- package/dist/app/assets/index-FMdnOuXa.css +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +8 -2
- package/dist/canvas.js +284 -25
- package/dist/empty-state.d.ts +4 -0
- package/dist/empty-state.js +27 -0
- package/dist/history.d.ts +10 -0
- package/dist/history.js +36 -0
- package/dist/info-panel.d.ts +2 -2
- package/dist/layout.d.ts +10 -3
- package/dist/layout.js +65 -11
- package/dist/main.js +242 -20
- package/dist/search.d.ts +2 -2
- package/dist/search.js +1 -1
- package/dist/shortcuts.d.ts +4 -0
- package/dist/shortcuts.js +66 -0
- package/dist/sidebar.d.ts +2 -2
- package/dist/style.css +493 -17
- package/dist/tools-pane.d.ts +18 -0
- package/dist/tools-pane.js +436 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BgwF999-.js +0 -1
- package/dist/app/assets/index-Cdb7srBF.css +0 -1
package/dist/canvas.js
CHANGED
|
@@ -16,6 +16,9 @@ export function initCanvas(container, onNodeClick) {
|
|
|
16
16
|
let animFrame = 0;
|
|
17
17
|
let selectedNodeIds = new Set();
|
|
18
18
|
let filteredNodeIds = null; // null = no filter (show all)
|
|
19
|
+
let showEdgeLabels = true;
|
|
20
|
+
let showTypeHulls = true;
|
|
21
|
+
let showMinimap = true;
|
|
19
22
|
// Pan animation state
|
|
20
23
|
let panTarget = null;
|
|
21
24
|
let panStart = null;
|
|
@@ -79,6 +82,51 @@ export function initCanvas(container, onNodeClick) {
|
|
|
79
82
|
ctx.save();
|
|
80
83
|
ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
|
|
81
84
|
ctx.scale(camera.scale, camera.scale);
|
|
85
|
+
// Draw type hulls (shaded regions behind same-type nodes)
|
|
86
|
+
if (showTypeHulls) {
|
|
87
|
+
const typeGroups = new Map();
|
|
88
|
+
for (const node of state.nodes) {
|
|
89
|
+
if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
|
|
90
|
+
continue;
|
|
91
|
+
const group = typeGroups.get(node.type) ?? [];
|
|
92
|
+
group.push(node);
|
|
93
|
+
typeGroups.set(node.type, group);
|
|
94
|
+
}
|
|
95
|
+
for (const [type, nodes] of typeGroups) {
|
|
96
|
+
if (nodes.length < 2)
|
|
97
|
+
continue;
|
|
98
|
+
const color = getColor(type);
|
|
99
|
+
const padding = NODE_RADIUS * 2.5;
|
|
100
|
+
// Compute bounding box
|
|
101
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
102
|
+
for (const n of nodes) {
|
|
103
|
+
if (n.x < minX)
|
|
104
|
+
minX = n.x;
|
|
105
|
+
if (n.y < minY)
|
|
106
|
+
minY = n.y;
|
|
107
|
+
if (n.x > maxX)
|
|
108
|
+
maxX = n.x;
|
|
109
|
+
if (n.y > maxY)
|
|
110
|
+
maxY = n.y;
|
|
111
|
+
}
|
|
112
|
+
ctx.beginPath();
|
|
113
|
+
const rx = (maxX - minX) / 2 + padding;
|
|
114
|
+
const ry = (maxY - minY) / 2 + padding;
|
|
115
|
+
const cx = (minX + maxX) / 2;
|
|
116
|
+
const cy = (minY + maxY) / 2;
|
|
117
|
+
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
118
|
+
ctx.fillStyle = color;
|
|
119
|
+
ctx.globalAlpha = 0.05;
|
|
120
|
+
ctx.fill();
|
|
121
|
+
ctx.strokeStyle = color;
|
|
122
|
+
ctx.globalAlpha = 0.12;
|
|
123
|
+
ctx.lineWidth = 1;
|
|
124
|
+
ctx.setLineDash([4, 4]);
|
|
125
|
+
ctx.stroke();
|
|
126
|
+
ctx.setLineDash([]);
|
|
127
|
+
ctx.globalAlpha = 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
82
130
|
// Draw edges
|
|
83
131
|
for (const edge of state.edges) {
|
|
84
132
|
const source = state.nodeMap.get(edge.sourceId);
|
|
@@ -114,17 +162,19 @@ export function initCanvas(container, onNodeClick) {
|
|
|
114
162
|
// Arrowhead
|
|
115
163
|
drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
|
|
116
164
|
// Edge label at midpoint
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
165
|
+
if (showEdgeLabels) {
|
|
166
|
+
const mx = (source.x + target.x) / 2;
|
|
167
|
+
const my = (source.y + target.y) / 2;
|
|
168
|
+
ctx.fillStyle = highlighted
|
|
169
|
+
? edgeLabelHighlight
|
|
170
|
+
: edgeDimmed
|
|
171
|
+
? edgeLabelDim
|
|
172
|
+
: edgeLabel;
|
|
173
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
174
|
+
ctx.textAlign = "center";
|
|
175
|
+
ctx.textBaseline = "bottom";
|
|
176
|
+
ctx.fillText(edge.type, mx, my - 4);
|
|
177
|
+
}
|
|
128
178
|
}
|
|
129
179
|
// Draw nodes
|
|
130
180
|
for (const node of state.nodes) {
|
|
@@ -173,6 +223,87 @@ export function initCanvas(container, onNodeClick) {
|
|
|
173
223
|
}
|
|
174
224
|
ctx.restore();
|
|
175
225
|
ctx.restore();
|
|
226
|
+
// Minimap
|
|
227
|
+
if (showMinimap && state.nodes.length > 1) {
|
|
228
|
+
drawMinimap();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function drawMinimap() {
|
|
232
|
+
if (!state)
|
|
233
|
+
return;
|
|
234
|
+
const mapW = 140;
|
|
235
|
+
const mapH = 100;
|
|
236
|
+
const mapPad = 8;
|
|
237
|
+
const mapX = canvas.clientWidth - mapW - 16;
|
|
238
|
+
const mapY = canvas.clientHeight - mapH - 16;
|
|
239
|
+
// Compute graph bounds
|
|
240
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
241
|
+
for (const n of state.nodes) {
|
|
242
|
+
if (n.x < minX)
|
|
243
|
+
minX = n.x;
|
|
244
|
+
if (n.y < minY)
|
|
245
|
+
minY = n.y;
|
|
246
|
+
if (n.x > maxX)
|
|
247
|
+
maxX = n.x;
|
|
248
|
+
if (n.y > maxY)
|
|
249
|
+
maxY = n.y;
|
|
250
|
+
}
|
|
251
|
+
const gw = maxX - minX || 1;
|
|
252
|
+
const gh = maxY - minY || 1;
|
|
253
|
+
const scale = Math.min((mapW - mapPad * 2) / gw, (mapH - mapPad * 2) / gh);
|
|
254
|
+
const offsetX = mapX + mapPad + ((mapW - mapPad * 2) - gw * scale) / 2;
|
|
255
|
+
const offsetY = mapY + mapPad + ((mapH - mapPad * 2) - gh * scale) / 2;
|
|
256
|
+
ctx.save();
|
|
257
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
258
|
+
// Background
|
|
259
|
+
ctx.fillStyle = cssVar("--bg-surface") || "#1a1a1a";
|
|
260
|
+
ctx.globalAlpha = 0.85;
|
|
261
|
+
ctx.beginPath();
|
|
262
|
+
ctx.roundRect(mapX, mapY, mapW, mapH, 8);
|
|
263
|
+
ctx.fill();
|
|
264
|
+
ctx.strokeStyle = cssVar("--border") || "#2a2a2a";
|
|
265
|
+
ctx.globalAlpha = 1;
|
|
266
|
+
ctx.lineWidth = 1;
|
|
267
|
+
ctx.stroke();
|
|
268
|
+
// Edges
|
|
269
|
+
ctx.globalAlpha = 0.15;
|
|
270
|
+
ctx.strokeStyle = cssVar("--canvas-edge") || "#555";
|
|
271
|
+
ctx.lineWidth = 0.5;
|
|
272
|
+
for (const edge of state.edges) {
|
|
273
|
+
const src = state.nodeMap.get(edge.sourceId);
|
|
274
|
+
const tgt = state.nodeMap.get(edge.targetId);
|
|
275
|
+
if (!src || !tgt || edge.sourceId === edge.targetId)
|
|
276
|
+
continue;
|
|
277
|
+
ctx.beginPath();
|
|
278
|
+
ctx.moveTo(offsetX + (src.x - minX) * scale, offsetY + (src.y - minY) * scale);
|
|
279
|
+
ctx.lineTo(offsetX + (tgt.x - minX) * scale, offsetY + (tgt.y - minY) * scale);
|
|
280
|
+
ctx.stroke();
|
|
281
|
+
}
|
|
282
|
+
// Nodes
|
|
283
|
+
ctx.globalAlpha = 0.8;
|
|
284
|
+
for (const node of state.nodes) {
|
|
285
|
+
const nx = offsetX + (node.x - minX) * scale;
|
|
286
|
+
const ny = offsetY + (node.y - minY) * scale;
|
|
287
|
+
ctx.beginPath();
|
|
288
|
+
ctx.arc(nx, ny, 2, 0, Math.PI * 2);
|
|
289
|
+
ctx.fillStyle = getColor(node.type);
|
|
290
|
+
ctx.fill();
|
|
291
|
+
}
|
|
292
|
+
// Viewport rectangle
|
|
293
|
+
const vx1 = camera.x;
|
|
294
|
+
const vy1 = camera.y;
|
|
295
|
+
const vx2 = camera.x + canvas.clientWidth / camera.scale;
|
|
296
|
+
const vy2 = camera.y + canvas.clientHeight / camera.scale;
|
|
297
|
+
const rx = offsetX + (vx1 - minX) * scale;
|
|
298
|
+
const ry = offsetY + (vy1 - minY) * scale;
|
|
299
|
+
const rw = (vx2 - vx1) * scale;
|
|
300
|
+
const rh = (vy2 - vy1) * scale;
|
|
301
|
+
ctx.globalAlpha = 0.3;
|
|
302
|
+
ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
|
|
303
|
+
ctx.lineWidth = 1.5;
|
|
304
|
+
ctx.strokeRect(Math.max(mapX, Math.min(rx, mapX + mapW)), Math.max(mapY, Math.min(ry, mapY + mapH)), Math.min(rw, mapW), Math.min(rh, mapH));
|
|
305
|
+
ctx.globalAlpha = 1;
|
|
306
|
+
ctx.restore();
|
|
176
307
|
}
|
|
177
308
|
function drawArrowhead(sx, sy, tx, ty, highlighted, arrowColor, arrowHighlight) {
|
|
178
309
|
const angle = Math.atan2(ty - sy, tx - sx);
|
|
@@ -195,10 +326,12 @@ export function initCanvas(container, onNodeClick) {
|
|
|
195
326
|
ctx.strokeStyle = highlighted ? edgeHighlight : edgeColor;
|
|
196
327
|
ctx.lineWidth = highlighted ? 2.5 : 1.5;
|
|
197
328
|
ctx.stroke();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
329
|
+
if (showEdgeLabels) {
|
|
330
|
+
ctx.fillStyle = highlighted ? labelHighlight : labelColor;
|
|
331
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
332
|
+
ctx.textAlign = "center";
|
|
333
|
+
ctx.fillText(type, cx, cy - 18);
|
|
334
|
+
}
|
|
202
335
|
}
|
|
203
336
|
// --- Simulation loop ---
|
|
204
337
|
function animatePan() {
|
|
@@ -313,6 +446,9 @@ export function initCanvas(container, onNodeClick) {
|
|
|
313
446
|
let touches = [];
|
|
314
447
|
let initialPinchDist = 0;
|
|
315
448
|
let initialPinchScale = 1;
|
|
449
|
+
let touchStartX = 0;
|
|
450
|
+
let touchStartY = 0;
|
|
451
|
+
let touchDidMove = false;
|
|
316
452
|
canvas.addEventListener("touchstart", (e) => {
|
|
317
453
|
e.preventDefault();
|
|
318
454
|
touches = Array.from(e.touches);
|
|
@@ -323,6 +459,9 @@ export function initCanvas(container, onNodeClick) {
|
|
|
323
459
|
else if (touches.length === 1) {
|
|
324
460
|
lastX = touches[0].clientX;
|
|
325
461
|
lastY = touches[0].clientY;
|
|
462
|
+
touchStartX = touches[0].clientX;
|
|
463
|
+
touchStartY = touches[0].clientY;
|
|
464
|
+
touchDidMove = false;
|
|
326
465
|
}
|
|
327
466
|
}, { passive: false });
|
|
328
467
|
canvas.addEventListener("touchmove", (e) => {
|
|
@@ -337,6 +476,10 @@ export function initCanvas(container, onNodeClick) {
|
|
|
337
476
|
else if (current.length === 1) {
|
|
338
477
|
const dx = current[0].clientX - lastX;
|
|
339
478
|
const dy = current[0].clientY - lastY;
|
|
479
|
+
if (Math.abs(current[0].clientX - touchStartX) > 10 ||
|
|
480
|
+
Math.abs(current[0].clientY - touchStartY) > 10) {
|
|
481
|
+
touchDidMove = true;
|
|
482
|
+
}
|
|
340
483
|
camera.x -= dx / camera.scale;
|
|
341
484
|
camera.y -= dy / camera.scale;
|
|
342
485
|
lastX = current[0].clientX;
|
|
@@ -345,6 +488,35 @@ export function initCanvas(container, onNodeClick) {
|
|
|
345
488
|
}
|
|
346
489
|
touches = current;
|
|
347
490
|
}, { passive: false });
|
|
491
|
+
canvas.addEventListener("touchend", (e) => {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
if (touchDidMove || e.changedTouches.length !== 1)
|
|
494
|
+
return;
|
|
495
|
+
const t = e.changedTouches[0];
|
|
496
|
+
const rect = canvas.getBoundingClientRect();
|
|
497
|
+
const mx = t.clientX - rect.left;
|
|
498
|
+
const my = t.clientY - rect.top;
|
|
499
|
+
const hit = nodeAtScreen(mx, my);
|
|
500
|
+
if (hit) {
|
|
501
|
+
if (selectedNodeIds.size === 1 && selectedNodeIds.has(hit.id)) {
|
|
502
|
+
selectedNodeIds.clear();
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
selectedNodeIds.clear();
|
|
506
|
+
selectedNodeIds.add(hit.id);
|
|
507
|
+
}
|
|
508
|
+
const ids = [...selectedNodeIds];
|
|
509
|
+
onNodeClick?.(ids.length > 0 ? ids : null);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
selectedNodeIds.clear();
|
|
513
|
+
onNodeClick?.(null);
|
|
514
|
+
}
|
|
515
|
+
render();
|
|
516
|
+
}, { passive: false });
|
|
517
|
+
// Prevent Safari page-level pinch zoom on the canvas
|
|
518
|
+
canvas.addEventListener("gesturestart", (e) => e.preventDefault());
|
|
519
|
+
canvas.addEventListener("gesturechange", (e) => e.preventDefault());
|
|
348
520
|
function touchDistance(a, b) {
|
|
349
521
|
const dx = a.clientX - b.clientX;
|
|
350
522
|
const dy = a.clientY - b.clientY;
|
|
@@ -446,25 +618,112 @@ export function initCanvas(container, onNodeClick) {
|
|
|
446
618
|
render();
|
|
447
619
|
},
|
|
448
620
|
panToNode(nodeId) {
|
|
449
|
-
|
|
621
|
+
this.panToNodes([nodeId]);
|
|
622
|
+
},
|
|
623
|
+
panToNodes(nodeIds) {
|
|
624
|
+
if (!state || nodeIds.length === 0)
|
|
450
625
|
return;
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
626
|
+
const nodes = nodeIds.map((id) => state.nodeMap.get(id)).filter(Boolean);
|
|
627
|
+
if (nodes.length === 0)
|
|
453
628
|
return;
|
|
454
|
-
selectedNodeIds = new Set(
|
|
455
|
-
onNodeClick?.(
|
|
629
|
+
selectedNodeIds = new Set(nodeIds);
|
|
630
|
+
onNodeClick?.(nodeIds);
|
|
456
631
|
const w = canvas.clientWidth;
|
|
457
632
|
const h = canvas.clientHeight;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
633
|
+
if (nodes.length === 1) {
|
|
634
|
+
panStart = { x: camera.x, y: camera.y, time: performance.now() };
|
|
635
|
+
panTarget = {
|
|
636
|
+
x: nodes[0].x - w / (2 * camera.scale),
|
|
637
|
+
y: nodes[0].y - h / (2 * camera.scale),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
// Fit all nodes in view with padding
|
|
642
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
643
|
+
for (const n of nodes) {
|
|
644
|
+
if (n.x < minX)
|
|
645
|
+
minX = n.x;
|
|
646
|
+
if (n.y < minY)
|
|
647
|
+
minY = n.y;
|
|
648
|
+
if (n.x > maxX)
|
|
649
|
+
maxX = n.x;
|
|
650
|
+
if (n.y > maxY)
|
|
651
|
+
maxY = n.y;
|
|
652
|
+
}
|
|
653
|
+
const pad = NODE_RADIUS * 4;
|
|
654
|
+
const bw = maxX - minX + pad * 2;
|
|
655
|
+
const bh = maxY - minY + pad * 2;
|
|
656
|
+
const fitScale = Math.min(w / bw, h / bh, camera.scale);
|
|
657
|
+
camera.scale = fitScale;
|
|
658
|
+
const cx = (minX + maxX) / 2;
|
|
659
|
+
const cy = (minY + maxY) / 2;
|
|
660
|
+
panStart = { x: camera.x, y: camera.y, time: performance.now() };
|
|
661
|
+
panTarget = {
|
|
662
|
+
x: cx - w / (2 * camera.scale),
|
|
663
|
+
y: cy - h / (2 * camera.scale),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
463
666
|
animatePan();
|
|
464
667
|
},
|
|
668
|
+
setEdgeLabels(visible) {
|
|
669
|
+
showEdgeLabels = visible;
|
|
670
|
+
render();
|
|
671
|
+
},
|
|
672
|
+
setTypeHulls(visible) {
|
|
673
|
+
showTypeHulls = visible;
|
|
674
|
+
render();
|
|
675
|
+
},
|
|
676
|
+
setMinimap(visible) {
|
|
677
|
+
showMinimap = visible;
|
|
678
|
+
render();
|
|
679
|
+
},
|
|
680
|
+
reheat() {
|
|
681
|
+
alpha = 0.5;
|
|
682
|
+
cancelAnimationFrame(animFrame);
|
|
683
|
+
simulate();
|
|
684
|
+
},
|
|
685
|
+
exportImage(format) {
|
|
686
|
+
if (!state)
|
|
687
|
+
return "";
|
|
688
|
+
// Use the actual canvas pixel dimensions (already scaled by dpr)
|
|
689
|
+
const pw = canvas.width;
|
|
690
|
+
const ph = canvas.height;
|
|
691
|
+
if (format === "png") {
|
|
692
|
+
const exportCanvas = document.createElement("canvas");
|
|
693
|
+
exportCanvas.width = pw;
|
|
694
|
+
exportCanvas.height = ph;
|
|
695
|
+
const ectx = exportCanvas.getContext("2d");
|
|
696
|
+
// Draw background
|
|
697
|
+
ectx.fillStyle = cssVar("--bg") || "#141414";
|
|
698
|
+
ectx.fillRect(0, 0, pw, ph);
|
|
699
|
+
// Copy current canvas pixels 1:1
|
|
700
|
+
ectx.drawImage(canvas, 0, 0);
|
|
701
|
+
// Watermark (scale font to match pixel density)
|
|
702
|
+
drawWatermark(ectx, pw, ph);
|
|
703
|
+
return exportCanvas.toDataURL("image/png");
|
|
704
|
+
}
|
|
705
|
+
// SVG: embed the canvas as a PNG image with text overlay
|
|
706
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
707
|
+
const fontSize = Math.max(16, Math.round(pw / 80));
|
|
708
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}">
|
|
709
|
+
<image href="${dataUrl}" width="${pw}" height="${ph}"/>
|
|
710
|
+
<text x="${pw - 20}" y="${ph - 16}" text-anchor="end" font-family="system-ui, sans-serif" font-size="${fontSize}" fill="#ffffff" opacity="0.4">backpackontology.com</text>
|
|
711
|
+
</svg>`;
|
|
712
|
+
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
|
|
713
|
+
},
|
|
465
714
|
destroy() {
|
|
466
715
|
cancelAnimationFrame(animFrame);
|
|
467
716
|
observer.disconnect();
|
|
468
717
|
},
|
|
469
718
|
};
|
|
719
|
+
function drawWatermark(ectx, w, h) {
|
|
720
|
+
const fontSize = Math.max(16, Math.round(w / 80));
|
|
721
|
+
ectx.save();
|
|
722
|
+
ectx.font = `${fontSize}px system-ui, sans-serif`;
|
|
723
|
+
ectx.fillStyle = "rgba(255, 255, 255, 0.4)";
|
|
724
|
+
ectx.textAlign = "right";
|
|
725
|
+
ectx.textBaseline = "bottom";
|
|
726
|
+
ectx.fillText("backpackontology.com", w - 20, h - 16);
|
|
727
|
+
ectx.restore();
|
|
728
|
+
}
|
|
470
729
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function initEmptyState(container) {
|
|
2
|
+
const el = document.createElement("div");
|
|
3
|
+
el.className = "empty-state";
|
|
4
|
+
el.innerHTML = `
|
|
5
|
+
<div class="empty-state-content">
|
|
6
|
+
<div class="empty-state-icon">
|
|
7
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
8
|
+
<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 002 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0022 16z"/>
|
|
9
|
+
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
|
10
|
+
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
|
11
|
+
</svg>
|
|
12
|
+
</div>
|
|
13
|
+
<h2 class="empty-state-title">No learning graphs yet</h2>
|
|
14
|
+
<p class="empty-state-desc">Connect Backpack to Claude, then start a conversation. Claude will build your first learning graph automatically.</p>
|
|
15
|
+
<div class="empty-state-setup">
|
|
16
|
+
<div class="empty-state-label">Add Backpack to Claude Code:</div>
|
|
17
|
+
<code class="empty-state-code">claude mcp add backpack-local -s user -- npx backpack-ontology@latest</code>
|
|
18
|
+
</div>
|
|
19
|
+
<p class="empty-state-hint">Press <kbd>?</kbd> for keyboard shortcuts</p>
|
|
20
|
+
</div>
|
|
21
|
+
`;
|
|
22
|
+
container.appendChild(el);
|
|
23
|
+
return {
|
|
24
|
+
show() { el.classList.remove("hidden"); },
|
|
25
|
+
hide() { el.classList.add("hidden"); },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LearningGraphData } from "backpack-ontology";
|
|
2
|
+
export declare function createHistory(): {
|
|
3
|
+
/** Call before mutating the data to snapshot the current state. */
|
|
4
|
+
push(data: LearningGraphData): void;
|
|
5
|
+
undo(currentData: LearningGraphData): LearningGraphData | null;
|
|
6
|
+
redo(currentData: LearningGraphData): LearningGraphData | null;
|
|
7
|
+
canUndo(): boolean;
|
|
8
|
+
canRedo(): boolean;
|
|
9
|
+
clear(): void;
|
|
10
|
+
};
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const MAX_HISTORY = 30;
|
|
2
|
+
export function createHistory() {
|
|
3
|
+
let undoStack = [];
|
|
4
|
+
let redoStack = [];
|
|
5
|
+
return {
|
|
6
|
+
/** Call before mutating the data to snapshot the current state. */
|
|
7
|
+
push(data) {
|
|
8
|
+
undoStack.push(JSON.stringify(data));
|
|
9
|
+
if (undoStack.length > MAX_HISTORY)
|
|
10
|
+
undoStack.shift();
|
|
11
|
+
redoStack = [];
|
|
12
|
+
},
|
|
13
|
+
undo(currentData) {
|
|
14
|
+
if (undoStack.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
redoStack.push(JSON.stringify(currentData));
|
|
17
|
+
return JSON.parse(undoStack.pop());
|
|
18
|
+
},
|
|
19
|
+
redo(currentData) {
|
|
20
|
+
if (redoStack.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
undoStack.push(JSON.stringify(currentData));
|
|
23
|
+
return JSON.parse(redoStack.pop());
|
|
24
|
+
},
|
|
25
|
+
canUndo() {
|
|
26
|
+
return undoStack.length > 0;
|
|
27
|
+
},
|
|
28
|
+
canRedo() {
|
|
29
|
+
return redoStack.length > 0;
|
|
30
|
+
},
|
|
31
|
+
clear() {
|
|
32
|
+
undoStack = [];
|
|
33
|
+
redoStack = [];
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/info-panel.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { LearningGraphData } from "backpack-ontology";
|
|
2
2
|
export interface EditCallbacks {
|
|
3
3
|
onUpdateNode(nodeId: string, properties: Record<string, unknown>): void;
|
|
4
4
|
onChangeNodeType(nodeId: string, newType: string): void;
|
|
@@ -7,7 +7,7 @@ export interface EditCallbacks {
|
|
|
7
7
|
onAddProperty(nodeId: string, key: string, value: string): void;
|
|
8
8
|
}
|
|
9
9
|
export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void): {
|
|
10
|
-
show(nodeIds: string[], data:
|
|
10
|
+
show(nodeIds: string[], data: LearningGraphData): void;
|
|
11
11
|
hide: () => void;
|
|
12
12
|
readonly visible: boolean;
|
|
13
13
|
};
|
package/dist/layout.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { LearningGraphData } from "backpack-ontology";
|
|
2
2
|
export interface LayoutNode {
|
|
3
3
|
id: string;
|
|
4
4
|
x: number;
|
|
@@ -18,7 +18,14 @@ export interface LayoutState {
|
|
|
18
18
|
edges: LayoutEdge[];
|
|
19
19
|
nodeMap: Map<string, LayoutNode>;
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
export interface LayoutParams {
|
|
22
|
+
clusterStrength: number;
|
|
23
|
+
spacing: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_LAYOUT_PARAMS: LayoutParams;
|
|
26
|
+
export declare function setLayoutParams(p: Partial<LayoutParams>): void;
|
|
27
|
+
export declare function getLayoutParams(): LayoutParams;
|
|
28
|
+
/** Create a layout state from ontology data. Nodes start grouped by type. */
|
|
29
|
+
export declare function createLayout(data: LearningGraphData): LayoutState;
|
|
23
30
|
/** Run one tick of the force simulation. Returns new alpha. */
|
|
24
31
|
export declare function tick(state: LayoutState, alpha: number): number;
|
package/dist/layout.js
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
+
export const DEFAULT_LAYOUT_PARAMS = {
|
|
2
|
+
clusterStrength: 0.05,
|
|
3
|
+
spacing: 1,
|
|
4
|
+
};
|
|
1
5
|
const REPULSION = 5000;
|
|
6
|
+
const CROSS_TYPE_REPULSION_BASE = 8000;
|
|
2
7
|
const ATTRACTION = 0.005;
|
|
3
|
-
const
|
|
8
|
+
const REST_LENGTH_SAME_BASE = 100;
|
|
9
|
+
const REST_LENGTH_CROSS_BASE = 250;
|
|
4
10
|
const DAMPING = 0.9;
|
|
5
11
|
const CENTER_GRAVITY = 0.01;
|
|
6
12
|
const MIN_DISTANCE = 30;
|
|
7
13
|
const MAX_VELOCITY = 50;
|
|
14
|
+
// Active params — mutated by setLayoutParams()
|
|
15
|
+
let params = { ...DEFAULT_LAYOUT_PARAMS };
|
|
16
|
+
export function setLayoutParams(p) {
|
|
17
|
+
if (p.clusterStrength !== undefined)
|
|
18
|
+
params.clusterStrength = p.clusterStrength;
|
|
19
|
+
if (p.spacing !== undefined)
|
|
20
|
+
params.spacing = p.spacing;
|
|
21
|
+
}
|
|
22
|
+
export function getLayoutParams() {
|
|
23
|
+
return { ...params };
|
|
24
|
+
}
|
|
8
25
|
/** Extract a display label from a node — first string property value, fallback to id. */
|
|
9
26
|
function nodeLabel(properties, id) {
|
|
10
27
|
for (const value of Object.values(properties)) {
|
|
@@ -13,16 +30,31 @@ function nodeLabel(properties, id) {
|
|
|
13
30
|
}
|
|
14
31
|
return id;
|
|
15
32
|
}
|
|
16
|
-
/** Create a layout state from ontology data. Nodes start
|
|
33
|
+
/** Create a layout state from ontology data. Nodes start grouped by type. */
|
|
17
34
|
export function createLayout(data) {
|
|
18
|
-
const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
|
|
19
35
|
const nodeMap = new Map();
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
// Group nodes by type for initial placement
|
|
37
|
+
const types = [...new Set(data.nodes.map((n) => n.type))];
|
|
38
|
+
const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
|
|
39
|
+
const typeCounters = new Map();
|
|
40
|
+
const typeSizes = new Map();
|
|
41
|
+
for (const n of data.nodes) {
|
|
42
|
+
typeSizes.set(n.type, (typeSizes.get(n.type) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
const nodes = data.nodes.map((n) => {
|
|
45
|
+
const ti = types.indexOf(n.type);
|
|
46
|
+
const typeAngle = (2 * Math.PI * ti) / Math.max(types.length, 1);
|
|
47
|
+
const cx = Math.cos(typeAngle) * typeRadius;
|
|
48
|
+
const cy = Math.sin(typeAngle) * typeRadius;
|
|
49
|
+
const ni = typeCounters.get(n.type) ?? 0;
|
|
50
|
+
typeCounters.set(n.type, ni + 1);
|
|
51
|
+
const groupSize = typeSizes.get(n.type) ?? 1;
|
|
52
|
+
const nodeAngle = (2 * Math.PI * ni) / groupSize;
|
|
53
|
+
const nodeRadius = REST_LENGTH_SAME_BASE * 0.6;
|
|
22
54
|
const node = {
|
|
23
55
|
id: n.id,
|
|
24
|
-
x: Math.cos(
|
|
25
|
-
y: Math.sin(
|
|
56
|
+
x: cx + Math.cos(nodeAngle) * nodeRadius,
|
|
57
|
+
y: cy + Math.sin(nodeAngle) * nodeRadius,
|
|
26
58
|
vx: 0,
|
|
27
59
|
vy: 0,
|
|
28
60
|
label: nodeLabel(n.properties, n.id),
|
|
@@ -41,7 +73,7 @@ export function createLayout(data) {
|
|
|
41
73
|
/** Run one tick of the force simulation. Returns new alpha. */
|
|
42
74
|
export function tick(state, alpha) {
|
|
43
75
|
const { nodes, edges, nodeMap } = state;
|
|
44
|
-
// Repulsion — all pairs
|
|
76
|
+
// Repulsion — all pairs (stronger between different types)
|
|
45
77
|
for (let i = 0; i < nodes.length; i++) {
|
|
46
78
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
47
79
|
const a = nodes[i];
|
|
@@ -51,7 +83,8 @@ export function tick(state, alpha) {
|
|
|
51
83
|
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
52
84
|
if (dist < MIN_DISTANCE)
|
|
53
85
|
dist = MIN_DISTANCE;
|
|
54
|
-
const
|
|
86
|
+
const rep = a.type === b.type ? REPULSION : CROSS_TYPE_REPULSION_BASE * params.spacing;
|
|
87
|
+
const force = (rep * alpha) / (dist * dist);
|
|
55
88
|
const fx = (dx / dist) * force;
|
|
56
89
|
const fy = (dy / dist) * force;
|
|
57
90
|
a.vx -= fx;
|
|
@@ -60,7 +93,7 @@ export function tick(state, alpha) {
|
|
|
60
93
|
b.vy += fy;
|
|
61
94
|
}
|
|
62
95
|
}
|
|
63
|
-
// Attraction — along edges
|
|
96
|
+
// Attraction — along edges (shorter rest length within same type)
|
|
64
97
|
for (const edge of edges) {
|
|
65
98
|
const source = nodeMap.get(edge.sourceId);
|
|
66
99
|
const target = nodeMap.get(edge.targetId);
|
|
@@ -71,7 +104,10 @@ export function tick(state, alpha) {
|
|
|
71
104
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
72
105
|
if (dist === 0)
|
|
73
106
|
continue;
|
|
74
|
-
const
|
|
107
|
+
const restLen = source.type === target.type
|
|
108
|
+
? REST_LENGTH_SAME_BASE * params.spacing
|
|
109
|
+
: REST_LENGTH_CROSS_BASE * params.spacing;
|
|
110
|
+
const force = ATTRACTION * (dist - restLen) * alpha;
|
|
75
111
|
const fx = (dx / dist) * force;
|
|
76
112
|
const fy = (dy / dist) * force;
|
|
77
113
|
source.vx += fx;
|
|
@@ -84,6 +120,24 @@ export function tick(state, alpha) {
|
|
|
84
120
|
node.vx -= node.x * CENTER_GRAVITY * alpha;
|
|
85
121
|
node.vy -= node.y * CENTER_GRAVITY * alpha;
|
|
86
122
|
}
|
|
123
|
+
// Cluster force — pull nodes toward their type centroid
|
|
124
|
+
const centroids = new Map();
|
|
125
|
+
for (const node of nodes) {
|
|
126
|
+
const c = centroids.get(node.type) ?? { x: 0, y: 0, count: 0 };
|
|
127
|
+
c.x += node.x;
|
|
128
|
+
c.y += node.y;
|
|
129
|
+
c.count++;
|
|
130
|
+
centroids.set(node.type, c);
|
|
131
|
+
}
|
|
132
|
+
for (const c of centroids.values()) {
|
|
133
|
+
c.x /= c.count;
|
|
134
|
+
c.y /= c.count;
|
|
135
|
+
}
|
|
136
|
+
for (const node of nodes) {
|
|
137
|
+
const c = centroids.get(node.type);
|
|
138
|
+
node.vx += (c.x - node.x) * params.clusterStrength * alpha;
|
|
139
|
+
node.vy += (c.y - node.y) * params.clusterStrength * alpha;
|
|
140
|
+
}
|
|
87
141
|
// Integrate — update positions, apply damping, clamp velocity
|
|
88
142
|
for (const node of nodes) {
|
|
89
143
|
node.vx *= DAMPING;
|