backpack-viewer 0.2.16 → 0.2.17
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 +73 -1
- package/bin/serve.js +8 -0
- package/dist/app/assets/index-CKtt38XS.css +1 -0
- package/dist/app/assets/index-D-5q69aO.js +21 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +6 -0
- package/dist/canvas.js +138 -59
- package/dist/config.d.ts +4 -0
- package/dist/config.js +31 -0
- package/dist/default-config.json +33 -0
- package/dist/info-panel.d.ts +4 -0
- package/dist/info-panel.js +52 -5
- package/dist/keybindings.d.ts +6 -0
- package/dist/keybindings.js +58 -0
- package/dist/layout.d.ts +2 -0
- package/dist/layout.js +18 -8
- package/dist/main.js +99 -39
- package/dist/search.js +41 -82
- package/dist/shortcuts.d.ts +3 -1
- package/dist/shortcuts.js +50 -19
- package/dist/style.css +74 -1
- package/dist/tools-pane.d.ts +1 -0
- package/dist/tools-pane.js +334 -149
- package/package.json +1 -1
- package/dist/app/assets/index-Mi0vDG5K.js +0 -21
- package/dist/app/assets/index-z15vEFEy.css +0 -1
package/dist/canvas.js
CHANGED
|
@@ -6,6 +6,18 @@ function cssVar(name) {
|
|
|
6
6
|
}
|
|
7
7
|
const NODE_RADIUS = 20;
|
|
8
8
|
const ALPHA_MIN = 0.001;
|
|
9
|
+
// Level-of-detail thresholds based on camera scale
|
|
10
|
+
const LOD_HIDE_BADGES = 0.4; // hide type badges above nodes
|
|
11
|
+
const LOD_HIDE_LABELS = 0.25; // hide node labels below nodes
|
|
12
|
+
const LOD_HIDE_EDGE_LABELS = 0.35; // hide edge labels even if enabled
|
|
13
|
+
const LOD_SMALL_NODES = 0.2; // shrink nodes to half size
|
|
14
|
+
const LOD_HIDE_ARROWS = 0.15; // hide arrowheads, draw 1px edges
|
|
15
|
+
/** Check if a point is within the visible viewport (with padding). */
|
|
16
|
+
function isInViewport(x, y, camera, canvasW, canvasH, pad = 100) {
|
|
17
|
+
const sx = (x - camera.x) * camera.scale;
|
|
18
|
+
const sy = (y - camera.y) * camera.scale;
|
|
19
|
+
return sx >= -pad && sx <= canvasW + pad && sy >= -pad && sy <= canvasH + pad;
|
|
20
|
+
}
|
|
9
21
|
export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
10
22
|
const canvas = container.querySelector("canvas");
|
|
11
23
|
const ctx = canvas.getContext("2d");
|
|
@@ -16,6 +28,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
16
28
|
let animFrame = 0;
|
|
17
29
|
let selectedNodeIds = new Set();
|
|
18
30
|
let filteredNodeIds = null; // null = no filter (show all)
|
|
31
|
+
let showEdges = true;
|
|
19
32
|
let showEdgeLabels = true;
|
|
20
33
|
let showTypeHulls = true;
|
|
21
34
|
let showMinimap = true;
|
|
@@ -89,7 +102,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
89
102
|
ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
|
|
90
103
|
ctx.scale(camera.scale, camera.scale);
|
|
91
104
|
// Draw type hulls (shaded regions behind same-type nodes)
|
|
92
|
-
if (showTypeHulls) {
|
|
105
|
+
if (showTypeHulls && camera.scale >= LOD_SMALL_NODES) {
|
|
93
106
|
const typeGroups = new Map();
|
|
94
107
|
for (const node of state.nodes) {
|
|
95
108
|
if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
|
|
@@ -134,56 +147,66 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
134
147
|
}
|
|
135
148
|
}
|
|
136
149
|
// Draw edges
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Arrowhead
|
|
169
|
-
drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
|
|
170
|
-
// Edge label at midpoint
|
|
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
|
|
150
|
+
if (showEdges)
|
|
151
|
+
for (const edge of state.edges) {
|
|
152
|
+
const source = state.nodeMap.get(edge.sourceId);
|
|
153
|
+
const target = state.nodeMap.get(edge.targetId);
|
|
154
|
+
if (!source || !target)
|
|
155
|
+
continue;
|
|
156
|
+
// Viewport culling — skip if both endpoints are off-screen
|
|
157
|
+
if (!isInViewport(source.x, source.y, camera, canvas.clientWidth, canvas.clientHeight, 200) &&
|
|
158
|
+
!isInViewport(target.x, target.y, camera, canvas.clientWidth, canvas.clientHeight, 200))
|
|
159
|
+
continue;
|
|
160
|
+
const sourceMatch = filteredNodeIds === null || filteredNodeIds.has(edge.sourceId);
|
|
161
|
+
const targetMatch = filteredNodeIds === null || filteredNodeIds.has(edge.targetId);
|
|
162
|
+
const bothMatch = sourceMatch && targetMatch;
|
|
163
|
+
// Hide edges where neither endpoint matches the filter
|
|
164
|
+
if (filteredNodeIds !== null && !sourceMatch && !targetMatch)
|
|
165
|
+
continue;
|
|
166
|
+
const isConnected = selectedNodeIds.size > 0 &&
|
|
167
|
+
(selectedNodeIds.has(edge.sourceId) || selectedNodeIds.has(edge.targetId));
|
|
168
|
+
const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
|
|
169
|
+
const edgeDimmed = filteredNodeIds !== null && !bothMatch;
|
|
170
|
+
// Self-loop
|
|
171
|
+
if (edge.sourceId === edge.targetId) {
|
|
172
|
+
drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// Line
|
|
176
|
+
ctx.beginPath();
|
|
177
|
+
ctx.moveTo(source.x, source.y);
|
|
178
|
+
ctx.lineTo(target.x, target.y);
|
|
179
|
+
ctx.strokeStyle = highlighted
|
|
180
|
+
? edgeHighlight
|
|
176
181
|
: edgeDimmed
|
|
177
|
-
?
|
|
178
|
-
:
|
|
179
|
-
ctx.
|
|
180
|
-
ctx.
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
? edgeDimColor
|
|
183
|
+
: edgeColor;
|
|
184
|
+
ctx.lineWidth = camera.scale < LOD_HIDE_ARROWS ? 1 : highlighted ? 2.5 : 1.5;
|
|
185
|
+
ctx.stroke();
|
|
186
|
+
// Arrowhead
|
|
187
|
+
if (camera.scale >= LOD_HIDE_ARROWS) {
|
|
188
|
+
drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
|
|
189
|
+
}
|
|
190
|
+
// Edge label at midpoint
|
|
191
|
+
if (showEdgeLabels && camera.scale >= LOD_HIDE_EDGE_LABELS) {
|
|
192
|
+
const mx = (source.x + target.x) / 2;
|
|
193
|
+
const my = (source.y + target.y) / 2;
|
|
194
|
+
ctx.fillStyle = highlighted
|
|
195
|
+
? edgeLabelHighlight
|
|
196
|
+
: edgeDimmed
|
|
197
|
+
? edgeLabelDim
|
|
198
|
+
: edgeLabel;
|
|
199
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
200
|
+
ctx.textAlign = "center";
|
|
201
|
+
ctx.textBaseline = "bottom";
|
|
202
|
+
ctx.fillText(edge.type, mx, my - 4);
|
|
203
|
+
}
|
|
183
204
|
}
|
|
184
|
-
}
|
|
185
205
|
// Draw nodes
|
|
186
206
|
for (const node of state.nodes) {
|
|
207
|
+
// Viewport culling
|
|
208
|
+
if (!isInViewport(node.x, node.y, camera, canvas.clientWidth, canvas.clientHeight))
|
|
209
|
+
continue;
|
|
187
210
|
const color = getColor(node.type);
|
|
188
211
|
const isSelected = selectedNodeIds.has(node.id);
|
|
189
212
|
const isNeighbor = selectedNodeIds.size > 0 &&
|
|
@@ -192,13 +215,14 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
192
215
|
const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
|
|
193
216
|
const dimmed = filteredOut ||
|
|
194
217
|
(selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
|
|
218
|
+
const r = camera.scale < LOD_SMALL_NODES ? NODE_RADIUS * 0.5 : NODE_RADIUS;
|
|
195
219
|
// Glow for selected node
|
|
196
220
|
if (isSelected) {
|
|
197
221
|
ctx.save();
|
|
198
222
|
ctx.shadowColor = color;
|
|
199
223
|
ctx.shadowBlur = 20;
|
|
200
224
|
ctx.beginPath();
|
|
201
|
-
ctx.arc(node.x, node.y,
|
|
225
|
+
ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
|
|
202
226
|
ctx.fillStyle = color;
|
|
203
227
|
ctx.globalAlpha = 0.3;
|
|
204
228
|
ctx.fill();
|
|
@@ -206,7 +230,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
206
230
|
}
|
|
207
231
|
// Circle
|
|
208
232
|
ctx.beginPath();
|
|
209
|
-
ctx.arc(node.x, node.y,
|
|
233
|
+
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
210
234
|
ctx.fillStyle = color;
|
|
211
235
|
ctx.globalAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
|
|
212
236
|
ctx.fill();
|
|
@@ -214,17 +238,21 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
214
238
|
ctx.lineWidth = isSelected ? 3 : 1.5;
|
|
215
239
|
ctx.stroke();
|
|
216
240
|
// Label below
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
241
|
+
if (camera.scale >= LOD_HIDE_LABELS) {
|
|
242
|
+
const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
|
|
243
|
+
ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
|
|
244
|
+
ctx.font = "11px system-ui, sans-serif";
|
|
245
|
+
ctx.textAlign = "center";
|
|
246
|
+
ctx.textBaseline = "top";
|
|
247
|
+
ctx.fillText(label, node.x, node.y + r + 4);
|
|
248
|
+
}
|
|
223
249
|
// Type badge above
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
250
|
+
if (camera.scale >= LOD_HIDE_BADGES) {
|
|
251
|
+
ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
|
|
252
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
253
|
+
ctx.textBaseline = "bottom";
|
|
254
|
+
ctx.fillText(node.type, node.x, node.y - r - 3);
|
|
255
|
+
}
|
|
228
256
|
ctx.globalAlpha = 1;
|
|
229
257
|
}
|
|
230
258
|
ctx.restore();
|
|
@@ -676,6 +704,10 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
676
704
|
}
|
|
677
705
|
animatePan();
|
|
678
706
|
},
|
|
707
|
+
setEdges(visible) {
|
|
708
|
+
showEdges = visible;
|
|
709
|
+
render();
|
|
710
|
+
},
|
|
679
711
|
setEdgeLabels(visible) {
|
|
680
712
|
showEdgeLabels = visible;
|
|
681
713
|
render();
|
|
@@ -688,6 +720,41 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
688
720
|
showMinimap = visible;
|
|
689
721
|
render();
|
|
690
722
|
},
|
|
723
|
+
centerView() {
|
|
724
|
+
if (!state)
|
|
725
|
+
return;
|
|
726
|
+
camera = { x: 0, y: 0, scale: 1 };
|
|
727
|
+
if (state.nodes.length > 0) {
|
|
728
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
729
|
+
for (const n of state.nodes) {
|
|
730
|
+
if (n.x < minX)
|
|
731
|
+
minX = n.x;
|
|
732
|
+
if (n.y < minY)
|
|
733
|
+
minY = n.y;
|
|
734
|
+
if (n.x > maxX)
|
|
735
|
+
maxX = n.x;
|
|
736
|
+
if (n.y > maxY)
|
|
737
|
+
maxY = n.y;
|
|
738
|
+
}
|
|
739
|
+
camera.x = (minX + maxX) / 2 - canvas.clientWidth / 2;
|
|
740
|
+
camera.y = (minY + maxY) / 2 - canvas.clientHeight / 2;
|
|
741
|
+
}
|
|
742
|
+
render();
|
|
743
|
+
},
|
|
744
|
+
panBy(dx, dy) {
|
|
745
|
+
camera.x += dx / camera.scale;
|
|
746
|
+
camera.y += dy / camera.scale;
|
|
747
|
+
render();
|
|
748
|
+
},
|
|
749
|
+
zoomBy(factor) {
|
|
750
|
+
const cx = canvas.clientWidth / 2;
|
|
751
|
+
const cy = canvas.clientHeight / 2;
|
|
752
|
+
const [wx, wy] = screenToWorld(cx, cy);
|
|
753
|
+
camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
|
|
754
|
+
camera.x = wx - cx / camera.scale;
|
|
755
|
+
camera.y = wy - cy / camera.scale;
|
|
756
|
+
render();
|
|
757
|
+
},
|
|
691
758
|
reheat() {
|
|
692
759
|
alpha = 0.5;
|
|
693
760
|
cancelAnimationFrame(animFrame);
|
|
@@ -790,6 +857,18 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
|
|
|
790
857
|
totalNodes: state.nodes.length,
|
|
791
858
|
};
|
|
792
859
|
},
|
|
860
|
+
/** Get all node IDs in the current layout (subgraph if focused, full graph otherwise). Seed nodes first. */
|
|
861
|
+
getNodeIds() {
|
|
862
|
+
if (!state)
|
|
863
|
+
return [];
|
|
864
|
+
if (focusSeedIds) {
|
|
865
|
+
const seedSet = new Set(focusSeedIds);
|
|
866
|
+
const seeds = state.nodes.filter((n) => seedSet.has(n.id)).map((n) => n.id);
|
|
867
|
+
const rest = state.nodes.filter((n) => !seedSet.has(n.id)).map((n) => n.id);
|
|
868
|
+
return [...seeds, ...rest];
|
|
869
|
+
}
|
|
870
|
+
return state.nodes.map((n) => n.id);
|
|
871
|
+
},
|
|
793
872
|
destroy() {
|
|
794
873
|
cancelAnimationFrame(animFrame);
|
|
795
874
|
observer.disconnect();
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import defaultConfig from "./default-config.json" with { type: "json" };
|
|
5
|
+
function viewerConfigDir() {
|
|
6
|
+
if (process.env.BACKPACK_DIR) {
|
|
7
|
+
return path.join(process.env.BACKPACK_DIR, "config");
|
|
8
|
+
}
|
|
9
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
10
|
+
return path.join(xdgConfig, "backpack");
|
|
11
|
+
}
|
|
12
|
+
function viewerConfigFile() {
|
|
13
|
+
return path.join(viewerConfigDir(), "viewer.json");
|
|
14
|
+
}
|
|
15
|
+
export function loadViewerConfig() {
|
|
16
|
+
const filePath = viewerConfigFile();
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
19
|
+
const userConfig = JSON.parse(raw);
|
|
20
|
+
return {
|
|
21
|
+
...defaultConfig,
|
|
22
|
+
keybindings: {
|
|
23
|
+
...defaultConfig.keybindings,
|
|
24
|
+
...(userConfig.keybindings ?? {}),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return defaultConfig;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keybindings": {
|
|
3
|
+
"search": "/",
|
|
4
|
+
"searchAlt": "ctrl+k",
|
|
5
|
+
"undo": "ctrl+z",
|
|
6
|
+
"redo": "ctrl+shift+z",
|
|
7
|
+
"help": "?",
|
|
8
|
+
"escape": "Escape",
|
|
9
|
+
"focus": "f",
|
|
10
|
+
"toggleEdges": "e",
|
|
11
|
+
"center": "c",
|
|
12
|
+
"nextNode": ".",
|
|
13
|
+
"prevNode": ",",
|
|
14
|
+
"nextConnection": ">",
|
|
15
|
+
"prevConnection": "<",
|
|
16
|
+
"historyBack": "(",
|
|
17
|
+
"historyForward": ")",
|
|
18
|
+
"hopsIncrease": "=",
|
|
19
|
+
"hopsDecrease": "-",
|
|
20
|
+
"panLeft": "h",
|
|
21
|
+
"panDown": "j",
|
|
22
|
+
"panUp": "k",
|
|
23
|
+
"panRight": "l",
|
|
24
|
+
"panFastLeft": "H",
|
|
25
|
+
"zoomOut": "J",
|
|
26
|
+
"zoomIn": "K",
|
|
27
|
+
"panFastRight": "L",
|
|
28
|
+
"spacingDecrease": "[",
|
|
29
|
+
"spacingIncrease": "]",
|
|
30
|
+
"clusteringDecrease": "{",
|
|
31
|
+
"clusteringIncrease": "}"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/info-panel.d.ts
CHANGED
|
@@ -9,5 +9,9 @@ export interface EditCallbacks {
|
|
|
9
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
|
+
goBack: () => void;
|
|
13
|
+
goForward: () => void;
|
|
14
|
+
cycleConnection(direction: 1 | -1): string | null;
|
|
15
|
+
setFocusDisabled(disabled: boolean): void;
|
|
12
16
|
readonly visible: boolean;
|
|
13
17
|
};
|
package/dist/info-panel.js
CHANGED
|
@@ -20,6 +20,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
20
20
|
let navigatingHistory = false;
|
|
21
21
|
let lastData = null;
|
|
22
22
|
let currentNodeIds = [];
|
|
23
|
+
let focusDisabled = false;
|
|
24
|
+
let connectionNodeIds = []; // other-end node IDs for each connection
|
|
25
|
+
let activeConnectionIndex = -1;
|
|
23
26
|
function hide() {
|
|
24
27
|
panel.classList.add("hidden");
|
|
25
28
|
panel.classList.remove("info-panel-maximized");
|
|
@@ -43,19 +46,23 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
43
46
|
navigatingHistory = false;
|
|
44
47
|
}
|
|
45
48
|
function goBack() {
|
|
46
|
-
if (historyIndex <= 0 || !lastData
|
|
49
|
+
if (historyIndex <= 0 || !lastData)
|
|
47
50
|
return;
|
|
48
51
|
historyIndex--;
|
|
49
52
|
navigatingHistory = true;
|
|
50
|
-
|
|
53
|
+
const nodeId = history[historyIndex];
|
|
54
|
+
onNavigateToNode?.(nodeId);
|
|
55
|
+
showSingle(nodeId, lastData);
|
|
51
56
|
navigatingHistory = false;
|
|
52
57
|
}
|
|
53
58
|
function goForward() {
|
|
54
|
-
if (historyIndex >= history.length - 1 || !lastData
|
|
59
|
+
if (historyIndex >= history.length - 1 || !lastData)
|
|
55
60
|
return;
|
|
56
61
|
historyIndex++;
|
|
57
62
|
navigatingHistory = true;
|
|
58
|
-
|
|
63
|
+
const nodeId = history[historyIndex];
|
|
64
|
+
onNavigateToNode?.(nodeId);
|
|
65
|
+
showSingle(nodeId, lastData);
|
|
59
66
|
navigatingHistory = false;
|
|
60
67
|
}
|
|
61
68
|
function createToolbar() {
|
|
@@ -83,8 +90,12 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
83
90
|
focusBtn.className = "info-toolbar-btn info-focus-btn";
|
|
84
91
|
focusBtn.textContent = "\u25CE"; // bullseye
|
|
85
92
|
focusBtn.title = "Focus on neighborhood (F)";
|
|
93
|
+
focusBtn.disabled = focusDisabled;
|
|
94
|
+
if (focusDisabled)
|
|
95
|
+
focusBtn.style.opacity = "0.3";
|
|
86
96
|
focusBtn.addEventListener("click", () => {
|
|
87
|
-
|
|
97
|
+
if (!focusDisabled)
|
|
98
|
+
onFocus(currentNodeIds);
|
|
88
99
|
});
|
|
89
100
|
toolbar.appendChild(focusBtn);
|
|
90
101
|
}
|
|
@@ -114,6 +125,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
114
125
|
if (!node)
|
|
115
126
|
return;
|
|
116
127
|
const connectedEdges = data.edges.filter((e) => e.sourceId === nodeId || e.targetId === nodeId);
|
|
128
|
+
// Store connection targets for keyboard cycling
|
|
129
|
+
connectionNodeIds = connectedEdges.map((e) => e.sourceId === nodeId ? e.targetId : e.sourceId);
|
|
130
|
+
activeConnectionIndex = -1;
|
|
117
131
|
panel.innerHTML = "";
|
|
118
132
|
panel.classList.remove("hidden");
|
|
119
133
|
if (maximized)
|
|
@@ -521,6 +535,39 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
521
535
|
}
|
|
522
536
|
},
|
|
523
537
|
hide,
|
|
538
|
+
goBack,
|
|
539
|
+
goForward,
|
|
540
|
+
cycleConnection(direction) {
|
|
541
|
+
if (connectionNodeIds.length === 0)
|
|
542
|
+
return null;
|
|
543
|
+
if (activeConnectionIndex === -1) {
|
|
544
|
+
activeConnectionIndex = direction === 1 ? 0 : connectionNodeIds.length - 1;
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
activeConnectionIndex += direction;
|
|
548
|
+
if (activeConnectionIndex >= connectionNodeIds.length)
|
|
549
|
+
activeConnectionIndex = 0;
|
|
550
|
+
if (activeConnectionIndex < 0)
|
|
551
|
+
activeConnectionIndex = connectionNodeIds.length - 1;
|
|
552
|
+
}
|
|
553
|
+
// Highlight active row in the panel
|
|
554
|
+
const items = panel.querySelectorAll(".info-connection");
|
|
555
|
+
items.forEach((el, i) => {
|
|
556
|
+
el.classList.toggle("info-connection-active", i === activeConnectionIndex);
|
|
557
|
+
});
|
|
558
|
+
if (activeConnectionIndex >= 0 && items[activeConnectionIndex]) {
|
|
559
|
+
items[activeConnectionIndex].scrollIntoView({ block: "nearest" });
|
|
560
|
+
}
|
|
561
|
+
return connectionNodeIds[activeConnectionIndex] ?? null;
|
|
562
|
+
},
|
|
563
|
+
setFocusDisabled(disabled) {
|
|
564
|
+
focusDisabled = disabled;
|
|
565
|
+
const btn = panel.querySelector(".info-focus-btn");
|
|
566
|
+
if (btn) {
|
|
567
|
+
btn.disabled = disabled;
|
|
568
|
+
btn.style.opacity = disabled ? "0.3" : "";
|
|
569
|
+
}
|
|
570
|
+
},
|
|
524
571
|
get visible() {
|
|
525
572
|
return !panel.classList.contains("hidden");
|
|
526
573
|
},
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type KeybindingAction = "search" | "searchAlt" | "undo" | "redo" | "help" | "escape" | "focus" | "toggleEdges" | "center" | "nextNode" | "prevNode" | "nextConnection" | "prevConnection" | "historyBack" | "historyForward" | "hopsIncrease" | "hopsDecrease" | "panLeft" | "panDown" | "panUp" | "panRight" | "panFastLeft" | "zoomOut" | "zoomIn" | "panFastRight" | "spacingDecrease" | "spacingIncrease" | "clusteringDecrease" | "clusteringIncrease";
|
|
2
|
+
export type KeybindingMap = Record<KeybindingAction, string>;
|
|
3
|
+
/** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
|
|
4
|
+
export declare function matchKey(e: KeyboardEvent, binding: string): boolean;
|
|
5
|
+
/** Build a reverse map: for each action, store its binding string. Used by the help modal. */
|
|
6
|
+
export declare function actionDescriptions(): Record<KeybindingAction, string>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
|
|
2
|
+
export function matchKey(e, binding) {
|
|
3
|
+
const parts = binding.toLowerCase().split("+");
|
|
4
|
+
const key = parts.pop();
|
|
5
|
+
const needCtrl = parts.includes("ctrl") || parts.includes("cmd") || parts.includes("meta");
|
|
6
|
+
const needShift = parts.includes("shift");
|
|
7
|
+
const needAlt = parts.includes("alt");
|
|
8
|
+
// Modifier checks
|
|
9
|
+
if (needCtrl !== (e.ctrlKey || e.metaKey))
|
|
10
|
+
return false;
|
|
11
|
+
if (needShift !== e.shiftKey)
|
|
12
|
+
return false;
|
|
13
|
+
if (needAlt !== e.altKey)
|
|
14
|
+
return false;
|
|
15
|
+
// For plain keys (no modifiers required), reject if ctrl/meta is held
|
|
16
|
+
if (!needCtrl && (e.ctrlKey || e.metaKey))
|
|
17
|
+
return false;
|
|
18
|
+
// Key match — case-sensitive for single chars, case-insensitive for named keys
|
|
19
|
+
if (key === "escape")
|
|
20
|
+
return e.key === "Escape";
|
|
21
|
+
if (key.length === 1)
|
|
22
|
+
return e.key === binding.split("+").pop(); // preserve original case
|
|
23
|
+
return e.key.toLowerCase() === key;
|
|
24
|
+
}
|
|
25
|
+
/** Build a reverse map: for each action, store its binding string. Used by the help modal. */
|
|
26
|
+
export function actionDescriptions() {
|
|
27
|
+
return {
|
|
28
|
+
search: "Focus search",
|
|
29
|
+
searchAlt: "Focus search (alt)",
|
|
30
|
+
undo: "Undo",
|
|
31
|
+
redo: "Redo",
|
|
32
|
+
help: "Toggle help",
|
|
33
|
+
escape: "Exit focus / close panel",
|
|
34
|
+
focus: "Focus on selected / exit focus",
|
|
35
|
+
toggleEdges: "Toggle edges on/off",
|
|
36
|
+
center: "Center view on graph",
|
|
37
|
+
nextNode: "Next node in view",
|
|
38
|
+
prevNode: "Previous node in view",
|
|
39
|
+
nextConnection: "Next connection",
|
|
40
|
+
prevConnection: "Previous connection",
|
|
41
|
+
historyBack: "Node history back",
|
|
42
|
+
historyForward: "Node history forward",
|
|
43
|
+
hopsIncrease: "Increase hops",
|
|
44
|
+
hopsDecrease: "Decrease hops",
|
|
45
|
+
panLeft: "Pan left",
|
|
46
|
+
panDown: "Pan down",
|
|
47
|
+
panUp: "Pan up",
|
|
48
|
+
panRight: "Pan right",
|
|
49
|
+
panFastLeft: "Pan fast left",
|
|
50
|
+
zoomOut: "Zoom out",
|
|
51
|
+
zoomIn: "Zoom in",
|
|
52
|
+
panFastRight: "Pan fast right",
|
|
53
|
+
spacingDecrease: "Decrease spacing",
|
|
54
|
+
spacingIncrease: "Increase spacing",
|
|
55
|
+
clusteringDecrease: "Decrease clustering",
|
|
56
|
+
clusteringIncrease: "Increase clustering",
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/layout.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface LayoutParams {
|
|
|
25
25
|
export declare const DEFAULT_LAYOUT_PARAMS: LayoutParams;
|
|
26
26
|
export declare function setLayoutParams(p: Partial<LayoutParams>): void;
|
|
27
27
|
export declare function getLayoutParams(): LayoutParams;
|
|
28
|
+
/** Compute sensible default layout params based on graph size. */
|
|
29
|
+
export declare function autoLayoutParams(nodeCount: number): LayoutParams;
|
|
28
30
|
/** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
|
|
29
31
|
export declare function extractSubgraph(data: LearningGraphData, seedIds: string[], hops: number): LearningGraphData;
|
|
30
32
|
/** Create a layout state from ontology data. Nodes start grouped by type. */
|
package/dist/layout.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const DEFAULT_LAYOUT_PARAMS = {
|
|
2
|
-
clusterStrength: 0.
|
|
3
|
-
spacing: 1,
|
|
2
|
+
clusterStrength: 0.08,
|
|
3
|
+
spacing: 1.5,
|
|
4
4
|
};
|
|
5
|
-
const REPULSION =
|
|
6
|
-
const CROSS_TYPE_REPULSION_BASE =
|
|
7
|
-
const ATTRACTION = 0.
|
|
8
|
-
const REST_LENGTH_SAME_BASE =
|
|
9
|
-
const REST_LENGTH_CROSS_BASE =
|
|
5
|
+
const REPULSION = 6000;
|
|
6
|
+
const CROSS_TYPE_REPULSION_BASE = 12000;
|
|
7
|
+
const ATTRACTION = 0.004;
|
|
8
|
+
const REST_LENGTH_SAME_BASE = 140;
|
|
9
|
+
const REST_LENGTH_CROSS_BASE = 350;
|
|
10
10
|
const DAMPING = 0.9;
|
|
11
11
|
const CENTER_GRAVITY = 0.01;
|
|
12
12
|
const MIN_DISTANCE = 30;
|
|
@@ -22,6 +22,16 @@ export function setLayoutParams(p) {
|
|
|
22
22
|
export function getLayoutParams() {
|
|
23
23
|
return { ...params };
|
|
24
24
|
}
|
|
25
|
+
/** Compute sensible default layout params based on graph size. */
|
|
26
|
+
export function autoLayoutParams(nodeCount) {
|
|
27
|
+
if (nodeCount <= 30)
|
|
28
|
+
return { ...DEFAULT_LAYOUT_PARAMS };
|
|
29
|
+
const scale = Math.log2(nodeCount / 30);
|
|
30
|
+
return {
|
|
31
|
+
clusterStrength: Math.min(0.5, 0.08 + 0.06 * scale),
|
|
32
|
+
spacing: Math.min(15, 1.5 + 1.2 * scale),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
25
35
|
/** Extract a display label from a node — first string property value, fallback to id. */
|
|
26
36
|
function nodeLabel(properties, id) {
|
|
27
37
|
for (const value of Object.values(properties)) {
|
|
@@ -61,7 +71,7 @@ export function createLayout(data) {
|
|
|
61
71
|
const nodeMap = new Map();
|
|
62
72
|
// Group nodes by type for initial placement
|
|
63
73
|
const types = [...new Set(data.nodes.map((n) => n.type))];
|
|
64
|
-
const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
|
|
74
|
+
const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6 * Math.max(1, params.spacing);
|
|
65
75
|
const typeCounters = new Map();
|
|
66
76
|
const typeSizes = new Map();
|
|
67
77
|
for (const n of data.nodes) {
|