backpack-viewer 0.2.6 → 0.2.8
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/bin/serve.js +1 -1
- package/dist/api.d.ts +5 -0
- package/dist/api.js +30 -0
- package/dist/app/assets/index-CLyb9OCm.css +1 -0
- package/dist/app/assets/index-Ev20LdMk.js +1 -0
- package/dist/{index.html → app/index.html} +2 -2
- package/dist/canvas.d.ts +7 -0
- package/dist/canvas.js +442 -0
- package/dist/colors.d.ts +7 -0
- package/dist/colors.js +37 -0
- package/dist/info-panel.d.ts +13 -0
- package/dist/info-panel.js +579 -0
- package/dist/layout.d.ts +24 -0
- package/dist/layout.js +100 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +177 -0
- package/dist/search.d.ts +8 -0
- package/dist/search.js +229 -0
- package/dist/sidebar.d.ts +9 -0
- package/dist/sidebar.js +103 -0
- package/dist/style.css +914 -0
- package/package.json +3 -3
- package/dist/assets/index-BwXh5IUT.js +0 -1
- package/dist/assets/index-DQfh3jIv.css +0 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OntologyData } from "backpack-ontology";
|
|
2
|
+
export interface EditCallbacks {
|
|
3
|
+
onUpdateNode(nodeId: string, properties: Record<string, unknown>): void;
|
|
4
|
+
onChangeNodeType(nodeId: string, newType: string): void;
|
|
5
|
+
onDeleteNode(nodeId: string): void;
|
|
6
|
+
onDeleteEdge(edgeId: string): void;
|
|
7
|
+
onAddProperty(nodeId: string, key: string, value: string): void;
|
|
8
|
+
}
|
|
9
|
+
export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void): {
|
|
10
|
+
show(nodeIds: string[], data: OntologyData): void;
|
|
11
|
+
hide: () => void;
|
|
12
|
+
readonly visible: boolean;
|
|
13
|
+
};
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { getColor } from "./colors";
|
|
2
|
+
/** Extract a display label from a node — first string property, fallback to id. */
|
|
3
|
+
function nodeLabel(node) {
|
|
4
|
+
for (const value of Object.values(node.properties)) {
|
|
5
|
+
if (typeof value === "string")
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
return node.id;
|
|
9
|
+
}
|
|
10
|
+
const EDIT_ICON = '\u270E'; // pencil
|
|
11
|
+
export function initInfoPanel(container, callbacks, onNavigateToNode) {
|
|
12
|
+
const panel = document.createElement("div");
|
|
13
|
+
panel.id = "info-panel";
|
|
14
|
+
panel.className = "info-panel hidden";
|
|
15
|
+
container.appendChild(panel);
|
|
16
|
+
// --- State ---
|
|
17
|
+
let maximized = false;
|
|
18
|
+
let history = [];
|
|
19
|
+
let historyIndex = -1;
|
|
20
|
+
let navigatingHistory = false;
|
|
21
|
+
let lastData = null;
|
|
22
|
+
function hide() {
|
|
23
|
+
panel.classList.add("hidden");
|
|
24
|
+
panel.classList.remove("info-panel-maximized");
|
|
25
|
+
panel.innerHTML = "";
|
|
26
|
+
maximized = false;
|
|
27
|
+
history = [];
|
|
28
|
+
historyIndex = -1;
|
|
29
|
+
}
|
|
30
|
+
function navigateTo(nodeId) {
|
|
31
|
+
if (!lastData || !onNavigateToNode)
|
|
32
|
+
return;
|
|
33
|
+
// Push to history before navigating
|
|
34
|
+
if (historyIndex < history.length - 1) {
|
|
35
|
+
history = history.slice(0, historyIndex + 1);
|
|
36
|
+
}
|
|
37
|
+
history.push(nodeId);
|
|
38
|
+
historyIndex = history.length - 1;
|
|
39
|
+
// Set flag so the show() call from canvas doesn't double-push
|
|
40
|
+
navigatingHistory = true;
|
|
41
|
+
onNavigateToNode(nodeId);
|
|
42
|
+
navigatingHistory = false;
|
|
43
|
+
}
|
|
44
|
+
function goBack() {
|
|
45
|
+
if (historyIndex <= 0 || !lastData || !onNavigateToNode)
|
|
46
|
+
return;
|
|
47
|
+
historyIndex--;
|
|
48
|
+
navigatingHistory = true;
|
|
49
|
+
onNavigateToNode(history[historyIndex]);
|
|
50
|
+
navigatingHistory = false;
|
|
51
|
+
}
|
|
52
|
+
function goForward() {
|
|
53
|
+
if (historyIndex >= history.length - 1 || !lastData || !onNavigateToNode)
|
|
54
|
+
return;
|
|
55
|
+
historyIndex++;
|
|
56
|
+
navigatingHistory = true;
|
|
57
|
+
onNavigateToNode(history[historyIndex]);
|
|
58
|
+
navigatingHistory = false;
|
|
59
|
+
}
|
|
60
|
+
function createToolbar() {
|
|
61
|
+
const toolbar = document.createElement("div");
|
|
62
|
+
toolbar.className = "info-panel-toolbar";
|
|
63
|
+
// Back
|
|
64
|
+
const backBtn = document.createElement("button");
|
|
65
|
+
backBtn.className = "info-toolbar-btn";
|
|
66
|
+
backBtn.textContent = "\u2190";
|
|
67
|
+
backBtn.title = "Back";
|
|
68
|
+
backBtn.disabled = historyIndex <= 0;
|
|
69
|
+
backBtn.addEventListener("click", goBack);
|
|
70
|
+
toolbar.appendChild(backBtn);
|
|
71
|
+
// Forward
|
|
72
|
+
const fwdBtn = document.createElement("button");
|
|
73
|
+
fwdBtn.className = "info-toolbar-btn";
|
|
74
|
+
fwdBtn.textContent = "\u2192";
|
|
75
|
+
fwdBtn.title = "Forward";
|
|
76
|
+
fwdBtn.disabled = historyIndex >= history.length - 1;
|
|
77
|
+
fwdBtn.addEventListener("click", goForward);
|
|
78
|
+
toolbar.appendChild(fwdBtn);
|
|
79
|
+
// Maximize/restore
|
|
80
|
+
const maxBtn = document.createElement("button");
|
|
81
|
+
maxBtn.className = "info-toolbar-btn";
|
|
82
|
+
maxBtn.textContent = maximized ? "\u2398" : "\u26F6";
|
|
83
|
+
maxBtn.title = maximized ? "Restore" : "Maximize";
|
|
84
|
+
maxBtn.addEventListener("click", () => {
|
|
85
|
+
maximized = !maximized;
|
|
86
|
+
panel.classList.toggle("info-panel-maximized", maximized);
|
|
87
|
+
maxBtn.textContent = maximized ? "\u2398" : "\u26F6";
|
|
88
|
+
maxBtn.title = maximized ? "Restore" : "Maximize";
|
|
89
|
+
});
|
|
90
|
+
toolbar.appendChild(maxBtn);
|
|
91
|
+
// Close
|
|
92
|
+
const closeBtn = document.createElement("button");
|
|
93
|
+
closeBtn.className = "info-toolbar-btn info-close-btn";
|
|
94
|
+
closeBtn.textContent = "\u00d7";
|
|
95
|
+
closeBtn.title = "Close";
|
|
96
|
+
closeBtn.addEventListener("click", hide);
|
|
97
|
+
toolbar.appendChild(closeBtn);
|
|
98
|
+
return toolbar;
|
|
99
|
+
}
|
|
100
|
+
function showSingle(nodeId, data) {
|
|
101
|
+
const node = data.nodes.find((n) => n.id === nodeId);
|
|
102
|
+
if (!node)
|
|
103
|
+
return;
|
|
104
|
+
const connectedEdges = data.edges.filter((e) => e.sourceId === nodeId || e.targetId === nodeId);
|
|
105
|
+
panel.innerHTML = "";
|
|
106
|
+
panel.classList.remove("hidden");
|
|
107
|
+
if (maximized)
|
|
108
|
+
panel.classList.add("info-panel-maximized");
|
|
109
|
+
// Toolbar (back, forward, maximize, close)
|
|
110
|
+
panel.appendChild(createToolbar());
|
|
111
|
+
// Header: type badge + label
|
|
112
|
+
const header = document.createElement("div");
|
|
113
|
+
header.className = "info-header";
|
|
114
|
+
const typeBadge = document.createElement("span");
|
|
115
|
+
typeBadge.className = "info-type-badge";
|
|
116
|
+
typeBadge.textContent = node.type;
|
|
117
|
+
typeBadge.style.backgroundColor = getColor(node.type);
|
|
118
|
+
if (callbacks) {
|
|
119
|
+
typeBadge.classList.add("info-editable");
|
|
120
|
+
const typeEditBtn = document.createElement("button");
|
|
121
|
+
typeEditBtn.className = "info-inline-edit";
|
|
122
|
+
typeEditBtn.textContent = EDIT_ICON;
|
|
123
|
+
typeEditBtn.addEventListener("click", (e) => {
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
const input = document.createElement("input");
|
|
126
|
+
input.type = "text";
|
|
127
|
+
input.className = "info-edit-inline-input";
|
|
128
|
+
input.value = node.type;
|
|
129
|
+
typeBadge.textContent = "";
|
|
130
|
+
typeBadge.appendChild(input);
|
|
131
|
+
input.focus();
|
|
132
|
+
input.select();
|
|
133
|
+
const finish = () => {
|
|
134
|
+
const val = input.value.trim();
|
|
135
|
+
if (val && val !== node.type) {
|
|
136
|
+
callbacks.onChangeNodeType(nodeId, val);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
typeBadge.textContent = node.type;
|
|
140
|
+
typeBadge.appendChild(typeEditBtn);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
input.addEventListener("blur", finish);
|
|
144
|
+
input.addEventListener("keydown", (ke) => {
|
|
145
|
+
if (ke.key === "Enter")
|
|
146
|
+
input.blur();
|
|
147
|
+
if (ke.key === "Escape") {
|
|
148
|
+
input.value = node.type;
|
|
149
|
+
input.blur();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
typeBadge.appendChild(typeEditBtn);
|
|
154
|
+
}
|
|
155
|
+
const label = document.createElement("h3");
|
|
156
|
+
label.className = "info-label";
|
|
157
|
+
label.textContent = nodeLabel(node);
|
|
158
|
+
const nodeIdEl = document.createElement("span");
|
|
159
|
+
nodeIdEl.className = "info-id";
|
|
160
|
+
nodeIdEl.textContent = node.id;
|
|
161
|
+
header.appendChild(typeBadge);
|
|
162
|
+
header.appendChild(label);
|
|
163
|
+
header.appendChild(nodeIdEl);
|
|
164
|
+
panel.appendChild(header);
|
|
165
|
+
// Properties section (editable)
|
|
166
|
+
const propKeys = Object.keys(node.properties);
|
|
167
|
+
const propSection = createSection("Properties");
|
|
168
|
+
if (propKeys.length > 0) {
|
|
169
|
+
const table = document.createElement("dl");
|
|
170
|
+
table.className = "info-props";
|
|
171
|
+
for (const key of propKeys) {
|
|
172
|
+
const dt = document.createElement("dt");
|
|
173
|
+
dt.textContent = key;
|
|
174
|
+
const dd = document.createElement("dd");
|
|
175
|
+
if (callbacks) {
|
|
176
|
+
const valueStr = formatValue(node.properties[key]);
|
|
177
|
+
const input = document.createElement("input");
|
|
178
|
+
input.type = "text";
|
|
179
|
+
input.className = "info-edit-input";
|
|
180
|
+
input.value = valueStr;
|
|
181
|
+
input.addEventListener("keydown", (e) => {
|
|
182
|
+
if (e.key === "Enter") {
|
|
183
|
+
input.blur();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
input.addEventListener("blur", () => {
|
|
187
|
+
const newVal = input.value;
|
|
188
|
+
if (newVal !== valueStr) {
|
|
189
|
+
callbacks.onUpdateNode(nodeId, { [key]: tryParseValue(newVal) });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
dd.appendChild(input);
|
|
193
|
+
// Delete property button
|
|
194
|
+
const delProp = document.createElement("button");
|
|
195
|
+
delProp.className = "info-delete-prop";
|
|
196
|
+
delProp.textContent = "\u00d7";
|
|
197
|
+
delProp.title = `Remove ${key}`;
|
|
198
|
+
delProp.addEventListener("click", () => {
|
|
199
|
+
const updated = { ...node.properties };
|
|
200
|
+
delete updated[key];
|
|
201
|
+
callbacks.onUpdateNode(nodeId, updated);
|
|
202
|
+
});
|
|
203
|
+
dd.appendChild(delProp);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
dd.appendChild(renderValue(node.properties[key]));
|
|
207
|
+
}
|
|
208
|
+
table.appendChild(dt);
|
|
209
|
+
table.appendChild(dd);
|
|
210
|
+
}
|
|
211
|
+
propSection.appendChild(table);
|
|
212
|
+
}
|
|
213
|
+
// Add property button
|
|
214
|
+
if (callbacks) {
|
|
215
|
+
const addBtn = document.createElement("button");
|
|
216
|
+
addBtn.className = "info-add-btn";
|
|
217
|
+
addBtn.textContent = "+ Add property";
|
|
218
|
+
addBtn.addEventListener("click", () => {
|
|
219
|
+
const row = document.createElement("div");
|
|
220
|
+
row.className = "info-add-row";
|
|
221
|
+
const keyInput = document.createElement("input");
|
|
222
|
+
keyInput.type = "text";
|
|
223
|
+
keyInput.className = "info-edit-input";
|
|
224
|
+
keyInput.placeholder = "key";
|
|
225
|
+
const valInput = document.createElement("input");
|
|
226
|
+
valInput.type = "text";
|
|
227
|
+
valInput.className = "info-edit-input";
|
|
228
|
+
valInput.placeholder = "value";
|
|
229
|
+
const saveBtn = document.createElement("button");
|
|
230
|
+
saveBtn.className = "info-add-save";
|
|
231
|
+
saveBtn.textContent = "Add";
|
|
232
|
+
saveBtn.addEventListener("click", () => {
|
|
233
|
+
if (keyInput.value) {
|
|
234
|
+
callbacks.onAddProperty(nodeId, keyInput.value, valInput.value);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
row.appendChild(keyInput);
|
|
238
|
+
row.appendChild(valInput);
|
|
239
|
+
row.appendChild(saveBtn);
|
|
240
|
+
propSection.appendChild(row);
|
|
241
|
+
keyInput.focus();
|
|
242
|
+
});
|
|
243
|
+
propSection.appendChild(addBtn);
|
|
244
|
+
}
|
|
245
|
+
panel.appendChild(propSection);
|
|
246
|
+
// Connections section
|
|
247
|
+
if (connectedEdges.length > 0) {
|
|
248
|
+
const section = createSection(`Connections (${connectedEdges.length})`);
|
|
249
|
+
const list = document.createElement("ul");
|
|
250
|
+
list.className = "info-connections";
|
|
251
|
+
for (const edge of connectedEdges) {
|
|
252
|
+
const isOutgoing = edge.sourceId === nodeId;
|
|
253
|
+
const otherId = isOutgoing ? edge.targetId : edge.sourceId;
|
|
254
|
+
const otherNode = data.nodes.find((n) => n.id === otherId);
|
|
255
|
+
const otherLabel = otherNode ? nodeLabel(otherNode) : otherId;
|
|
256
|
+
const li = document.createElement("li");
|
|
257
|
+
li.className = "info-connection";
|
|
258
|
+
// Make clickable if we can navigate
|
|
259
|
+
if (onNavigateToNode && otherNode) {
|
|
260
|
+
li.classList.add("info-connection-link");
|
|
261
|
+
li.addEventListener("click", (e) => {
|
|
262
|
+
// Don't navigate if clicking the delete edge button
|
|
263
|
+
if (e.target.closest(".info-delete-edge"))
|
|
264
|
+
return;
|
|
265
|
+
navigateTo(otherId);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (otherNode) {
|
|
269
|
+
const dot = document.createElement("span");
|
|
270
|
+
dot.className = "info-target-dot";
|
|
271
|
+
dot.style.backgroundColor = getColor(otherNode.type);
|
|
272
|
+
li.appendChild(dot);
|
|
273
|
+
}
|
|
274
|
+
const arrow = document.createElement("span");
|
|
275
|
+
arrow.className = "info-arrow";
|
|
276
|
+
arrow.textContent = isOutgoing ? "\u2192" : "\u2190";
|
|
277
|
+
const edgeType = document.createElement("span");
|
|
278
|
+
edgeType.className = "info-edge-type";
|
|
279
|
+
edgeType.textContent = edge.type;
|
|
280
|
+
const target = document.createElement("span");
|
|
281
|
+
target.className = "info-target";
|
|
282
|
+
target.textContent = otherLabel;
|
|
283
|
+
li.appendChild(arrow);
|
|
284
|
+
li.appendChild(edgeType);
|
|
285
|
+
li.appendChild(target);
|
|
286
|
+
// Edge properties (if any)
|
|
287
|
+
const edgePropKeys = Object.keys(edge.properties);
|
|
288
|
+
if (edgePropKeys.length > 0) {
|
|
289
|
+
const edgeProps = document.createElement("div");
|
|
290
|
+
edgeProps.className = "info-edge-props";
|
|
291
|
+
for (const key of edgePropKeys) {
|
|
292
|
+
const prop = document.createElement("span");
|
|
293
|
+
prop.className = "info-edge-prop";
|
|
294
|
+
prop.textContent = `${key}: ${formatValue(edge.properties[key])}`;
|
|
295
|
+
edgeProps.appendChild(prop);
|
|
296
|
+
}
|
|
297
|
+
li.appendChild(edgeProps);
|
|
298
|
+
}
|
|
299
|
+
// Delete edge button
|
|
300
|
+
if (callbacks) {
|
|
301
|
+
const delEdge = document.createElement("button");
|
|
302
|
+
delEdge.className = "info-delete-edge";
|
|
303
|
+
delEdge.textContent = "\u00d7";
|
|
304
|
+
delEdge.title = "Remove connection";
|
|
305
|
+
delEdge.addEventListener("click", (e) => {
|
|
306
|
+
e.stopPropagation();
|
|
307
|
+
callbacks.onDeleteEdge(edge.id);
|
|
308
|
+
});
|
|
309
|
+
li.appendChild(delEdge);
|
|
310
|
+
}
|
|
311
|
+
list.appendChild(li);
|
|
312
|
+
}
|
|
313
|
+
section.appendChild(list);
|
|
314
|
+
panel.appendChild(section);
|
|
315
|
+
}
|
|
316
|
+
// Timestamps
|
|
317
|
+
const tsSection = createSection("Timestamps");
|
|
318
|
+
const timestamps = document.createElement("dl");
|
|
319
|
+
timestamps.className = "info-props";
|
|
320
|
+
const createdDt = document.createElement("dt");
|
|
321
|
+
createdDt.textContent = "created";
|
|
322
|
+
const createdDd = document.createElement("dd");
|
|
323
|
+
createdDd.textContent = formatTimestamp(node.createdAt);
|
|
324
|
+
const updatedDt = document.createElement("dt");
|
|
325
|
+
updatedDt.textContent = "updated";
|
|
326
|
+
const updatedDd = document.createElement("dd");
|
|
327
|
+
updatedDd.textContent = formatTimestamp(node.updatedAt);
|
|
328
|
+
timestamps.appendChild(createdDt);
|
|
329
|
+
timestamps.appendChild(createdDd);
|
|
330
|
+
timestamps.appendChild(updatedDt);
|
|
331
|
+
timestamps.appendChild(updatedDd);
|
|
332
|
+
tsSection.appendChild(timestamps);
|
|
333
|
+
panel.appendChild(tsSection);
|
|
334
|
+
// Delete node button
|
|
335
|
+
if (callbacks) {
|
|
336
|
+
const deleteSection = document.createElement("div");
|
|
337
|
+
deleteSection.className = "info-section info-danger";
|
|
338
|
+
const deleteBtn = document.createElement("button");
|
|
339
|
+
deleteBtn.className = "info-delete-node";
|
|
340
|
+
deleteBtn.textContent = "Delete node";
|
|
341
|
+
deleteBtn.addEventListener("click", () => {
|
|
342
|
+
callbacks.onDeleteNode(nodeId);
|
|
343
|
+
hide();
|
|
344
|
+
});
|
|
345
|
+
deleteSection.appendChild(deleteBtn);
|
|
346
|
+
panel.appendChild(deleteSection);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function showMulti(nodeIds, data) {
|
|
350
|
+
const selectedSet = new Set(nodeIds);
|
|
351
|
+
const nodes = data.nodes.filter((n) => selectedSet.has(n.id));
|
|
352
|
+
if (nodes.length === 0)
|
|
353
|
+
return;
|
|
354
|
+
const sharedEdges = data.edges.filter((e) => selectedSet.has(e.sourceId) && selectedSet.has(e.targetId));
|
|
355
|
+
panel.innerHTML = "";
|
|
356
|
+
panel.classList.remove("hidden");
|
|
357
|
+
if (maximized)
|
|
358
|
+
panel.classList.add("info-panel-maximized");
|
|
359
|
+
// Toolbar
|
|
360
|
+
panel.appendChild(createToolbar());
|
|
361
|
+
const header = document.createElement("div");
|
|
362
|
+
header.className = "info-header";
|
|
363
|
+
const label = document.createElement("h3");
|
|
364
|
+
label.className = "info-label";
|
|
365
|
+
label.textContent = `${nodes.length} nodes selected`;
|
|
366
|
+
header.appendChild(label);
|
|
367
|
+
const badgeRow = document.createElement("div");
|
|
368
|
+
badgeRow.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px";
|
|
369
|
+
const typeCounts = new Map();
|
|
370
|
+
for (const node of nodes) {
|
|
371
|
+
typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
|
|
372
|
+
}
|
|
373
|
+
for (const [type, count] of typeCounts) {
|
|
374
|
+
const badge = document.createElement("span");
|
|
375
|
+
badge.className = "info-type-badge";
|
|
376
|
+
badge.style.backgroundColor = getColor(type);
|
|
377
|
+
badge.textContent = count > 1 ? `${type} (${count})` : type;
|
|
378
|
+
badgeRow.appendChild(badge);
|
|
379
|
+
}
|
|
380
|
+
header.appendChild(badgeRow);
|
|
381
|
+
panel.appendChild(header);
|
|
382
|
+
const nodesSection = createSection("Selected Nodes");
|
|
383
|
+
const nodesList = document.createElement("ul");
|
|
384
|
+
nodesList.className = "info-connections";
|
|
385
|
+
for (const node of nodes) {
|
|
386
|
+
const li = document.createElement("li");
|
|
387
|
+
li.className = "info-connection";
|
|
388
|
+
// Make clickable to navigate to single node
|
|
389
|
+
if (onNavigateToNode) {
|
|
390
|
+
li.classList.add("info-connection-link");
|
|
391
|
+
li.addEventListener("click", () => {
|
|
392
|
+
navigateTo(node.id);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const dot = document.createElement("span");
|
|
396
|
+
dot.className = "info-target-dot";
|
|
397
|
+
dot.style.backgroundColor = getColor(node.type);
|
|
398
|
+
const name = document.createElement("span");
|
|
399
|
+
name.className = "info-target";
|
|
400
|
+
name.textContent = nodeLabel(node);
|
|
401
|
+
const type = document.createElement("span");
|
|
402
|
+
type.className = "info-edge-type";
|
|
403
|
+
type.textContent = node.type;
|
|
404
|
+
li.appendChild(dot);
|
|
405
|
+
li.appendChild(name);
|
|
406
|
+
li.appendChild(type);
|
|
407
|
+
nodesList.appendChild(li);
|
|
408
|
+
}
|
|
409
|
+
nodesSection.appendChild(nodesList);
|
|
410
|
+
panel.appendChild(nodesSection);
|
|
411
|
+
const connSection = createSection(sharedEdges.length > 0
|
|
412
|
+
? `Connections Between Selected (${sharedEdges.length})`
|
|
413
|
+
: "Connections Between Selected");
|
|
414
|
+
if (sharedEdges.length === 0) {
|
|
415
|
+
const empty = document.createElement("p");
|
|
416
|
+
empty.style.cssText = "font-size:12px;color:var(--text-dim)";
|
|
417
|
+
empty.textContent = "No direct connections between selected nodes";
|
|
418
|
+
connSection.appendChild(empty);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
const list = document.createElement("ul");
|
|
422
|
+
list.className = "info-connections";
|
|
423
|
+
for (const edge of sharedEdges) {
|
|
424
|
+
const sourceNode = data.nodes.find((n) => n.id === edge.sourceId);
|
|
425
|
+
const targetNode = data.nodes.find((n) => n.id === edge.targetId);
|
|
426
|
+
const sourceLabel = sourceNode
|
|
427
|
+
? nodeLabel(sourceNode)
|
|
428
|
+
: edge.sourceId;
|
|
429
|
+
const targetLabel = targetNode
|
|
430
|
+
? nodeLabel(targetNode)
|
|
431
|
+
: edge.targetId;
|
|
432
|
+
const li = document.createElement("li");
|
|
433
|
+
li.className = "info-connection";
|
|
434
|
+
if (sourceNode) {
|
|
435
|
+
const dot = document.createElement("span");
|
|
436
|
+
dot.className = "info-target-dot";
|
|
437
|
+
dot.style.backgroundColor = getColor(sourceNode.type);
|
|
438
|
+
li.appendChild(dot);
|
|
439
|
+
}
|
|
440
|
+
const source = document.createElement("span");
|
|
441
|
+
source.className = "info-target";
|
|
442
|
+
source.textContent = sourceLabel;
|
|
443
|
+
const arrow = document.createElement("span");
|
|
444
|
+
arrow.className = "info-arrow";
|
|
445
|
+
arrow.textContent = "\u2192";
|
|
446
|
+
const edgeType = document.createElement("span");
|
|
447
|
+
edgeType.className = "info-edge-type";
|
|
448
|
+
edgeType.textContent = edge.type;
|
|
449
|
+
const arrow2 = document.createElement("span");
|
|
450
|
+
arrow2.className = "info-arrow";
|
|
451
|
+
arrow2.textContent = "\u2192";
|
|
452
|
+
li.appendChild(source);
|
|
453
|
+
li.appendChild(arrow);
|
|
454
|
+
li.appendChild(edgeType);
|
|
455
|
+
li.appendChild(arrow2);
|
|
456
|
+
if (targetNode) {
|
|
457
|
+
const dot2 = document.createElement("span");
|
|
458
|
+
dot2.className = "info-target-dot";
|
|
459
|
+
dot2.style.backgroundColor = getColor(targetNode.type);
|
|
460
|
+
li.appendChild(dot2);
|
|
461
|
+
}
|
|
462
|
+
const target = document.createElement("span");
|
|
463
|
+
target.className = "info-target";
|
|
464
|
+
target.textContent = targetLabel;
|
|
465
|
+
li.appendChild(target);
|
|
466
|
+
const edgePropKeys = Object.keys(edge.properties);
|
|
467
|
+
if (edgePropKeys.length > 0) {
|
|
468
|
+
const edgeProps = document.createElement("div");
|
|
469
|
+
edgeProps.className = "info-edge-props";
|
|
470
|
+
for (const key of edgePropKeys) {
|
|
471
|
+
const prop = document.createElement("span");
|
|
472
|
+
prop.className = "info-edge-prop";
|
|
473
|
+
prop.textContent = `${key}: ${formatValue(edge.properties[key])}`;
|
|
474
|
+
edgeProps.appendChild(prop);
|
|
475
|
+
}
|
|
476
|
+
li.appendChild(edgeProps);
|
|
477
|
+
}
|
|
478
|
+
list.appendChild(li);
|
|
479
|
+
}
|
|
480
|
+
connSection.appendChild(list);
|
|
481
|
+
}
|
|
482
|
+
panel.appendChild(connSection);
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
show(nodeIds, data) {
|
|
486
|
+
lastData = data;
|
|
487
|
+
// Track history for single-node views
|
|
488
|
+
if (nodeIds.length === 1 && !navigatingHistory) {
|
|
489
|
+
const nodeId = nodeIds[0];
|
|
490
|
+
// Don't push duplicate consecutive entries
|
|
491
|
+
if (history[historyIndex] !== nodeId) {
|
|
492
|
+
if (historyIndex < history.length - 1) {
|
|
493
|
+
history = history.slice(0, historyIndex + 1);
|
|
494
|
+
}
|
|
495
|
+
history.push(nodeId);
|
|
496
|
+
historyIndex = history.length - 1;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (nodeIds.length === 1) {
|
|
500
|
+
showSingle(nodeIds[0], data);
|
|
501
|
+
}
|
|
502
|
+
else if (nodeIds.length > 1) {
|
|
503
|
+
showMulti(nodeIds, data);
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
hide,
|
|
507
|
+
get visible() {
|
|
508
|
+
return !panel.classList.contains("hidden");
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function createSection(title) {
|
|
513
|
+
const section = document.createElement("div");
|
|
514
|
+
section.className = "info-section";
|
|
515
|
+
const heading = document.createElement("h4");
|
|
516
|
+
heading.className = "info-section-title";
|
|
517
|
+
heading.textContent = title;
|
|
518
|
+
section.appendChild(heading);
|
|
519
|
+
return section;
|
|
520
|
+
}
|
|
521
|
+
function renderValue(value) {
|
|
522
|
+
if (Array.isArray(value)) {
|
|
523
|
+
const container = document.createElement("div");
|
|
524
|
+
container.className = "info-array";
|
|
525
|
+
for (const item of value) {
|
|
526
|
+
const tag = document.createElement("span");
|
|
527
|
+
tag.className = "info-tag";
|
|
528
|
+
tag.textContent = String(item);
|
|
529
|
+
container.appendChild(tag);
|
|
530
|
+
}
|
|
531
|
+
return container;
|
|
532
|
+
}
|
|
533
|
+
if (value !== null && typeof value === "object") {
|
|
534
|
+
const pre = document.createElement("pre");
|
|
535
|
+
pre.className = "info-json";
|
|
536
|
+
pre.textContent = JSON.stringify(value, null, 2);
|
|
537
|
+
return pre;
|
|
538
|
+
}
|
|
539
|
+
const span = document.createElement("span");
|
|
540
|
+
span.className = "info-value";
|
|
541
|
+
span.textContent = String(value ?? "");
|
|
542
|
+
return span;
|
|
543
|
+
}
|
|
544
|
+
function formatValue(value) {
|
|
545
|
+
if (Array.isArray(value))
|
|
546
|
+
return value.map(String).join(", ");
|
|
547
|
+
if (value !== null && typeof value === "object")
|
|
548
|
+
return JSON.stringify(value);
|
|
549
|
+
return String(value ?? "");
|
|
550
|
+
}
|
|
551
|
+
/** Try to parse a string as JSON (number, boolean, array), fall back to string. */
|
|
552
|
+
function tryParseValue(str) {
|
|
553
|
+
const trimmed = str.trim();
|
|
554
|
+
if (trimmed === "true")
|
|
555
|
+
return true;
|
|
556
|
+
if (trimmed === "false")
|
|
557
|
+
return false;
|
|
558
|
+
if (trimmed !== "" && !isNaN(Number(trimmed)))
|
|
559
|
+
return Number(trimmed);
|
|
560
|
+
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) ||
|
|
561
|
+
(trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
|
562
|
+
try {
|
|
563
|
+
return JSON.parse(trimmed);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return str;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return str;
|
|
570
|
+
}
|
|
571
|
+
function formatTimestamp(iso) {
|
|
572
|
+
try {
|
|
573
|
+
const d = new Date(iso);
|
|
574
|
+
return d.toLocaleString();
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
return iso;
|
|
578
|
+
}
|
|
579
|
+
}
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OntologyData } from "backpack-ontology";
|
|
2
|
+
export interface LayoutNode {
|
|
3
|
+
id: string;
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
vx: number;
|
|
7
|
+
vy: number;
|
|
8
|
+
label: string;
|
|
9
|
+
type: string;
|
|
10
|
+
}
|
|
11
|
+
export interface LayoutEdge {
|
|
12
|
+
sourceId: string;
|
|
13
|
+
targetId: string;
|
|
14
|
+
type: string;
|
|
15
|
+
}
|
|
16
|
+
export interface LayoutState {
|
|
17
|
+
nodes: LayoutNode[];
|
|
18
|
+
edges: LayoutEdge[];
|
|
19
|
+
nodeMap: Map<string, LayoutNode>;
|
|
20
|
+
}
|
|
21
|
+
/** Create a layout state from ontology data. Nodes start in a circle. */
|
|
22
|
+
export declare function createLayout(data: OntologyData): LayoutState;
|
|
23
|
+
/** Run one tick of the force simulation. Returns new alpha. */
|
|
24
|
+
export declare function tick(state: LayoutState, alpha: number): number;
|