backpack-viewer 0.2.13 → 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/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 +6 -0
- package/dist/canvas.js +245 -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/layout.d.ts +8 -1
- package/dist/layout.js +65 -11
- package/dist/main.js +239 -17
- package/dist/shortcuts.d.ts +4 -0
- package/dist/shortcuts.js +66 -0
- package/dist/style.css +471 -17
- package/dist/tools-pane.d.ts +18 -0
- package/dist/tools-pane.js +436 -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,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;
|
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;
|
|
@@ -93,15 +121,116 @@ async function main() {
|
|
|
93
121
|
}, (nodeId) => {
|
|
94
122
|
canvas.panToNode(nodeId);
|
|
95
123
|
});
|
|
124
|
+
const mobileQuery = window.matchMedia("(max-width: 768px)");
|
|
96
125
|
canvas = initCanvas(canvasContainer, (nodeIds) => {
|
|
97
126
|
if (nodeIds && nodeIds.length > 0 && currentData) {
|
|
98
127
|
infoPanel.show(nodeIds, currentData);
|
|
128
|
+
if (mobileQuery.matches)
|
|
129
|
+
toolsPane.collapse();
|
|
130
|
+
updateUrl(activeOntology, nodeIds);
|
|
99
131
|
}
|
|
100
132
|
else {
|
|
101
133
|
infoPanel.hide();
|
|
134
|
+
if (activeOntology)
|
|
135
|
+
updateUrl(activeOntology);
|
|
102
136
|
}
|
|
103
137
|
});
|
|
104
138
|
const search = initSearch(canvasContainer);
|
|
139
|
+
const toolsPane = initToolsPane(canvasContainer, {
|
|
140
|
+
onFilterByType(type) {
|
|
141
|
+
if (!currentData)
|
|
142
|
+
return;
|
|
143
|
+
if (type === null) {
|
|
144
|
+
canvas.setFilteredNodeIds(null);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const ids = new Set((currentData?.nodes ?? [])
|
|
148
|
+
.filter((n) => n.type === type)
|
|
149
|
+
.map((n) => n.id));
|
|
150
|
+
canvas.setFilteredNodeIds(ids);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
onNavigateToNode(nodeId) {
|
|
154
|
+
canvas.panToNode(nodeId);
|
|
155
|
+
if (currentData)
|
|
156
|
+
infoPanel.show([nodeId], currentData);
|
|
157
|
+
},
|
|
158
|
+
onRenameNodeType(oldType, newType) {
|
|
159
|
+
if (!currentData)
|
|
160
|
+
return;
|
|
161
|
+
undoHistory.push(currentData);
|
|
162
|
+
for (const node of currentData.nodes) {
|
|
163
|
+
if (node.type === oldType) {
|
|
164
|
+
node.type = newType;
|
|
165
|
+
node.updatedAt = new Date().toISOString();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
save();
|
|
169
|
+
},
|
|
170
|
+
onRenameEdgeType(oldType, newType) {
|
|
171
|
+
if (!currentData)
|
|
172
|
+
return;
|
|
173
|
+
undoHistory.push(currentData);
|
|
174
|
+
for (const edge of currentData.edges) {
|
|
175
|
+
if (edge.type === oldType) {
|
|
176
|
+
edge.type = newType;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
save();
|
|
180
|
+
},
|
|
181
|
+
onToggleEdgeLabels(visible) {
|
|
182
|
+
canvas.setEdgeLabels(visible);
|
|
183
|
+
},
|
|
184
|
+
onToggleTypeHulls(visible) {
|
|
185
|
+
canvas.setTypeHulls(visible);
|
|
186
|
+
},
|
|
187
|
+
onToggleMinimap(visible) {
|
|
188
|
+
canvas.setMinimap(visible);
|
|
189
|
+
},
|
|
190
|
+
onLayoutChange(param, value) {
|
|
191
|
+
setLayoutParams({ [param]: value });
|
|
192
|
+
canvas.reheat();
|
|
193
|
+
},
|
|
194
|
+
onExport(format) {
|
|
195
|
+
const dataUrl = canvas.exportImage(format);
|
|
196
|
+
if (!dataUrl)
|
|
197
|
+
return;
|
|
198
|
+
const link = document.createElement("a");
|
|
199
|
+
link.download = `${activeOntology || "graph"}.${format}`;
|
|
200
|
+
link.href = dataUrl;
|
|
201
|
+
link.click();
|
|
202
|
+
},
|
|
203
|
+
onOpen() {
|
|
204
|
+
if (mobileQuery.matches)
|
|
205
|
+
infoPanel.hide();
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
// --- Top bar: flex container for all top controls ---
|
|
209
|
+
const topBar = document.createElement("div");
|
|
210
|
+
topBar.className = "canvas-top-bar";
|
|
211
|
+
const topLeft = document.createElement("div");
|
|
212
|
+
topLeft.className = "canvas-top-left";
|
|
213
|
+
const topCenter = document.createElement("div");
|
|
214
|
+
topCenter.className = "canvas-top-center";
|
|
215
|
+
const topRight = document.createElement("div");
|
|
216
|
+
topRight.className = "canvas-top-right";
|
|
217
|
+
// Move tools toggle into left slot
|
|
218
|
+
const toolsToggle = canvasContainer.querySelector(".tools-pane-toggle");
|
|
219
|
+
if (toolsToggle)
|
|
220
|
+
topLeft.appendChild(toolsToggle);
|
|
221
|
+
// Move search overlay into center slot
|
|
222
|
+
const searchOverlay = canvasContainer.querySelector(".search-overlay");
|
|
223
|
+
if (searchOverlay)
|
|
224
|
+
topCenter.appendChild(searchOverlay);
|
|
225
|
+
// Move zoom controls and theme toggle into right slot
|
|
226
|
+
const zoomControls = canvasContainer.querySelector(".zoom-controls");
|
|
227
|
+
if (zoomControls)
|
|
228
|
+
topRight.appendChild(zoomControls);
|
|
229
|
+
topRight.appendChild(themeBtn);
|
|
230
|
+
topBar.appendChild(topLeft);
|
|
231
|
+
topBar.appendChild(topCenter);
|
|
232
|
+
topBar.appendChild(topRight);
|
|
233
|
+
canvasContainer.appendChild(topBar);
|
|
105
234
|
search.onFilterChange((ids) => {
|
|
106
235
|
canvas.setFilteredNodeIds(ids);
|
|
107
236
|
});
|
|
@@ -112,15 +241,7 @@ async function main() {
|
|
|
112
241
|
}
|
|
113
242
|
});
|
|
114
243
|
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
|
-
},
|
|
244
|
+
onSelect: (name) => selectGraph(name),
|
|
124
245
|
onRename: async (oldName, newName) => {
|
|
125
246
|
await renameOntology(oldName, newName);
|
|
126
247
|
if (activeOntology === oldName) {
|
|
@@ -133,21 +254,75 @@ async function main() {
|
|
|
133
254
|
currentData = await loadOntology(newName);
|
|
134
255
|
canvas.loadGraph(currentData);
|
|
135
256
|
search.setLearningGraphData(currentData);
|
|
257
|
+
toolsPane.setData(currentData);
|
|
136
258
|
}
|
|
137
259
|
},
|
|
138
260
|
});
|
|
261
|
+
const shortcuts = initShortcuts(canvasContainer);
|
|
262
|
+
const emptyState = initEmptyState(canvasContainer);
|
|
263
|
+
// --- URL deep linking ---
|
|
264
|
+
function updateUrl(name, nodeIds) {
|
|
265
|
+
const hash = "#" + encodeURIComponent(name) +
|
|
266
|
+
(nodeIds?.length ? "?node=" + nodeIds.map(encodeURIComponent).join(",") : "");
|
|
267
|
+
history.replaceState(null, "", hash);
|
|
268
|
+
}
|
|
269
|
+
function parseUrl() {
|
|
270
|
+
const hash = window.location.hash.slice(1);
|
|
271
|
+
if (!hash)
|
|
272
|
+
return { graph: null, nodes: [] };
|
|
273
|
+
const [graphPart, queryPart] = hash.split("?");
|
|
274
|
+
const graph = graphPart ? decodeURIComponent(graphPart) : null;
|
|
275
|
+
let nodes = [];
|
|
276
|
+
if (queryPart) {
|
|
277
|
+
const params = new URLSearchParams(queryPart);
|
|
278
|
+
const nodeParam = params.get("node");
|
|
279
|
+
if (nodeParam)
|
|
280
|
+
nodes = nodeParam.split(",").map(decodeURIComponent);
|
|
281
|
+
}
|
|
282
|
+
return { graph, nodes };
|
|
283
|
+
}
|
|
284
|
+
async function selectGraph(name, focusNodeIds) {
|
|
285
|
+
activeOntology = name;
|
|
286
|
+
sidebar.setActive(name);
|
|
287
|
+
infoPanel.hide();
|
|
288
|
+
search.clear();
|
|
289
|
+
undoHistory.clear();
|
|
290
|
+
currentData = await loadOntology(name);
|
|
291
|
+
canvas.loadGraph(currentData);
|
|
292
|
+
search.setLearningGraphData(currentData);
|
|
293
|
+
toolsPane.setData(currentData);
|
|
294
|
+
emptyState.hide();
|
|
295
|
+
updateUrl(name);
|
|
296
|
+
// Focus on specific nodes if requested
|
|
297
|
+
if (focusNodeIds?.length && currentData) {
|
|
298
|
+
const validIds = focusNodeIds.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
299
|
+
if (validIds.length) {
|
|
300
|
+
setTimeout(() => {
|
|
301
|
+
canvas.panToNodes(validIds);
|
|
302
|
+
if (currentData)
|
|
303
|
+
infoPanel.show(validIds, currentData);
|
|
304
|
+
updateUrl(name, validIds);
|
|
305
|
+
}, 500);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
139
309
|
// Load ontology list
|
|
140
310
|
const summaries = await listOntologies();
|
|
141
311
|
sidebar.setSummaries(summaries);
|
|
142
|
-
// Auto-load first
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
312
|
+
// Auto-load from URL hash, or first graph
|
|
313
|
+
const initialUrl = parseUrl();
|
|
314
|
+
const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
|
|
315
|
+
? initialUrl.graph
|
|
316
|
+
: summaries.length > 0
|
|
317
|
+
? summaries[0].name
|
|
318
|
+
: null;
|
|
319
|
+
if (initialName) {
|
|
320
|
+
await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
emptyState.show();
|
|
149
324
|
}
|
|
150
|
-
// Keyboard
|
|
325
|
+
// Keyboard shortcuts
|
|
151
326
|
document.addEventListener("keydown", (e) => {
|
|
152
327
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
153
328
|
return;
|
|
@@ -155,22 +330,69 @@ async function main() {
|
|
|
155
330
|
e.preventDefault();
|
|
156
331
|
search.focus();
|
|
157
332
|
}
|
|
333
|
+
else if (e.key === "z" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
if (currentData) {
|
|
336
|
+
const restored = undoHistory.redo(currentData);
|
|
337
|
+
if (restored)
|
|
338
|
+
applyState(restored);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
|
|
342
|
+
e.preventDefault();
|
|
343
|
+
if (currentData) {
|
|
344
|
+
const restored = undoHistory.undo(currentData);
|
|
345
|
+
if (restored)
|
|
346
|
+
applyState(restored);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else if (e.key === "?") {
|
|
350
|
+
shortcuts.show();
|
|
351
|
+
}
|
|
352
|
+
else if (e.key === "Escape") {
|
|
353
|
+
shortcuts.hide();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
// Handle browser back/forward
|
|
357
|
+
window.addEventListener("hashchange", () => {
|
|
358
|
+
const url = parseUrl();
|
|
359
|
+
if (url.graph && url.graph !== activeOntology) {
|
|
360
|
+
selectGraph(url.graph, url.nodes.length ? url.nodes : undefined);
|
|
361
|
+
}
|
|
362
|
+
else if (url.graph && url.nodes.length && currentData) {
|
|
363
|
+
const validIds = url.nodes.filter((id) => currentData.nodes.some((n) => n.id === id));
|
|
364
|
+
if (validIds.length) {
|
|
365
|
+
canvas.panToNodes(validIds);
|
|
366
|
+
infoPanel.show(validIds, currentData);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
158
369
|
});
|
|
159
370
|
// Live reload — when Claude adds nodes via MCP, re-fetch and re-render
|
|
160
371
|
if (import.meta.hot) {
|
|
161
372
|
import.meta.hot.on("ontology-change", async () => {
|
|
162
373
|
const updated = await listOntologies();
|
|
163
374
|
sidebar.setSummaries(updated);
|
|
375
|
+
if (updated.length > 0)
|
|
376
|
+
emptyState.hide();
|
|
164
377
|
if (activeOntology) {
|
|
165
378
|
try {
|
|
166
379
|
currentData = await loadOntology(activeOntology);
|
|
167
380
|
canvas.loadGraph(currentData);
|
|
168
381
|
search.setLearningGraphData(currentData);
|
|
382
|
+
toolsPane.setData(currentData);
|
|
169
383
|
}
|
|
170
384
|
catch {
|
|
171
385
|
// Ontology may have been deleted
|
|
172
386
|
}
|
|
173
387
|
}
|
|
388
|
+
else if (updated.length > 0) {
|
|
389
|
+
activeOntology = updated[0].name;
|
|
390
|
+
sidebar.setActive(activeOntology);
|
|
391
|
+
currentData = await loadOntology(activeOntology);
|
|
392
|
+
canvas.loadGraph(currentData);
|
|
393
|
+
search.setLearningGraphData(currentData);
|
|
394
|
+
toolsPane.setData(currentData);
|
|
395
|
+
}
|
|
174
396
|
});
|
|
175
397
|
}
|
|
176
398
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const SHORTCUTS = [
|
|
2
|
+
{ key: "/", alt: "Ctrl+K", description: "Focus search" },
|
|
3
|
+
{ key: "Ctrl+Z", description: "Undo" },
|
|
4
|
+
{ key: "Ctrl+Shift+Z", description: "Redo" },
|
|
5
|
+
{ key: "?", description: "Show this help" },
|
|
6
|
+
{ key: "Esc", description: "Close panel / clear search" },
|
|
7
|
+
{ key: "Click", description: "Select node" },
|
|
8
|
+
{ key: "Ctrl+Click", description: "Multi-select nodes" },
|
|
9
|
+
{ key: "Drag", description: "Pan canvas" },
|
|
10
|
+
{ key: "Scroll", description: "Zoom in/out" },
|
|
11
|
+
];
|
|
12
|
+
export function initShortcuts(container) {
|
|
13
|
+
const overlay = document.createElement("div");
|
|
14
|
+
overlay.className = "shortcuts-overlay hidden";
|
|
15
|
+
const modal = document.createElement("div");
|
|
16
|
+
modal.className = "shortcuts-modal";
|
|
17
|
+
const title = document.createElement("h3");
|
|
18
|
+
title.className = "shortcuts-title";
|
|
19
|
+
title.textContent = "Keyboard Shortcuts";
|
|
20
|
+
const list = document.createElement("div");
|
|
21
|
+
list.className = "shortcuts-list";
|
|
22
|
+
for (const s of SHORTCUTS) {
|
|
23
|
+
const row = document.createElement("div");
|
|
24
|
+
row.className = "shortcuts-row";
|
|
25
|
+
const keys = document.createElement("div");
|
|
26
|
+
keys.className = "shortcuts-keys";
|
|
27
|
+
const kbd = document.createElement("kbd");
|
|
28
|
+
kbd.textContent = s.key;
|
|
29
|
+
keys.appendChild(kbd);
|
|
30
|
+
if (s.alt) {
|
|
31
|
+
const or = document.createElement("span");
|
|
32
|
+
or.className = "shortcuts-or";
|
|
33
|
+
or.textContent = "or";
|
|
34
|
+
keys.appendChild(or);
|
|
35
|
+
const kbd2 = document.createElement("kbd");
|
|
36
|
+
kbd2.textContent = s.alt;
|
|
37
|
+
keys.appendChild(kbd2);
|
|
38
|
+
}
|
|
39
|
+
const desc = document.createElement("span");
|
|
40
|
+
desc.className = "shortcuts-desc";
|
|
41
|
+
desc.textContent = s.description;
|
|
42
|
+
row.appendChild(keys);
|
|
43
|
+
row.appendChild(desc);
|
|
44
|
+
list.appendChild(row);
|
|
45
|
+
}
|
|
46
|
+
const closeBtn = document.createElement("button");
|
|
47
|
+
closeBtn.className = "shortcuts-close";
|
|
48
|
+
closeBtn.textContent = "\u00d7";
|
|
49
|
+
modal.appendChild(closeBtn);
|
|
50
|
+
modal.appendChild(title);
|
|
51
|
+
modal.appendChild(list);
|
|
52
|
+
overlay.appendChild(modal);
|
|
53
|
+
container.appendChild(overlay);
|
|
54
|
+
function show() {
|
|
55
|
+
overlay.classList.remove("hidden");
|
|
56
|
+
}
|
|
57
|
+
function hide() {
|
|
58
|
+
overlay.classList.add("hidden");
|
|
59
|
+
}
|
|
60
|
+
closeBtn.addEventListener("click", hide);
|
|
61
|
+
overlay.addEventListener("click", (e) => {
|
|
62
|
+
if (e.target === overlay)
|
|
63
|
+
hide();
|
|
64
|
+
});
|
|
65
|
+
return { show, hide };
|
|
66
|
+
}
|