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.
- package/dist/app/assets/index-Mi0vDG5K.js +21 -0
- package/dist/app/assets/index-z15vEFEy.css +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +16 -1
- package/dist/canvas.js +326 -27
- 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 +1 -1
- package/dist/info-panel.js +32 -11
- package/dist/layout.d.ts +10 -1
- package/dist/layout.js +91 -11
- package/dist/main.js +364 -17
- package/dist/shortcuts.d.ts +4 -0
- package/dist/shortcuts.js +67 -0
- package/dist/style.css +557 -17
- package/dist/tools-pane.d.ts +21 -0
- package/dist/tools-pane.js +598 -0
- package/package.json +1 -1
- package/dist/app/assets/index-C1crWHUS.css +0 -1
- package/dist/app/assets/index-DI_1rZKx.js +0 -1
package/dist/canvas.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createLayout, tick } from "./layout";
|
|
1
|
+
import { createLayout, extractSubgraph, tick } from "./layout";
|
|
2
2
|
import { getColor } from "./colors";
|
|
3
3
|
/** Read a CSS custom property from :root. */
|
|
4
4
|
function cssVar(name) {
|
|
@@ -6,7 +6,7 @@ function cssVar(name) {
|
|
|
6
6
|
}
|
|
7
7
|
const NODE_RADIUS = 20;
|
|
8
8
|
const ALPHA_MIN = 0.001;
|
|
9
|
-
export function initCanvas(container, onNodeClick) {
|
|
9
|
+
export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
10
10
|
const canvas = container.querySelector("canvas");
|
|
11
11
|
const ctx = canvas.getContext("2d");
|
|
12
12
|
const dpr = window.devicePixelRatio || 1;
|
|
@@ -16,6 +16,15 @@ 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;
|
|
22
|
+
// Focus mode state
|
|
23
|
+
let lastLoadedData = null;
|
|
24
|
+
let focusSeedIds = null;
|
|
25
|
+
let focusHops = 1;
|
|
26
|
+
let savedFullState = null;
|
|
27
|
+
let savedFullCamera = null;
|
|
19
28
|
// Pan animation state
|
|
20
29
|
let panTarget = null;
|
|
21
30
|
let panStart = null;
|
|
@@ -79,6 +88,51 @@ export function initCanvas(container, onNodeClick) {
|
|
|
79
88
|
ctx.save();
|
|
80
89
|
ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
|
|
81
90
|
ctx.scale(camera.scale, camera.scale);
|
|
91
|
+
// Draw type hulls (shaded regions behind same-type nodes)
|
|
92
|
+
if (showTypeHulls) {
|
|
93
|
+
const typeGroups = new Map();
|
|
94
|
+
for (const node of state.nodes) {
|
|
95
|
+
if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
|
|
96
|
+
continue;
|
|
97
|
+
const group = typeGroups.get(node.type) ?? [];
|
|
98
|
+
group.push(node);
|
|
99
|
+
typeGroups.set(node.type, group);
|
|
100
|
+
}
|
|
101
|
+
for (const [type, nodes] of typeGroups) {
|
|
102
|
+
if (nodes.length < 2)
|
|
103
|
+
continue;
|
|
104
|
+
const color = getColor(type);
|
|
105
|
+
const padding = NODE_RADIUS * 2.5;
|
|
106
|
+
// Compute bounding box
|
|
107
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
108
|
+
for (const n of nodes) {
|
|
109
|
+
if (n.x < minX)
|
|
110
|
+
minX = n.x;
|
|
111
|
+
if (n.y < minY)
|
|
112
|
+
minY = n.y;
|
|
113
|
+
if (n.x > maxX)
|
|
114
|
+
maxX = n.x;
|
|
115
|
+
if (n.y > maxY)
|
|
116
|
+
maxY = n.y;
|
|
117
|
+
}
|
|
118
|
+
ctx.beginPath();
|
|
119
|
+
const rx = (maxX - minX) / 2 + padding;
|
|
120
|
+
const ry = (maxY - minY) / 2 + padding;
|
|
121
|
+
const cx = (minX + maxX) / 2;
|
|
122
|
+
const cy = (minY + maxY) / 2;
|
|
123
|
+
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
124
|
+
ctx.fillStyle = color;
|
|
125
|
+
ctx.globalAlpha = 0.05;
|
|
126
|
+
ctx.fill();
|
|
127
|
+
ctx.strokeStyle = color;
|
|
128
|
+
ctx.globalAlpha = 0.12;
|
|
129
|
+
ctx.lineWidth = 1;
|
|
130
|
+
ctx.setLineDash([4, 4]);
|
|
131
|
+
ctx.stroke();
|
|
132
|
+
ctx.setLineDash([]);
|
|
133
|
+
ctx.globalAlpha = 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
82
136
|
// Draw edges
|
|
83
137
|
for (const edge of state.edges) {
|
|
84
138
|
const source = state.nodeMap.get(edge.sourceId);
|
|
@@ -114,17 +168,19 @@ export function initCanvas(container, onNodeClick) {
|
|
|
114
168
|
// Arrowhead
|
|
115
169
|
drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
|
|
116
170
|
// Edge label at midpoint
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
if (showEdgeLabels) {
|
|
172
|
+
const mx = (source.x + target.x) / 2;
|
|
173
|
+
const my = (source.y + target.y) / 2;
|
|
174
|
+
ctx.fillStyle = highlighted
|
|
175
|
+
? edgeLabelHighlight
|
|
176
|
+
: edgeDimmed
|
|
177
|
+
? edgeLabelDim
|
|
178
|
+
: edgeLabel;
|
|
179
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
180
|
+
ctx.textAlign = "center";
|
|
181
|
+
ctx.textBaseline = "bottom";
|
|
182
|
+
ctx.fillText(edge.type, mx, my - 4);
|
|
183
|
+
}
|
|
128
184
|
}
|
|
129
185
|
// Draw nodes
|
|
130
186
|
for (const node of state.nodes) {
|
|
@@ -173,6 +229,87 @@ export function initCanvas(container, onNodeClick) {
|
|
|
173
229
|
}
|
|
174
230
|
ctx.restore();
|
|
175
231
|
ctx.restore();
|
|
232
|
+
// Minimap
|
|
233
|
+
if (showMinimap && state.nodes.length > 1) {
|
|
234
|
+
drawMinimap();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function drawMinimap() {
|
|
238
|
+
if (!state)
|
|
239
|
+
return;
|
|
240
|
+
const mapW = 140;
|
|
241
|
+
const mapH = 100;
|
|
242
|
+
const mapPad = 8;
|
|
243
|
+
const mapX = canvas.clientWidth - mapW - 16;
|
|
244
|
+
const mapY = canvas.clientHeight - mapH - 16;
|
|
245
|
+
// Compute graph bounds
|
|
246
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
247
|
+
for (const n of state.nodes) {
|
|
248
|
+
if (n.x < minX)
|
|
249
|
+
minX = n.x;
|
|
250
|
+
if (n.y < minY)
|
|
251
|
+
minY = n.y;
|
|
252
|
+
if (n.x > maxX)
|
|
253
|
+
maxX = n.x;
|
|
254
|
+
if (n.y > maxY)
|
|
255
|
+
maxY = n.y;
|
|
256
|
+
}
|
|
257
|
+
const gw = maxX - minX || 1;
|
|
258
|
+
const gh = maxY - minY || 1;
|
|
259
|
+
const scale = Math.min((mapW - mapPad * 2) / gw, (mapH - mapPad * 2) / gh);
|
|
260
|
+
const offsetX = mapX + mapPad + ((mapW - mapPad * 2) - gw * scale) / 2;
|
|
261
|
+
const offsetY = mapY + mapPad + ((mapH - mapPad * 2) - gh * scale) / 2;
|
|
262
|
+
ctx.save();
|
|
263
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
264
|
+
// Background
|
|
265
|
+
ctx.fillStyle = cssVar("--bg-surface") || "#1a1a1a";
|
|
266
|
+
ctx.globalAlpha = 0.85;
|
|
267
|
+
ctx.beginPath();
|
|
268
|
+
ctx.roundRect(mapX, mapY, mapW, mapH, 8);
|
|
269
|
+
ctx.fill();
|
|
270
|
+
ctx.strokeStyle = cssVar("--border") || "#2a2a2a";
|
|
271
|
+
ctx.globalAlpha = 1;
|
|
272
|
+
ctx.lineWidth = 1;
|
|
273
|
+
ctx.stroke();
|
|
274
|
+
// Edges
|
|
275
|
+
ctx.globalAlpha = 0.15;
|
|
276
|
+
ctx.strokeStyle = cssVar("--canvas-edge") || "#555";
|
|
277
|
+
ctx.lineWidth = 0.5;
|
|
278
|
+
for (const edge of state.edges) {
|
|
279
|
+
const src = state.nodeMap.get(edge.sourceId);
|
|
280
|
+
const tgt = state.nodeMap.get(edge.targetId);
|
|
281
|
+
if (!src || !tgt || edge.sourceId === edge.targetId)
|
|
282
|
+
continue;
|
|
283
|
+
ctx.beginPath();
|
|
284
|
+
ctx.moveTo(offsetX + (src.x - minX) * scale, offsetY + (src.y - minY) * scale);
|
|
285
|
+
ctx.lineTo(offsetX + (tgt.x - minX) * scale, offsetY + (tgt.y - minY) * scale);
|
|
286
|
+
ctx.stroke();
|
|
287
|
+
}
|
|
288
|
+
// Nodes
|
|
289
|
+
ctx.globalAlpha = 0.8;
|
|
290
|
+
for (const node of state.nodes) {
|
|
291
|
+
const nx = offsetX + (node.x - minX) * scale;
|
|
292
|
+
const ny = offsetY + (node.y - minY) * scale;
|
|
293
|
+
ctx.beginPath();
|
|
294
|
+
ctx.arc(nx, ny, 2, 0, Math.PI * 2);
|
|
295
|
+
ctx.fillStyle = getColor(node.type);
|
|
296
|
+
ctx.fill();
|
|
297
|
+
}
|
|
298
|
+
// Viewport rectangle
|
|
299
|
+
const vx1 = camera.x;
|
|
300
|
+
const vy1 = camera.y;
|
|
301
|
+
const vx2 = camera.x + canvas.clientWidth / camera.scale;
|
|
302
|
+
const vy2 = camera.y + canvas.clientHeight / camera.scale;
|
|
303
|
+
const rx = offsetX + (vx1 - minX) * scale;
|
|
304
|
+
const ry = offsetY + (vy1 - minY) * scale;
|
|
305
|
+
const rw = (vx2 - vx1) * scale;
|
|
306
|
+
const rh = (vy2 - vy1) * scale;
|
|
307
|
+
ctx.globalAlpha = 0.3;
|
|
308
|
+
ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
|
|
309
|
+
ctx.lineWidth = 1.5;
|
|
310
|
+
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));
|
|
311
|
+
ctx.globalAlpha = 1;
|
|
312
|
+
ctx.restore();
|
|
176
313
|
}
|
|
177
314
|
function drawArrowhead(sx, sy, tx, ty, highlighted, arrowColor, arrowHighlight) {
|
|
178
315
|
const angle = Math.atan2(ty - sy, tx - sx);
|
|
@@ -195,10 +332,12 @@ export function initCanvas(container, onNodeClick) {
|
|
|
195
332
|
ctx.strokeStyle = highlighted ? edgeHighlight : edgeColor;
|
|
196
333
|
ctx.lineWidth = highlighted ? 2.5 : 1.5;
|
|
197
334
|
ctx.stroke();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
335
|
+
if (showEdgeLabels) {
|
|
336
|
+
ctx.fillStyle = highlighted ? labelHighlight : labelColor;
|
|
337
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
338
|
+
ctx.textAlign = "center";
|
|
339
|
+
ctx.fillText(type, cx, cy - 18);
|
|
340
|
+
}
|
|
202
341
|
}
|
|
203
342
|
// --- Simulation loop ---
|
|
204
343
|
function animatePan() {
|
|
@@ -453,6 +592,11 @@ export function initCanvas(container, onNodeClick) {
|
|
|
453
592
|
return {
|
|
454
593
|
loadGraph(data) {
|
|
455
594
|
cancelAnimationFrame(animFrame);
|
|
595
|
+
lastLoadedData = data;
|
|
596
|
+
// Exit any active focus when full graph reloads
|
|
597
|
+
focusSeedIds = null;
|
|
598
|
+
savedFullState = null;
|
|
599
|
+
savedFullCamera = null;
|
|
456
600
|
state = createLayout(data);
|
|
457
601
|
alpha = 1;
|
|
458
602
|
selectedNodeIds = new Set();
|
|
@@ -485,25 +629,180 @@ export function initCanvas(container, onNodeClick) {
|
|
|
485
629
|
render();
|
|
486
630
|
},
|
|
487
631
|
panToNode(nodeId) {
|
|
488
|
-
|
|
632
|
+
this.panToNodes([nodeId]);
|
|
633
|
+
},
|
|
634
|
+
panToNodes(nodeIds) {
|
|
635
|
+
if (!state || nodeIds.length === 0)
|
|
489
636
|
return;
|
|
490
|
-
const
|
|
491
|
-
if (
|
|
637
|
+
const nodes = nodeIds.map((id) => state.nodeMap.get(id)).filter(Boolean);
|
|
638
|
+
if (nodes.length === 0)
|
|
492
639
|
return;
|
|
493
|
-
selectedNodeIds = new Set(
|
|
494
|
-
onNodeClick?.(
|
|
640
|
+
selectedNodeIds = new Set(nodeIds);
|
|
641
|
+
onNodeClick?.(nodeIds);
|
|
495
642
|
const w = canvas.clientWidth;
|
|
496
643
|
const h = canvas.clientHeight;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
644
|
+
if (nodes.length === 1) {
|
|
645
|
+
panStart = { x: camera.x, y: camera.y, time: performance.now() };
|
|
646
|
+
panTarget = {
|
|
647
|
+
x: nodes[0].x - w / (2 * camera.scale),
|
|
648
|
+
y: nodes[0].y - h / (2 * camera.scale),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
// Fit all nodes in view with padding
|
|
653
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
654
|
+
for (const n of nodes) {
|
|
655
|
+
if (n.x < minX)
|
|
656
|
+
minX = n.x;
|
|
657
|
+
if (n.y < minY)
|
|
658
|
+
minY = n.y;
|
|
659
|
+
if (n.x > maxX)
|
|
660
|
+
maxX = n.x;
|
|
661
|
+
if (n.y > maxY)
|
|
662
|
+
maxY = n.y;
|
|
663
|
+
}
|
|
664
|
+
const pad = NODE_RADIUS * 4;
|
|
665
|
+
const bw = maxX - minX + pad * 2;
|
|
666
|
+
const bh = maxY - minY + pad * 2;
|
|
667
|
+
const fitScale = Math.min(w / bw, h / bh, camera.scale);
|
|
668
|
+
camera.scale = fitScale;
|
|
669
|
+
const cx = (minX + maxX) / 2;
|
|
670
|
+
const cy = (minY + maxY) / 2;
|
|
671
|
+
panStart = { x: camera.x, y: camera.y, time: performance.now() };
|
|
672
|
+
panTarget = {
|
|
673
|
+
x: cx - w / (2 * camera.scale),
|
|
674
|
+
y: cy - h / (2 * camera.scale),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
502
677
|
animatePan();
|
|
503
678
|
},
|
|
679
|
+
setEdgeLabels(visible) {
|
|
680
|
+
showEdgeLabels = visible;
|
|
681
|
+
render();
|
|
682
|
+
},
|
|
683
|
+
setTypeHulls(visible) {
|
|
684
|
+
showTypeHulls = visible;
|
|
685
|
+
render();
|
|
686
|
+
},
|
|
687
|
+
setMinimap(visible) {
|
|
688
|
+
showMinimap = visible;
|
|
689
|
+
render();
|
|
690
|
+
},
|
|
691
|
+
reheat() {
|
|
692
|
+
alpha = 0.5;
|
|
693
|
+
cancelAnimationFrame(animFrame);
|
|
694
|
+
simulate();
|
|
695
|
+
},
|
|
696
|
+
exportImage(format) {
|
|
697
|
+
if (!state)
|
|
698
|
+
return "";
|
|
699
|
+
// Use the actual canvas pixel dimensions (already scaled by dpr)
|
|
700
|
+
const pw = canvas.width;
|
|
701
|
+
const ph = canvas.height;
|
|
702
|
+
if (format === "png") {
|
|
703
|
+
const exportCanvas = document.createElement("canvas");
|
|
704
|
+
exportCanvas.width = pw;
|
|
705
|
+
exportCanvas.height = ph;
|
|
706
|
+
const ectx = exportCanvas.getContext("2d");
|
|
707
|
+
// Draw background
|
|
708
|
+
ectx.fillStyle = cssVar("--bg") || "#141414";
|
|
709
|
+
ectx.fillRect(0, 0, pw, ph);
|
|
710
|
+
// Copy current canvas pixels 1:1
|
|
711
|
+
ectx.drawImage(canvas, 0, 0);
|
|
712
|
+
// Watermark (scale font to match pixel density)
|
|
713
|
+
drawWatermark(ectx, pw, ph);
|
|
714
|
+
return exportCanvas.toDataURL("image/png");
|
|
715
|
+
}
|
|
716
|
+
// SVG: embed the canvas as a PNG image with text overlay
|
|
717
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
718
|
+
const fontSize = Math.max(16, Math.round(pw / 80));
|
|
719
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}">
|
|
720
|
+
<image href="${dataUrl}" width="${pw}" height="${ph}"/>
|
|
721
|
+
<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>
|
|
722
|
+
</svg>`;
|
|
723
|
+
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
|
|
724
|
+
},
|
|
725
|
+
enterFocus(seedNodeIds, hops) {
|
|
726
|
+
if (!lastLoadedData || !state)
|
|
727
|
+
return;
|
|
728
|
+
// Save current full-graph state
|
|
729
|
+
if (!focusSeedIds) {
|
|
730
|
+
savedFullState = state;
|
|
731
|
+
savedFullCamera = { ...camera };
|
|
732
|
+
}
|
|
733
|
+
focusSeedIds = seedNodeIds;
|
|
734
|
+
focusHops = hops;
|
|
735
|
+
const subgraph = extractSubgraph(lastLoadedData, seedNodeIds, hops);
|
|
736
|
+
cancelAnimationFrame(animFrame);
|
|
737
|
+
state = createLayout(subgraph);
|
|
738
|
+
alpha = 1;
|
|
739
|
+
selectedNodeIds = new Set(seedNodeIds);
|
|
740
|
+
filteredNodeIds = null;
|
|
741
|
+
// Center camera
|
|
742
|
+
camera = { x: 0, y: 0, scale: 1 };
|
|
743
|
+
if (state.nodes.length > 0) {
|
|
744
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
745
|
+
for (const n of state.nodes) {
|
|
746
|
+
if (n.x < minX)
|
|
747
|
+
minX = n.x;
|
|
748
|
+
if (n.y < minY)
|
|
749
|
+
minY = n.y;
|
|
750
|
+
if (n.x > maxX)
|
|
751
|
+
maxX = n.x;
|
|
752
|
+
if (n.y > maxY)
|
|
753
|
+
maxY = n.y;
|
|
754
|
+
}
|
|
755
|
+
const cx = (minX + maxX) / 2;
|
|
756
|
+
const cy = (minY + maxY) / 2;
|
|
757
|
+
camera.x = cx - canvas.clientWidth / 2;
|
|
758
|
+
camera.y = cy - canvas.clientHeight / 2;
|
|
759
|
+
}
|
|
760
|
+
simulate();
|
|
761
|
+
onFocusChange?.({
|
|
762
|
+
seedNodeIds,
|
|
763
|
+
hops,
|
|
764
|
+
totalNodes: subgraph.nodes.length,
|
|
765
|
+
});
|
|
766
|
+
},
|
|
767
|
+
exitFocus() {
|
|
768
|
+
if (!focusSeedIds || !savedFullState)
|
|
769
|
+
return;
|
|
770
|
+
cancelAnimationFrame(animFrame);
|
|
771
|
+
state = savedFullState;
|
|
772
|
+
camera = savedFullCamera ?? { x: 0, y: 0, scale: 1 };
|
|
773
|
+
focusSeedIds = null;
|
|
774
|
+
savedFullState = null;
|
|
775
|
+
savedFullCamera = null;
|
|
776
|
+
selectedNodeIds = new Set();
|
|
777
|
+
filteredNodeIds = null;
|
|
778
|
+
render();
|
|
779
|
+
onFocusChange?.(null);
|
|
780
|
+
},
|
|
781
|
+
isFocused() {
|
|
782
|
+
return focusSeedIds !== null;
|
|
783
|
+
},
|
|
784
|
+
getFocusInfo() {
|
|
785
|
+
if (!focusSeedIds || !state)
|
|
786
|
+
return null;
|
|
787
|
+
return {
|
|
788
|
+
seedNodeIds: focusSeedIds,
|
|
789
|
+
hops: focusHops,
|
|
790
|
+
totalNodes: state.nodes.length,
|
|
791
|
+
};
|
|
792
|
+
},
|
|
504
793
|
destroy() {
|
|
505
794
|
cancelAnimationFrame(animFrame);
|
|
506
795
|
observer.disconnect();
|
|
507
796
|
},
|
|
508
797
|
};
|
|
798
|
+
function drawWatermark(ectx, w, h) {
|
|
799
|
+
const fontSize = Math.max(16, Math.round(w / 80));
|
|
800
|
+
ectx.save();
|
|
801
|
+
ectx.font = `${fontSize}px system-ui, sans-serif`;
|
|
802
|
+
ectx.fillStyle = "rgba(255, 255, 255, 0.4)";
|
|
803
|
+
ectx.textAlign = "right";
|
|
804
|
+
ectx.textBaseline = "bottom";
|
|
805
|
+
ectx.fillText("backpackontology.com", w - 20, h - 16);
|
|
806
|
+
ectx.restore();
|
|
807
|
+
}
|
|
509
808
|
}
|
|
@@ -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
|
@@ -6,7 +6,7 @@ export interface EditCallbacks {
|
|
|
6
6
|
onDeleteEdge(edgeId: string): void;
|
|
7
7
|
onAddProperty(nodeId: string, key: string, value: string): void;
|
|
8
8
|
}
|
|
9
|
-
export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void): {
|
|
9
|
+
export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void, onFocus?: (nodeIds: string[]) => void): {
|
|
10
10
|
show(nodeIds: string[], data: LearningGraphData): void;
|
|
11
11
|
hide: () => void;
|
|
12
12
|
readonly visible: boolean;
|
package/dist/info-panel.js
CHANGED
|
@@ -8,7 +8,7 @@ function nodeLabel(node) {
|
|
|
8
8
|
return node.id;
|
|
9
9
|
}
|
|
10
10
|
const EDIT_ICON = '\u270E'; // pencil
|
|
11
|
-
export function initInfoPanel(container, callbacks, onNavigateToNode) {
|
|
11
|
+
export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
12
12
|
const panel = document.createElement("div");
|
|
13
13
|
panel.id = "info-panel";
|
|
14
14
|
panel.className = "info-panel hidden";
|
|
@@ -19,6 +19,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
|
|
|
19
19
|
let historyIndex = -1;
|
|
20
20
|
let navigatingHistory = false;
|
|
21
21
|
let lastData = null;
|
|
22
|
+
let currentNodeIds = [];
|
|
22
23
|
function hide() {
|
|
23
24
|
panel.classList.add("hidden");
|
|
24
25
|
panel.classList.remove("info-panel-maximized");
|
|
@@ -76,6 +77,17 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
|
|
|
76
77
|
fwdBtn.disabled = historyIndex >= history.length - 1;
|
|
77
78
|
fwdBtn.addEventListener("click", goForward);
|
|
78
79
|
toolbar.appendChild(fwdBtn);
|
|
80
|
+
// Focus
|
|
81
|
+
if (onFocus && currentNodeIds.length > 0) {
|
|
82
|
+
const focusBtn = document.createElement("button");
|
|
83
|
+
focusBtn.className = "info-toolbar-btn info-focus-btn";
|
|
84
|
+
focusBtn.textContent = "\u25CE"; // bullseye
|
|
85
|
+
focusBtn.title = "Focus on neighborhood (F)";
|
|
86
|
+
focusBtn.addEventListener("click", () => {
|
|
87
|
+
onFocus(currentNodeIds);
|
|
88
|
+
});
|
|
89
|
+
toolbar.appendChild(focusBtn);
|
|
90
|
+
}
|
|
79
91
|
// Maximize/restore
|
|
80
92
|
const maxBtn = document.createElement("button");
|
|
81
93
|
maxBtn.className = "info-toolbar-btn";
|
|
@@ -174,22 +186,26 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
|
|
|
174
186
|
const dd = document.createElement("dd");
|
|
175
187
|
if (callbacks) {
|
|
176
188
|
const valueStr = formatValue(node.properties[key]);
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
const textarea = document.createElement("textarea");
|
|
190
|
+
textarea.className = "info-edit-input";
|
|
191
|
+
textarea.value = valueStr;
|
|
192
|
+
textarea.rows = 1;
|
|
193
|
+
textarea.addEventListener("input", () => autoResize(textarea));
|
|
194
|
+
textarea.addEventListener("keydown", (e) => {
|
|
195
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
textarea.blur();
|
|
184
198
|
}
|
|
185
199
|
});
|
|
186
|
-
|
|
187
|
-
const newVal =
|
|
200
|
+
textarea.addEventListener("blur", () => {
|
|
201
|
+
const newVal = textarea.value;
|
|
188
202
|
if (newVal !== valueStr) {
|
|
189
203
|
callbacks.onUpdateNode(nodeId, { [key]: tryParseValue(newVal) });
|
|
190
204
|
}
|
|
191
205
|
});
|
|
192
|
-
dd.appendChild(
|
|
206
|
+
dd.appendChild(textarea);
|
|
207
|
+
// Auto-size after append
|
|
208
|
+
requestAnimationFrame(() => autoResize(textarea));
|
|
193
209
|
// Delete property button
|
|
194
210
|
const delProp = document.createElement("button");
|
|
195
211
|
delProp.className = "info-delete-prop";
|
|
@@ -484,6 +500,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode) {
|
|
|
484
500
|
return {
|
|
485
501
|
show(nodeIds, data) {
|
|
486
502
|
lastData = data;
|
|
503
|
+
currentNodeIds = nodeIds;
|
|
487
504
|
// Track history for single-node views
|
|
488
505
|
if (nodeIds.length === 1 && !navigatingHistory) {
|
|
489
506
|
const nodeId = nodeIds[0];
|
|
@@ -568,6 +585,10 @@ function tryParseValue(str) {
|
|
|
568
585
|
}
|
|
569
586
|
return str;
|
|
570
587
|
}
|
|
588
|
+
function autoResize(textarea) {
|
|
589
|
+
textarea.style.height = "auto";
|
|
590
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
591
|
+
}
|
|
571
592
|
function formatTimestamp(iso) {
|
|
572
593
|
try {
|
|
573
594
|
const d = new Date(iso);
|
package/dist/layout.d.ts
CHANGED
|
@@ -18,7 +18,16 @@ export interface LayoutState {
|
|
|
18
18
|
edges: LayoutEdge[];
|
|
19
19
|
nodeMap: Map<string, LayoutNode>;
|
|
20
20
|
}
|
|
21
|
-
|
|
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
|
+
/** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
|
|
29
|
+
export declare function extractSubgraph(data: LearningGraphData, seedIds: string[], hops: number): LearningGraphData;
|
|
30
|
+
/** Create a layout state from ontology data. Nodes start grouped by type. */
|
|
22
31
|
export declare function createLayout(data: LearningGraphData): LayoutState;
|
|
23
32
|
/** Run one tick of the force simulation. Returns new alpha. */
|
|
24
33
|
export declare function tick(state: LayoutState, alpha: number): number;
|