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/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,57 @@ function nodeLabel(properties, id) {
|
|
|
13
30
|
}
|
|
14
31
|
return id;
|
|
15
32
|
}
|
|
16
|
-
/**
|
|
33
|
+
/** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
|
|
34
|
+
export function extractSubgraph(data, seedIds, hops) {
|
|
35
|
+
const visited = new Set(seedIds);
|
|
36
|
+
let frontier = new Set(seedIds);
|
|
37
|
+
for (let h = 0; h < hops; h++) {
|
|
38
|
+
const next = new Set();
|
|
39
|
+
for (const edge of data.edges) {
|
|
40
|
+
if (frontier.has(edge.sourceId) && !visited.has(edge.targetId)) {
|
|
41
|
+
next.add(edge.targetId);
|
|
42
|
+
}
|
|
43
|
+
if (frontier.has(edge.targetId) && !visited.has(edge.sourceId)) {
|
|
44
|
+
next.add(edge.sourceId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const id of next)
|
|
48
|
+
visited.add(id);
|
|
49
|
+
frontier = next;
|
|
50
|
+
if (next.size === 0)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
nodes: data.nodes.filter((n) => visited.has(n.id)),
|
|
55
|
+
edges: data.edges.filter((e) => visited.has(e.sourceId) && visited.has(e.targetId)),
|
|
56
|
+
metadata: data.metadata,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Create a layout state from ontology data. Nodes start grouped by type. */
|
|
17
60
|
export function createLayout(data) {
|
|
18
|
-
const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
|
|
19
61
|
const nodeMap = new Map();
|
|
20
|
-
|
|
21
|
-
|
|
62
|
+
// Group nodes by type for initial placement
|
|
63
|
+
const types = [...new Set(data.nodes.map((n) => n.type))];
|
|
64
|
+
const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
|
|
65
|
+
const typeCounters = new Map();
|
|
66
|
+
const typeSizes = new Map();
|
|
67
|
+
for (const n of data.nodes) {
|
|
68
|
+
typeSizes.set(n.type, (typeSizes.get(n.type) ?? 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
const nodes = data.nodes.map((n) => {
|
|
71
|
+
const ti = types.indexOf(n.type);
|
|
72
|
+
const typeAngle = (2 * Math.PI * ti) / Math.max(types.length, 1);
|
|
73
|
+
const cx = Math.cos(typeAngle) * typeRadius;
|
|
74
|
+
const cy = Math.sin(typeAngle) * typeRadius;
|
|
75
|
+
const ni = typeCounters.get(n.type) ?? 0;
|
|
76
|
+
typeCounters.set(n.type, ni + 1);
|
|
77
|
+
const groupSize = typeSizes.get(n.type) ?? 1;
|
|
78
|
+
const nodeAngle = (2 * Math.PI * ni) / groupSize;
|
|
79
|
+
const nodeRadius = REST_LENGTH_SAME_BASE * 0.6;
|
|
22
80
|
const node = {
|
|
23
81
|
id: n.id,
|
|
24
|
-
x: Math.cos(
|
|
25
|
-
y: Math.sin(
|
|
82
|
+
x: cx + Math.cos(nodeAngle) * nodeRadius,
|
|
83
|
+
y: cy + Math.sin(nodeAngle) * nodeRadius,
|
|
26
84
|
vx: 0,
|
|
27
85
|
vy: 0,
|
|
28
86
|
label: nodeLabel(n.properties, n.id),
|
|
@@ -41,7 +99,7 @@ export function createLayout(data) {
|
|
|
41
99
|
/** Run one tick of the force simulation. Returns new alpha. */
|
|
42
100
|
export function tick(state, alpha) {
|
|
43
101
|
const { nodes, edges, nodeMap } = state;
|
|
44
|
-
// Repulsion — all pairs
|
|
102
|
+
// Repulsion — all pairs (stronger between different types)
|
|
45
103
|
for (let i = 0; i < nodes.length; i++) {
|
|
46
104
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
47
105
|
const a = nodes[i];
|
|
@@ -51,7 +109,8 @@ export function tick(state, alpha) {
|
|
|
51
109
|
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
52
110
|
if (dist < MIN_DISTANCE)
|
|
53
111
|
dist = MIN_DISTANCE;
|
|
54
|
-
const
|
|
112
|
+
const rep = a.type === b.type ? REPULSION : CROSS_TYPE_REPULSION_BASE * params.spacing;
|
|
113
|
+
const force = (rep * alpha) / (dist * dist);
|
|
55
114
|
const fx = (dx / dist) * force;
|
|
56
115
|
const fy = (dy / dist) * force;
|
|
57
116
|
a.vx -= fx;
|
|
@@ -60,7 +119,7 @@ export function tick(state, alpha) {
|
|
|
60
119
|
b.vy += fy;
|
|
61
120
|
}
|
|
62
121
|
}
|
|
63
|
-
// Attraction — along edges
|
|
122
|
+
// Attraction — along edges (shorter rest length within same type)
|
|
64
123
|
for (const edge of edges) {
|
|
65
124
|
const source = nodeMap.get(edge.sourceId);
|
|
66
125
|
const target = nodeMap.get(edge.targetId);
|
|
@@ -71,7 +130,10 @@ export function tick(state, alpha) {
|
|
|
71
130
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
72
131
|
if (dist === 0)
|
|
73
132
|
continue;
|
|
74
|
-
const
|
|
133
|
+
const restLen = source.type === target.type
|
|
134
|
+
? REST_LENGTH_SAME_BASE * params.spacing
|
|
135
|
+
: REST_LENGTH_CROSS_BASE * params.spacing;
|
|
136
|
+
const force = ATTRACTION * (dist - restLen) * alpha;
|
|
75
137
|
const fx = (dx / dist) * force;
|
|
76
138
|
const fy = (dy / dist) * force;
|
|
77
139
|
source.vx += fx;
|
|
@@ -84,6 +146,24 @@ export function tick(state, alpha) {
|
|
|
84
146
|
node.vx -= node.x * CENTER_GRAVITY * alpha;
|
|
85
147
|
node.vy -= node.y * CENTER_GRAVITY * alpha;
|
|
86
148
|
}
|
|
149
|
+
// Cluster force — pull nodes toward their type centroid
|
|
150
|
+
const centroids = new Map();
|
|
151
|
+
for (const node of nodes) {
|
|
152
|
+
const c = centroids.get(node.type) ?? { x: 0, y: 0, count: 0 };
|
|
153
|
+
c.x += node.x;
|
|
154
|
+
c.y += node.y;
|
|
155
|
+
c.count++;
|
|
156
|
+
centroids.set(node.type, c);
|
|
157
|
+
}
|
|
158
|
+
for (const c of centroids.values()) {
|
|
159
|
+
c.x /= c.count;
|
|
160
|
+
c.y /= c.count;
|
|
161
|
+
}
|
|
162
|
+
for (const node of nodes) {
|
|
163
|
+
const c = centroids.get(node.type);
|
|
164
|
+
node.vx += (c.x - node.x) * params.clusterStrength * alpha;
|
|
165
|
+
node.vy += (c.y - node.y) * params.clusterStrength * alpha;
|
|
166
|
+
}
|
|
87
167
|
// Integrate — update positions, apply damping, clamp velocity
|
|
88
168
|
for (const node of nodes) {
|
|
89
169
|
node.vx *= DAMPING;
|
package/dist/main.js
CHANGED
|
@@ -3,6 +3,11 @@ import { initSidebar } from "./sidebar";
|
|
|
3
3
|
import { initCanvas } from "./canvas";
|
|
4
4
|
import { initInfoPanel } from "./info-panel";
|
|
5
5
|
import { initSearch } from "./search";
|
|
6
|
+
import { initToolsPane } from "./tools-pane";
|
|
7
|
+
import { setLayoutParams } from "./layout";
|
|
8
|
+
import { initShortcuts } from "./shortcuts";
|
|
9
|
+
import { initEmptyState } from "./empty-state";
|
|
10
|
+
import { createHistory } from "./history";
|
|
6
11
|
import "./style.css";
|
|
7
12
|
let activeOntology = "";
|
|
8
13
|
let currentData = null;
|
|
@@ -25,6 +30,8 @@ async function main() {
|
|
|
25
30
|
themeBtn.textContent = next === "light" ? "\u263E" : "\u263C";
|
|
26
31
|
});
|
|
27
32
|
canvasContainer.appendChild(themeBtn);
|
|
33
|
+
// --- Undo/redo ---
|
|
34
|
+
const undoHistory = createHistory();
|
|
28
35
|
// --- Save and re-render helper ---
|
|
29
36
|
async function save() {
|
|
30
37
|
if (!activeOntology || !currentData)
|
|
@@ -33,10 +40,26 @@ async function main() {
|
|
|
33
40
|
await saveOntology(activeOntology, currentData);
|
|
34
41
|
canvas.loadGraph(currentData);
|
|
35
42
|
search.setLearningGraphData(currentData);
|
|
43
|
+
toolsPane.setData(currentData);
|
|
36
44
|
// Refresh sidebar counts
|
|
37
45
|
const updated = await listOntologies();
|
|
38
46
|
sidebar.setSummaries(updated);
|
|
39
47
|
}
|
|
48
|
+
/** Snapshot current state, then save. Call this instead of save() for undoable actions. */
|
|
49
|
+
async function undoableSave() {
|
|
50
|
+
if (currentData)
|
|
51
|
+
undoHistory.push(currentData);
|
|
52
|
+
await save();
|
|
53
|
+
}
|
|
54
|
+
async function applyState(data) {
|
|
55
|
+
currentData = data;
|
|
56
|
+
await saveOntology(activeOntology, currentData);
|
|
57
|
+
canvas.loadGraph(currentData);
|
|
58
|
+
search.setLearningGraphData(currentData);
|
|
59
|
+
toolsPane.setData(currentData);
|
|
60
|
+
const updated = await listOntologies();
|
|
61
|
+
sidebar.setSummaries(updated);
|
|
62
|
+
}
|
|
40
63
|
// --- Info panel with edit callbacks ---
|
|
41
64
|
// canvas is used inside the navigate callback but declared below —
|
|
42
65
|
// that's fine because the callback is only invoked after setup completes.
|
|
@@ -45,6 +68,7 @@ async function main() {
|
|
|
45
68
|
onUpdateNode(nodeId, properties) {
|
|
46
69
|
if (!currentData)
|
|
47
70
|
return;
|
|
71
|
+
undoHistory.push(currentData);
|
|
48
72
|
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
49
73
|
if (!node)
|
|
50
74
|
return;
|
|
@@ -55,6 +79,7 @@ async function main() {
|
|
|
55
79
|
onChangeNodeType(nodeId, newType) {
|
|
56
80
|
if (!currentData)
|
|
57
81
|
return;
|
|
82
|
+
undoHistory.push(currentData);
|
|
58
83
|
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
59
84
|
if (!node)
|
|
60
85
|
return;
|
|
@@ -65,6 +90,7 @@ async function main() {
|
|
|
65
90
|
onDeleteNode(nodeId) {
|
|
66
91
|
if (!currentData)
|
|
67
92
|
return;
|
|
93
|
+
undoHistory.push(currentData);
|
|
68
94
|
currentData.nodes = currentData.nodes.filter((n) => n.id !== nodeId);
|
|
69
95
|
currentData.edges = currentData.edges.filter((e) => e.sourceId !== nodeId && e.targetId !== nodeId);
|
|
70
96
|
save();
|
|
@@ -72,6 +98,7 @@ async function main() {
|
|
|
72
98
|
onDeleteEdge(edgeId) {
|
|
73
99
|
if (!currentData)
|
|
74
100
|
return;
|
|
101
|
+
undoHistory.push(currentData);
|
|
75
102
|
const selectedNodeId = currentData.edges.find((e) => e.id === edgeId)?.sourceId;
|
|
76
103
|
currentData.edges = currentData.edges.filter((e) => e.id !== edgeId);
|
|
77
104
|
save().then(() => {
|
|
@@ -83,6 +110,7 @@ async function main() {
|
|
|
83
110
|
onAddProperty(nodeId, key, value) {
|
|
84
111
|
if (!currentData)
|
|
85
112
|
return;
|
|
113
|
+
undoHistory.push(currentData);
|
|
86
114
|
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
87
115
|
if (!node)
|
|
88
116
|
return;
|
|
@@ -92,35 +120,206 @@ async function main() {
|
|
|
92
120
|
},
|
|
93
121
|
}, (nodeId) => {
|
|
94
122
|
canvas.panToNode(nodeId);
|
|
123
|
+
}, (nodeIds) => {
|
|
124
|
+
toolsPane.addToFocusSet(nodeIds);
|
|
95
125
|
});
|
|
126
|
+
const mobileQuery = window.matchMedia("(max-width: 768px)");
|
|
127
|
+
// Track current selection for keyboard shortcuts
|
|
128
|
+
let currentSelection = [];
|
|
129
|
+
// --- Focus indicator (top bar pill) ---
|
|
130
|
+
let focusIndicator = null;
|
|
131
|
+
function buildFocusIndicator(info) {
|
|
132
|
+
if (focusIndicator)
|
|
133
|
+
focusIndicator.remove();
|
|
134
|
+
focusIndicator = document.createElement("div");
|
|
135
|
+
focusIndicator.className = "focus-indicator";
|
|
136
|
+
const label = document.createElement("span");
|
|
137
|
+
label.className = "focus-indicator-label";
|
|
138
|
+
label.textContent = `Focused: ${info.totalNodes} nodes`;
|
|
139
|
+
const hopsLabel = document.createElement("span");
|
|
140
|
+
hopsLabel.className = "focus-indicator-hops";
|
|
141
|
+
hopsLabel.textContent = `${info.hops}`;
|
|
142
|
+
const minus = document.createElement("button");
|
|
143
|
+
minus.className = "focus-indicator-btn";
|
|
144
|
+
minus.textContent = "\u2212";
|
|
145
|
+
minus.title = "Fewer hops";
|
|
146
|
+
minus.disabled = info.hops === 0;
|
|
147
|
+
minus.addEventListener("click", () => {
|
|
148
|
+
canvas.enterFocus(info.seedNodeIds, Math.max(0, info.hops - 1));
|
|
149
|
+
});
|
|
150
|
+
const plus = document.createElement("button");
|
|
151
|
+
plus.className = "focus-indicator-btn";
|
|
152
|
+
plus.textContent = "+";
|
|
153
|
+
plus.title = "More hops";
|
|
154
|
+
plus.disabled = false;
|
|
155
|
+
plus.addEventListener("click", () => {
|
|
156
|
+
canvas.enterFocus(info.seedNodeIds, info.hops + 1);
|
|
157
|
+
});
|
|
158
|
+
const exit = document.createElement("button");
|
|
159
|
+
exit.className = "focus-indicator-btn focus-indicator-exit";
|
|
160
|
+
exit.textContent = "\u00d7";
|
|
161
|
+
exit.title = "Exit focus (Esc)";
|
|
162
|
+
exit.addEventListener("click", () => toolsPane.clearFocusSet());
|
|
163
|
+
focusIndicator.appendChild(label);
|
|
164
|
+
focusIndicator.appendChild(minus);
|
|
165
|
+
focusIndicator.appendChild(hopsLabel);
|
|
166
|
+
focusIndicator.appendChild(plus);
|
|
167
|
+
focusIndicator.appendChild(exit);
|
|
168
|
+
}
|
|
169
|
+
function removeFocusIndicator() {
|
|
170
|
+
if (focusIndicator) {
|
|
171
|
+
focusIndicator.remove();
|
|
172
|
+
focusIndicator = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
96
175
|
canvas = initCanvas(canvasContainer, (nodeIds) => {
|
|
176
|
+
currentSelection = nodeIds ?? [];
|
|
97
177
|
if (nodeIds && nodeIds.length > 0 && currentData) {
|
|
98
178
|
infoPanel.show(nodeIds, currentData);
|
|
179
|
+
if (mobileQuery.matches)
|
|
180
|
+
toolsPane.collapse();
|
|
181
|
+
updateUrl(activeOntology, nodeIds);
|
|
99
182
|
}
|
|
100
183
|
else {
|
|
101
184
|
infoPanel.hide();
|
|
185
|
+
if (activeOntology)
|
|
186
|
+
updateUrl(activeOntology);
|
|
187
|
+
}
|
|
188
|
+
}, (focus) => {
|
|
189
|
+
if (focus) {
|
|
190
|
+
buildFocusIndicator(focus);
|
|
191
|
+
// Insert into top-left, after tools toggle
|
|
192
|
+
const topLeft = canvasContainer.querySelector(".canvas-top-left");
|
|
193
|
+
if (topLeft && focusIndicator)
|
|
194
|
+
topLeft.appendChild(focusIndicator);
|
|
195
|
+
updateUrl(activeOntology, focus.seedNodeIds);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
removeFocusIndicator();
|
|
199
|
+
if (activeOntology)
|
|
200
|
+
updateUrl(activeOntology);
|
|
102
201
|
}
|
|
103
202
|
});
|
|
104
203
|
const search = initSearch(canvasContainer);
|
|
204
|
+
const toolsPane = initToolsPane(canvasContainer, {
|
|
205
|
+
onFilterByType(type) {
|
|
206
|
+
if (!currentData)
|
|
207
|
+
return;
|
|
208
|
+
if (type === null) {
|
|
209
|
+
canvas.setFilteredNodeIds(null);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const ids = new Set((currentData?.nodes ?? [])
|
|
213
|
+
.filter((n) => n.type === type)
|
|
214
|
+
.map((n) => n.id));
|
|
215
|
+
canvas.setFilteredNodeIds(ids);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
onNavigateToNode(nodeId) {
|
|
219
|
+
canvas.panToNode(nodeId);
|
|
220
|
+
if (currentData)
|
|
221
|
+
infoPanel.show([nodeId], currentData);
|
|
222
|
+
},
|
|
223
|
+
onFocusChange(seedNodeIds) {
|
|
224
|
+
if (seedNodeIds && seedNodeIds.length > 0) {
|
|
225
|
+
canvas.enterFocus(seedNodeIds, 1);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
if (canvas.isFocused())
|
|
229
|
+
canvas.exitFocus();
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
onRenameNodeType(oldType, newType) {
|
|
233
|
+
if (!currentData)
|
|
234
|
+
return;
|
|
235
|
+
undoHistory.push(currentData);
|
|
236
|
+
for (const node of currentData.nodes) {
|
|
237
|
+
if (node.type === oldType) {
|
|
238
|
+
node.type = newType;
|
|
239
|
+
node.updatedAt = new Date().toISOString();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
save();
|
|
243
|
+
},
|
|
244
|
+
onRenameEdgeType(oldType, newType) {
|
|
245
|
+
if (!currentData)
|
|
246
|
+
return;
|
|
247
|
+
undoHistory.push(currentData);
|
|
248
|
+
for (const edge of currentData.edges) {
|
|
249
|
+
if (edge.type === oldType) {
|
|
250
|
+
edge.type = newType;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
save();
|
|
254
|
+
},
|
|
255
|
+
onToggleEdgeLabels(visible) {
|
|
256
|
+
canvas.setEdgeLabels(visible);
|
|
257
|
+
},
|
|
258
|
+
onToggleTypeHulls(visible) {
|
|
259
|
+
canvas.setTypeHulls(visible);
|
|
260
|
+
},
|
|
261
|
+
onToggleMinimap(visible) {
|
|
262
|
+
canvas.setMinimap(visible);
|
|
263
|
+
},
|
|
264
|
+
onLayoutChange(param, value) {
|
|
265
|
+
setLayoutParams({ [param]: value });
|
|
266
|
+
canvas.reheat();
|
|
267
|
+
},
|
|
268
|
+
onExport(format) {
|
|
269
|
+
const dataUrl = canvas.exportImage(format);
|
|
270
|
+
if (!dataUrl)
|
|
271
|
+
return;
|
|
272
|
+
const link = document.createElement("a");
|
|
273
|
+
link.download = `${activeOntology || "graph"}.${format}`;
|
|
274
|
+
link.href = dataUrl;
|
|
275
|
+
link.click();
|
|
276
|
+
},
|
|
277
|
+
onOpen() {
|
|
278
|
+
if (mobileQuery.matches)
|
|
279
|
+
infoPanel.hide();
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
// --- Top bar: flex container for all top controls ---
|
|
283
|
+
const topBar = document.createElement("div");
|
|
284
|
+
topBar.className = "canvas-top-bar";
|
|
285
|
+
const topLeft = document.createElement("div");
|
|
286
|
+
topLeft.className = "canvas-top-left";
|
|
287
|
+
const topCenter = document.createElement("div");
|
|
288
|
+
topCenter.className = "canvas-top-center";
|
|
289
|
+
const topRight = document.createElement("div");
|
|
290
|
+
topRight.className = "canvas-top-right";
|
|
291
|
+
// Move tools toggle into left slot
|
|
292
|
+
const toolsToggle = canvasContainer.querySelector(".tools-pane-toggle");
|
|
293
|
+
if (toolsToggle)
|
|
294
|
+
topLeft.appendChild(toolsToggle);
|
|
295
|
+
// Move search overlay into center slot
|
|
296
|
+
const searchOverlay = canvasContainer.querySelector(".search-overlay");
|
|
297
|
+
if (searchOverlay)
|
|
298
|
+
topCenter.appendChild(searchOverlay);
|
|
299
|
+
// Move zoom controls and theme toggle into right slot
|
|
300
|
+
const zoomControls = canvasContainer.querySelector(".zoom-controls");
|
|
301
|
+
if (zoomControls)
|
|
302
|
+
topRight.appendChild(zoomControls);
|
|
303
|
+
topRight.appendChild(themeBtn);
|
|
304
|
+
topBar.appendChild(topLeft);
|
|
305
|
+
topBar.appendChild(topCenter);
|
|
306
|
+
topBar.appendChild(topRight);
|
|
307
|
+
canvasContainer.appendChild(topBar);
|
|
105
308
|
search.onFilterChange((ids) => {
|
|
106
309
|
canvas.setFilteredNodeIds(ids);
|
|
107
310
|
});
|
|
108
311
|
search.onNodeSelect((nodeId) => {
|
|
312
|
+
// If focused and the node isn't in the subgraph, exit focus first
|
|
313
|
+
if (canvas.isFocused()) {
|
|
314
|
+
toolsPane.clearFocusSet();
|
|
315
|
+
}
|
|
109
316
|
canvas.panToNode(nodeId);
|
|
110
317
|
if (currentData) {
|
|
111
318
|
infoPanel.show([nodeId], currentData);
|
|
112
319
|
}
|
|
113
320
|
});
|
|
114
321
|
const sidebar = initSidebar(document.getElementById("sidebar"), {
|
|
115
|
-
onSelect:
|
|
116
|
-
activeOntology = name;
|
|
117
|
-
sidebar.setActive(name);
|
|
118
|
-
infoPanel.hide();
|
|
119
|
-
search.clear();
|
|
120
|
-
currentData = await loadOntology(name);
|
|
121
|
-
canvas.loadGraph(currentData);
|
|
122
|
-
search.setLearningGraphData(currentData);
|
|
123
|
-
},
|
|
322
|
+
onSelect: (name) => selectGraph(name),
|
|
124
323
|
onRename: async (oldName, newName) => {
|
|
125
324
|
await renameOntology(oldName, newName);
|
|
126
325
|
if (activeOntology === oldName) {
|
|
@@ -133,21 +332,103 @@ async function main() {
|
|
|
133
332
|
currentData = await loadOntology(newName);
|
|
134
333
|
canvas.loadGraph(currentData);
|
|
135
334
|
search.setLearningGraphData(currentData);
|
|
335
|
+
toolsPane.setData(currentData);
|
|
136
336
|
}
|
|
137
337
|
},
|
|
138
338
|
});
|
|
339
|
+
const shortcuts = initShortcuts(canvasContainer);
|
|
340
|
+
const emptyState = initEmptyState(canvasContainer);
|
|
341
|
+
// --- URL deep linking ---
|
|
342
|
+
function updateUrl(name, nodeIds) {
|
|
343
|
+
const parts = [];
|
|
344
|
+
if (nodeIds?.length) {
|
|
345
|
+
parts.push("node=" + nodeIds.map(encodeURIComponent).join(","));
|
|
346
|
+
}
|
|
347
|
+
const focusInfo = canvas.getFocusInfo();
|
|
348
|
+
if (focusInfo) {
|
|
349
|
+
parts.push("focus=" + focusInfo.seedNodeIds.map(encodeURIComponent).join(","));
|
|
350
|
+
parts.push("hops=" + focusInfo.hops);
|
|
351
|
+
}
|
|
352
|
+
const hash = "#" + encodeURIComponent(name) +
|
|
353
|
+
(parts.length ? "?" + parts.join("&") : "");
|
|
354
|
+
history.replaceState(null, "", hash);
|
|
355
|
+
}
|
|
356
|
+
function parseUrl() {
|
|
357
|
+
const hash = window.location.hash.slice(1);
|
|
358
|
+
if (!hash)
|
|
359
|
+
return { graph: null, nodes: [], focus: [], hops: 1 };
|
|
360
|
+
const [graphPart, queryPart] = hash.split("?");
|
|
361
|
+
const graph = graphPart ? decodeURIComponent(graphPart) : null;
|
|
362
|
+
let nodes = [];
|
|
363
|
+
let focus = [];
|
|
364
|
+
let hops = 1;
|
|
365
|
+
if (queryPart) {
|
|
366
|
+
const params = new URLSearchParams(queryPart);
|
|
367
|
+
const nodeParam = params.get("node");
|
|
368
|
+
if (nodeParam)
|
|
369
|
+
nodes = nodeParam.split(",").map(decodeURIComponent);
|
|
370
|
+
const focusParam = params.get("focus");
|
|
371
|
+
if (focusParam)
|
|
372
|
+
focus = focusParam.split(",").map(decodeURIComponent);
|
|
373
|
+
const hopsParam = params.get("hops");
|
|
374
|
+
if (hopsParam)
|
|
375
|
+
hops = Math.max(0, parseInt(hopsParam, 10) || 1);
|
|
376
|
+
}
|
|
377
|
+
return { graph, nodes, focus, hops };
|
|
378
|
+
}
|
|
379
|
+
async function selectGraph(name, panToNodeIds, focusSeedIds, focusHops) {
|
|
380
|
+
activeOntology = name;
|
|
381
|
+
sidebar.setActive(name);
|
|
382
|
+
infoPanel.hide();
|
|
383
|
+
removeFocusIndicator();
|
|
384
|
+
search.clear();
|
|
385
|
+
undoHistory.clear();
|
|
386
|
+
currentData = await loadOntology(name);
|
|
387
|
+
canvas.loadGraph(currentData);
|
|
388
|
+
search.setLearningGraphData(currentData);
|
|
389
|
+
toolsPane.setData(currentData);
|
|
390
|
+
emptyState.hide();
|
|
391
|
+
updateUrl(name);
|
|
392
|
+
// Restore focus mode if requested
|
|
393
|
+
if (focusSeedIds?.length && currentData) {
|
|
394
|
+
const validFocus = focusSeedIds.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
395
|
+
if (validFocus.length) {
|
|
396
|
+
setTimeout(() => {
|
|
397
|
+
canvas.enterFocus(validFocus, focusHops ?? 1);
|
|
398
|
+
}, 500);
|
|
399
|
+
return; // enterFocus handles the URL update
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Pan to specific nodes if requested
|
|
403
|
+
if (panToNodeIds?.length && currentData) {
|
|
404
|
+
const validIds = panToNodeIds.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
405
|
+
if (validIds.length) {
|
|
406
|
+
setTimeout(() => {
|
|
407
|
+
canvas.panToNodes(validIds);
|
|
408
|
+
if (currentData)
|
|
409
|
+
infoPanel.show(validIds, currentData);
|
|
410
|
+
updateUrl(name, validIds);
|
|
411
|
+
}, 500);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
139
415
|
// Load ontology list
|
|
140
416
|
const summaries = await listOntologies();
|
|
141
417
|
sidebar.setSummaries(summaries);
|
|
142
|
-
// Auto-load first
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
418
|
+
// Auto-load from URL hash, or first graph
|
|
419
|
+
const initialUrl = parseUrl();
|
|
420
|
+
const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
|
|
421
|
+
? initialUrl.graph
|
|
422
|
+
: summaries.length > 0
|
|
423
|
+
? summaries[0].name
|
|
424
|
+
: null;
|
|
425
|
+
if (initialName) {
|
|
426
|
+
await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
|
|
149
427
|
}
|
|
150
|
-
|
|
428
|
+
else {
|
|
429
|
+
emptyState.show();
|
|
430
|
+
}
|
|
431
|
+
// Keyboard shortcuts
|
|
151
432
|
document.addEventListener("keydown", (e) => {
|
|
152
433
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
153
434
|
return;
|
|
@@ -155,22 +436,88 @@ async function main() {
|
|
|
155
436
|
e.preventDefault();
|
|
156
437
|
search.focus();
|
|
157
438
|
}
|
|
439
|
+
else if (e.key === "z" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
if (currentData) {
|
|
442
|
+
const restored = undoHistory.redo(currentData);
|
|
443
|
+
if (restored)
|
|
444
|
+
applyState(restored);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
else if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
if (currentData) {
|
|
450
|
+
const restored = undoHistory.undo(currentData);
|
|
451
|
+
if (restored)
|
|
452
|
+
applyState(restored);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else if (e.key === "f" || e.key === "F") {
|
|
456
|
+
// Toggle focus mode on current selection
|
|
457
|
+
if (canvas.isFocused()) {
|
|
458
|
+
toolsPane.clearFocusSet();
|
|
459
|
+
}
|
|
460
|
+
else if (currentSelection.length > 0) {
|
|
461
|
+
toolsPane.addToFocusSet(currentSelection);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
else if (e.key === "?") {
|
|
465
|
+
shortcuts.show();
|
|
466
|
+
}
|
|
467
|
+
else if (e.key === "Escape") {
|
|
468
|
+
if (canvas.isFocused()) {
|
|
469
|
+
toolsPane.clearFocusSet();
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
shortcuts.hide();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
// Handle browser back/forward
|
|
477
|
+
window.addEventListener("hashchange", () => {
|
|
478
|
+
const url = parseUrl();
|
|
479
|
+
if (url.graph && url.graph !== activeOntology) {
|
|
480
|
+
selectGraph(url.graph, url.nodes.length ? url.nodes : undefined, url.focus.length ? url.focus : undefined, url.hops);
|
|
481
|
+
}
|
|
482
|
+
else if (url.graph && url.focus.length && currentData) {
|
|
483
|
+
canvas.enterFocus(url.focus, url.hops);
|
|
484
|
+
}
|
|
485
|
+
else if (url.graph && url.nodes.length && currentData) {
|
|
486
|
+
if (canvas.isFocused())
|
|
487
|
+
canvas.exitFocus();
|
|
488
|
+
const validIds = url.nodes.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
489
|
+
if (validIds.length) {
|
|
490
|
+
canvas.panToNodes(validIds);
|
|
491
|
+
infoPanel.show(validIds, currentData);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
158
494
|
});
|
|
159
495
|
// Live reload — when Claude adds nodes via MCP, re-fetch and re-render
|
|
160
496
|
if (import.meta.hot) {
|
|
161
497
|
import.meta.hot.on("ontology-change", async () => {
|
|
162
498
|
const updated = await listOntologies();
|
|
163
499
|
sidebar.setSummaries(updated);
|
|
500
|
+
if (updated.length > 0)
|
|
501
|
+
emptyState.hide();
|
|
164
502
|
if (activeOntology) {
|
|
165
503
|
try {
|
|
166
504
|
currentData = await loadOntology(activeOntology);
|
|
167
505
|
canvas.loadGraph(currentData);
|
|
168
506
|
search.setLearningGraphData(currentData);
|
|
507
|
+
toolsPane.setData(currentData);
|
|
169
508
|
}
|
|
170
509
|
catch {
|
|
171
510
|
// Ontology may have been deleted
|
|
172
511
|
}
|
|
173
512
|
}
|
|
513
|
+
else if (updated.length > 0) {
|
|
514
|
+
activeOntology = updated[0].name;
|
|
515
|
+
sidebar.setActive(activeOntology);
|
|
516
|
+
currentData = await loadOntology(activeOntology);
|
|
517
|
+
canvas.loadGraph(currentData);
|
|
518
|
+
search.setLearningGraphData(currentData);
|
|
519
|
+
toolsPane.setData(currentData);
|
|
520
|
+
}
|
|
174
521
|
});
|
|
175
522
|
}
|
|
176
523
|
}
|