backpack-viewer 0.1.3 → 0.2.5
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 +33 -72
- package/bin/serve.js +86 -9
- package/dist/assets/index-BwXh5IUT.js +1 -0
- package/dist/assets/index-DQfh3jIv.css +1 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +6 -9
- package/src/api.ts +0 -13
- package/src/canvas.ts +0 -409
- package/src/colors.ts +0 -40
- package/src/info-panel.ts +0 -230
- package/src/layout.ts +0 -138
- package/src/main.ts +0 -68
- package/src/sidebar.ts +0 -80
- package/src/style.css +0 -337
- package/tsconfig.json +0 -15
- package/tsconfig.node.json +0 -13
- package/vite.config.ts +0 -75
package/src/canvas.ts
DELETED
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
import type { OntologyData } from "backpack-ontology";
|
|
2
|
-
import { createLayout, tick, type LayoutState, type LayoutNode } from "./layout";
|
|
3
|
-
import { getColor } from "./colors";
|
|
4
|
-
|
|
5
|
-
interface Camera {
|
|
6
|
-
x: number;
|
|
7
|
-
y: number;
|
|
8
|
-
scale: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const NODE_RADIUS = 20;
|
|
12
|
-
const ALPHA_MIN = 0.001;
|
|
13
|
-
|
|
14
|
-
export function initCanvas(
|
|
15
|
-
container: HTMLElement,
|
|
16
|
-
onNodeClick?: (nodeId: string | null) => void
|
|
17
|
-
) {
|
|
18
|
-
const canvas = container.querySelector("canvas") as HTMLCanvasElement;
|
|
19
|
-
const ctx = canvas.getContext("2d")!;
|
|
20
|
-
const dpr = window.devicePixelRatio || 1;
|
|
21
|
-
|
|
22
|
-
let camera: Camera = { x: 0, y: 0, scale: 1 };
|
|
23
|
-
let state: LayoutState | null = null;
|
|
24
|
-
let alpha = 1;
|
|
25
|
-
let animFrame = 0;
|
|
26
|
-
let selectedNodeId: string | null = null;
|
|
27
|
-
|
|
28
|
-
// --- Sizing ---
|
|
29
|
-
|
|
30
|
-
function resize() {
|
|
31
|
-
canvas.width = canvas.clientWidth * dpr;
|
|
32
|
-
canvas.height = canvas.clientHeight * dpr;
|
|
33
|
-
render();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const observer = new ResizeObserver(resize);
|
|
37
|
-
observer.observe(container);
|
|
38
|
-
resize();
|
|
39
|
-
|
|
40
|
-
// --- Coordinate transforms ---
|
|
41
|
-
|
|
42
|
-
function screenToWorld(sx: number, sy: number): [number, number] {
|
|
43
|
-
return [
|
|
44
|
-
sx / camera.scale + camera.x,
|
|
45
|
-
sy / camera.scale + camera.y,
|
|
46
|
-
];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// --- Hit testing ---
|
|
50
|
-
|
|
51
|
-
function nodeAtScreen(sx: number, sy: number): LayoutNode | null {
|
|
52
|
-
if (!state) return null;
|
|
53
|
-
const [wx, wy] = screenToWorld(sx, sy);
|
|
54
|
-
// Iterate in reverse so topmost (last drawn) nodes are hit first
|
|
55
|
-
for (let i = state.nodes.length - 1; i >= 0; i--) {
|
|
56
|
-
const node = state.nodes[i];
|
|
57
|
-
const dx = wx - node.x;
|
|
58
|
-
const dy = wy - node.y;
|
|
59
|
-
if (dx * dx + dy * dy <= NODE_RADIUS * NODE_RADIUS) {
|
|
60
|
-
return node;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// --- Rendering ---
|
|
67
|
-
|
|
68
|
-
function render() {
|
|
69
|
-
if (!state) {
|
|
70
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
ctx.save();
|
|
75
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
76
|
-
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
|
77
|
-
|
|
78
|
-
ctx.save();
|
|
79
|
-
ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
|
|
80
|
-
ctx.scale(camera.scale, camera.scale);
|
|
81
|
-
|
|
82
|
-
// Draw edges
|
|
83
|
-
for (const edge of state.edges) {
|
|
84
|
-
const source = state.nodeMap.get(edge.sourceId);
|
|
85
|
-
const target = state.nodeMap.get(edge.targetId);
|
|
86
|
-
if (!source || !target) continue;
|
|
87
|
-
|
|
88
|
-
const isConnected =
|
|
89
|
-
selectedNodeId !== null &&
|
|
90
|
-
(edge.sourceId === selectedNodeId || edge.targetId === selectedNodeId);
|
|
91
|
-
|
|
92
|
-
// Self-loop
|
|
93
|
-
if (edge.sourceId === edge.targetId) {
|
|
94
|
-
drawSelfLoop(source, edge.type, isConnected);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Line
|
|
99
|
-
ctx.beginPath();
|
|
100
|
-
ctx.moveTo(source.x, source.y);
|
|
101
|
-
ctx.lineTo(target.x, target.y);
|
|
102
|
-
ctx.strokeStyle = isConnected
|
|
103
|
-
? "rgba(212, 162, 127, 0.5)"
|
|
104
|
-
: "rgba(255, 255, 255, 0.08)";
|
|
105
|
-
ctx.lineWidth = isConnected ? 2.5 : 1.5;
|
|
106
|
-
ctx.stroke();
|
|
107
|
-
|
|
108
|
-
// Arrowhead
|
|
109
|
-
drawArrowhead(source.x, source.y, target.x, target.y, isConnected);
|
|
110
|
-
|
|
111
|
-
// Edge label at midpoint
|
|
112
|
-
const mx = (source.x + target.x) / 2;
|
|
113
|
-
const my = (source.y + target.y) / 2;
|
|
114
|
-
ctx.fillStyle = isConnected
|
|
115
|
-
? "rgba(212, 162, 127, 0.7)"
|
|
116
|
-
: "rgba(255, 255, 255, 0.2)";
|
|
117
|
-
ctx.font = "9px system-ui, sans-serif";
|
|
118
|
-
ctx.textAlign = "center";
|
|
119
|
-
ctx.textBaseline = "bottom";
|
|
120
|
-
ctx.fillText(edge.type, mx, my - 4);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Draw nodes
|
|
124
|
-
for (const node of state.nodes) {
|
|
125
|
-
const color = getColor(node.type);
|
|
126
|
-
const isSelected = node.id === selectedNodeId;
|
|
127
|
-
const isNeighbor =
|
|
128
|
-
selectedNodeId !== null &&
|
|
129
|
-
state.edges.some(
|
|
130
|
-
(e) =>
|
|
131
|
-
(e.sourceId === selectedNodeId && e.targetId === node.id) ||
|
|
132
|
-
(e.targetId === selectedNodeId && e.sourceId === node.id)
|
|
133
|
-
);
|
|
134
|
-
const dimmed =
|
|
135
|
-
selectedNodeId !== null && !isSelected && !isNeighbor;
|
|
136
|
-
|
|
137
|
-
// Glow for selected node
|
|
138
|
-
if (isSelected) {
|
|
139
|
-
ctx.save();
|
|
140
|
-
ctx.shadowColor = color;
|
|
141
|
-
ctx.shadowBlur = 20;
|
|
142
|
-
ctx.beginPath();
|
|
143
|
-
ctx.arc(node.x, node.y, NODE_RADIUS + 3, 0, Math.PI * 2);
|
|
144
|
-
ctx.fillStyle = color;
|
|
145
|
-
ctx.globalAlpha = 0.3;
|
|
146
|
-
ctx.fill();
|
|
147
|
-
ctx.restore();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Circle
|
|
151
|
-
ctx.beginPath();
|
|
152
|
-
ctx.arc(node.x, node.y, NODE_RADIUS, 0, Math.PI * 2);
|
|
153
|
-
ctx.fillStyle = color;
|
|
154
|
-
ctx.globalAlpha = dimmed ? 0.3 : 1;
|
|
155
|
-
ctx.fill();
|
|
156
|
-
ctx.strokeStyle = isSelected
|
|
157
|
-
? "#d4d4d4"
|
|
158
|
-
: "rgba(255, 255, 255, 0.15)";
|
|
159
|
-
ctx.lineWidth = isSelected ? 3 : 1.5;
|
|
160
|
-
ctx.stroke();
|
|
161
|
-
|
|
162
|
-
// Label below
|
|
163
|
-
const label =
|
|
164
|
-
node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
|
|
165
|
-
ctx.fillStyle = dimmed
|
|
166
|
-
? "rgba(212, 212, 212, 0.2)"
|
|
167
|
-
: "#a3a3a3";
|
|
168
|
-
ctx.font = "11px system-ui, sans-serif";
|
|
169
|
-
ctx.textAlign = "center";
|
|
170
|
-
ctx.textBaseline = "top";
|
|
171
|
-
ctx.fillText(label, node.x, node.y + NODE_RADIUS + 4);
|
|
172
|
-
|
|
173
|
-
// Type badge above
|
|
174
|
-
ctx.fillStyle = dimmed
|
|
175
|
-
? "rgba(115, 115, 115, 0.15)"
|
|
176
|
-
: "rgba(115, 115, 115, 0.5)";
|
|
177
|
-
ctx.font = "9px system-ui, sans-serif";
|
|
178
|
-
ctx.textBaseline = "bottom";
|
|
179
|
-
ctx.fillText(node.type, node.x, node.y - NODE_RADIUS - 3);
|
|
180
|
-
|
|
181
|
-
ctx.globalAlpha = 1;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
ctx.restore();
|
|
185
|
-
ctx.restore();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function drawArrowhead(
|
|
189
|
-
sx: number,
|
|
190
|
-
sy: number,
|
|
191
|
-
tx: number,
|
|
192
|
-
ty: number,
|
|
193
|
-
highlighted: boolean
|
|
194
|
-
) {
|
|
195
|
-
const angle = Math.atan2(ty - sy, tx - sx);
|
|
196
|
-
const tipX = tx - Math.cos(angle) * NODE_RADIUS;
|
|
197
|
-
const tipY = ty - Math.sin(angle) * NODE_RADIUS;
|
|
198
|
-
const size = 8;
|
|
199
|
-
|
|
200
|
-
ctx.beginPath();
|
|
201
|
-
ctx.moveTo(tipX, tipY);
|
|
202
|
-
ctx.lineTo(
|
|
203
|
-
tipX - size * Math.cos(angle - 0.4),
|
|
204
|
-
tipY - size * Math.sin(angle - 0.4)
|
|
205
|
-
);
|
|
206
|
-
ctx.lineTo(
|
|
207
|
-
tipX - size * Math.cos(angle + 0.4),
|
|
208
|
-
tipY - size * Math.sin(angle + 0.4)
|
|
209
|
-
);
|
|
210
|
-
ctx.closePath();
|
|
211
|
-
ctx.fillStyle = highlighted
|
|
212
|
-
? "rgba(212, 162, 127, 0.5)"
|
|
213
|
-
: "rgba(255, 255, 255, 0.12)";
|
|
214
|
-
ctx.fill();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function drawSelfLoop(
|
|
218
|
-
node: LayoutNode,
|
|
219
|
-
type: string,
|
|
220
|
-
highlighted: boolean
|
|
221
|
-
) {
|
|
222
|
-
const cx = node.x + NODE_RADIUS + 15;
|
|
223
|
-
const cy = node.y - NODE_RADIUS - 15;
|
|
224
|
-
ctx.beginPath();
|
|
225
|
-
ctx.arc(cx, cy, 15, 0, Math.PI * 2);
|
|
226
|
-
ctx.strokeStyle = highlighted
|
|
227
|
-
? "rgba(212, 162, 127, 0.5)"
|
|
228
|
-
: "rgba(255, 255, 255, 0.08)";
|
|
229
|
-
ctx.lineWidth = highlighted ? 2.5 : 1.5;
|
|
230
|
-
ctx.stroke();
|
|
231
|
-
|
|
232
|
-
ctx.fillStyle = highlighted
|
|
233
|
-
? "rgba(212, 162, 127, 0.7)"
|
|
234
|
-
: "rgba(255, 255, 255, 0.2)";
|
|
235
|
-
ctx.font = "9px system-ui, sans-serif";
|
|
236
|
-
ctx.textAlign = "center";
|
|
237
|
-
ctx.fillText(type, cx, cy - 18);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// --- Simulation loop ---
|
|
241
|
-
|
|
242
|
-
function simulate() {
|
|
243
|
-
if (!state || alpha < ALPHA_MIN) return;
|
|
244
|
-
alpha = tick(state, alpha);
|
|
245
|
-
render();
|
|
246
|
-
animFrame = requestAnimationFrame(simulate);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// --- Interaction: Pan + Click ---
|
|
250
|
-
|
|
251
|
-
let dragging = false;
|
|
252
|
-
let didDrag = false;
|
|
253
|
-
let lastX = 0;
|
|
254
|
-
let lastY = 0;
|
|
255
|
-
|
|
256
|
-
canvas.addEventListener("mousedown", (e) => {
|
|
257
|
-
dragging = true;
|
|
258
|
-
didDrag = false;
|
|
259
|
-
lastX = e.clientX;
|
|
260
|
-
lastY = e.clientY;
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
canvas.addEventListener("mousemove", (e) => {
|
|
264
|
-
if (!dragging) return;
|
|
265
|
-
const dx = e.clientX - lastX;
|
|
266
|
-
const dy = e.clientY - lastY;
|
|
267
|
-
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) didDrag = true;
|
|
268
|
-
camera.x -= dx / camera.scale;
|
|
269
|
-
camera.y -= dy / camera.scale;
|
|
270
|
-
lastX = e.clientX;
|
|
271
|
-
lastY = e.clientY;
|
|
272
|
-
render();
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
canvas.addEventListener("mouseup", (e) => {
|
|
276
|
-
dragging = false;
|
|
277
|
-
if (didDrag) return;
|
|
278
|
-
|
|
279
|
-
// Click — hit test for node selection
|
|
280
|
-
const rect = canvas.getBoundingClientRect();
|
|
281
|
-
const mx = e.clientX - rect.left;
|
|
282
|
-
const my = e.clientY - rect.top;
|
|
283
|
-
const hit = nodeAtScreen(mx, my);
|
|
284
|
-
|
|
285
|
-
if (hit) {
|
|
286
|
-
selectedNodeId = hit.id;
|
|
287
|
-
onNodeClick?.(hit.id);
|
|
288
|
-
} else {
|
|
289
|
-
selectedNodeId = null;
|
|
290
|
-
onNodeClick?.(null);
|
|
291
|
-
}
|
|
292
|
-
render();
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
canvas.addEventListener("mouseleave", () => {
|
|
296
|
-
dragging = false;
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// --- Interaction: Zoom (wheel + pinch) ---
|
|
300
|
-
|
|
301
|
-
canvas.addEventListener(
|
|
302
|
-
"wheel",
|
|
303
|
-
(e) => {
|
|
304
|
-
e.preventDefault();
|
|
305
|
-
|
|
306
|
-
const rect = canvas.getBoundingClientRect();
|
|
307
|
-
const mx = e.clientX - rect.left;
|
|
308
|
-
const my = e.clientY - rect.top;
|
|
309
|
-
|
|
310
|
-
const [wx, wy] = screenToWorld(mx, my);
|
|
311
|
-
|
|
312
|
-
const factor = e.ctrlKey
|
|
313
|
-
? 1 - e.deltaY * 0.01
|
|
314
|
-
: e.deltaY > 0
|
|
315
|
-
? 0.9
|
|
316
|
-
: 1.1;
|
|
317
|
-
|
|
318
|
-
camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
|
|
319
|
-
|
|
320
|
-
camera.x = wx - mx / camera.scale;
|
|
321
|
-
camera.y = wy - my / camera.scale;
|
|
322
|
-
|
|
323
|
-
render();
|
|
324
|
-
},
|
|
325
|
-
{ passive: false }
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
// --- Interaction: Touch (pinch zoom + drag) ---
|
|
329
|
-
|
|
330
|
-
let touches: Touch[] = [];
|
|
331
|
-
let initialPinchDist = 0;
|
|
332
|
-
let initialPinchScale = 1;
|
|
333
|
-
|
|
334
|
-
canvas.addEventListener("touchstart", (e) => {
|
|
335
|
-
e.preventDefault();
|
|
336
|
-
touches = Array.from(e.touches);
|
|
337
|
-
if (touches.length === 2) {
|
|
338
|
-
initialPinchDist = touchDistance(touches[0], touches[1]);
|
|
339
|
-
initialPinchScale = camera.scale;
|
|
340
|
-
} else if (touches.length === 1) {
|
|
341
|
-
lastX = touches[0].clientX;
|
|
342
|
-
lastY = touches[0].clientY;
|
|
343
|
-
}
|
|
344
|
-
}, { passive: false });
|
|
345
|
-
|
|
346
|
-
canvas.addEventListener("touchmove", (e) => {
|
|
347
|
-
e.preventDefault();
|
|
348
|
-
const current = Array.from(e.touches);
|
|
349
|
-
|
|
350
|
-
if (current.length === 2 && touches.length === 2) {
|
|
351
|
-
const dist = touchDistance(current[0], current[1]);
|
|
352
|
-
const ratio = dist / initialPinchDist;
|
|
353
|
-
camera.scale = Math.max(0.05, Math.min(10, initialPinchScale * ratio));
|
|
354
|
-
render();
|
|
355
|
-
} else if (current.length === 1) {
|
|
356
|
-
const dx = current[0].clientX - lastX;
|
|
357
|
-
const dy = current[0].clientY - lastY;
|
|
358
|
-
camera.x -= dx / camera.scale;
|
|
359
|
-
camera.y -= dy / camera.scale;
|
|
360
|
-
lastX = current[0].clientX;
|
|
361
|
-
lastY = current[0].clientY;
|
|
362
|
-
render();
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
touches = current;
|
|
366
|
-
}, { passive: false });
|
|
367
|
-
|
|
368
|
-
function touchDistance(a: Touch, b: Touch): number {
|
|
369
|
-
const dx = a.clientX - b.clientX;
|
|
370
|
-
const dy = a.clientY - b.clientY;
|
|
371
|
-
return Math.sqrt(dx * dx + dy * dy);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// --- Public API ---
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
loadGraph(data: OntologyData) {
|
|
378
|
-
cancelAnimationFrame(animFrame);
|
|
379
|
-
state = createLayout(data);
|
|
380
|
-
alpha = 1;
|
|
381
|
-
selectedNodeId = null;
|
|
382
|
-
|
|
383
|
-
// Center camera on the graph
|
|
384
|
-
camera = { x: 0, y: 0, scale: 1 };
|
|
385
|
-
if (state.nodes.length > 0) {
|
|
386
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
387
|
-
for (const n of state.nodes) {
|
|
388
|
-
if (n.x < minX) minX = n.x;
|
|
389
|
-
if (n.y < minY) minY = n.y;
|
|
390
|
-
if (n.x > maxX) maxX = n.x;
|
|
391
|
-
if (n.y > maxY) maxY = n.y;
|
|
392
|
-
}
|
|
393
|
-
const cx = (minX + maxX) / 2;
|
|
394
|
-
const cy = (minY + maxY) / 2;
|
|
395
|
-
const w = canvas.clientWidth;
|
|
396
|
-
const h = canvas.clientHeight;
|
|
397
|
-
camera.x = cx - w / 2;
|
|
398
|
-
camera.y = cy - h / 2;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
simulate();
|
|
402
|
-
},
|
|
403
|
-
|
|
404
|
-
destroy() {
|
|
405
|
-
cancelAnimationFrame(animFrame);
|
|
406
|
-
observer.disconnect();
|
|
407
|
-
},
|
|
408
|
-
};
|
|
409
|
-
}
|
package/src/colors.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic type → color mapping.
|
|
3
|
-
* Earth-tone accent palette on a neutral gray UI.
|
|
4
|
-
* These are the only warm colors in the interface —
|
|
5
|
-
* everything else is grayscale.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const PALETTE = [
|
|
9
|
-
"#d4a27f", // warm tan
|
|
10
|
-
"#c17856", // terracotta
|
|
11
|
-
"#b07a5e", // sienna
|
|
12
|
-
"#d4956b", // burnt amber
|
|
13
|
-
"#a67c5a", // walnut
|
|
14
|
-
"#cc9e7c", // copper
|
|
15
|
-
"#c4866a", // clay
|
|
16
|
-
"#cb8e6c", // apricot
|
|
17
|
-
"#b8956e", // wheat
|
|
18
|
-
"#a88a70", // driftwood
|
|
19
|
-
"#d9b08c", // caramel
|
|
20
|
-
"#c4a882", // sand
|
|
21
|
-
"#e8b898", // peach
|
|
22
|
-
"#b5927a", // dusty rose
|
|
23
|
-
"#a8886e", // muted brown
|
|
24
|
-
"#d1a990", // blush tan
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
const cache = new Map<string, string>();
|
|
28
|
-
|
|
29
|
-
export function getColor(type: string): string {
|
|
30
|
-
const cached = cache.get(type);
|
|
31
|
-
if (cached) return cached;
|
|
32
|
-
|
|
33
|
-
let hash = 0;
|
|
34
|
-
for (let i = 0; i < type.length; i++) {
|
|
35
|
-
hash = ((hash << 5) - hash + type.charCodeAt(i)) | 0;
|
|
36
|
-
}
|
|
37
|
-
const color = PALETTE[Math.abs(hash) % PALETTE.length];
|
|
38
|
-
cache.set(type, color);
|
|
39
|
-
return color;
|
|
40
|
-
}
|
package/src/info-panel.ts
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import type { Node, Edge, OntologyData } from "backpack-ontology";
|
|
2
|
-
import { getColor } from "./colors";
|
|
3
|
-
|
|
4
|
-
/** Extract a display label from a node — first string property, fallback to id. */
|
|
5
|
-
function nodeLabel(node: Node): string {
|
|
6
|
-
for (const value of Object.values(node.properties)) {
|
|
7
|
-
if (typeof value === "string") return value;
|
|
8
|
-
}
|
|
9
|
-
return node.id;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function initInfoPanel(container: HTMLElement) {
|
|
13
|
-
const panel = document.createElement("div");
|
|
14
|
-
panel.id = "info-panel";
|
|
15
|
-
panel.className = "info-panel hidden";
|
|
16
|
-
container.appendChild(panel);
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
show(nodeId: string, data: OntologyData) {
|
|
20
|
-
const node = data.nodes.find((n) => n.id === nodeId);
|
|
21
|
-
if (!node) return;
|
|
22
|
-
|
|
23
|
-
const connectedEdges = data.edges.filter(
|
|
24
|
-
(e) => e.sourceId === nodeId || e.targetId === nodeId
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
panel.innerHTML = "";
|
|
28
|
-
panel.classList.remove("hidden");
|
|
29
|
-
|
|
30
|
-
// Close button
|
|
31
|
-
const closeBtn = document.createElement("button");
|
|
32
|
-
closeBtn.className = "info-close";
|
|
33
|
-
closeBtn.textContent = "\u00d7";
|
|
34
|
-
closeBtn.addEventListener("click", () => this.hide());
|
|
35
|
-
panel.appendChild(closeBtn);
|
|
36
|
-
|
|
37
|
-
// Header: type badge + label
|
|
38
|
-
const header = document.createElement("div");
|
|
39
|
-
header.className = "info-header";
|
|
40
|
-
|
|
41
|
-
const typeBadge = document.createElement("span");
|
|
42
|
-
typeBadge.className = "info-type-badge";
|
|
43
|
-
typeBadge.textContent = node.type;
|
|
44
|
-
typeBadge.style.backgroundColor = getColor(node.type);
|
|
45
|
-
|
|
46
|
-
const label = document.createElement("h3");
|
|
47
|
-
label.className = "info-label";
|
|
48
|
-
label.textContent = nodeLabel(node);
|
|
49
|
-
|
|
50
|
-
const nodeIdEl = document.createElement("span");
|
|
51
|
-
nodeIdEl.className = "info-id";
|
|
52
|
-
nodeIdEl.textContent = node.id;
|
|
53
|
-
|
|
54
|
-
header.appendChild(typeBadge);
|
|
55
|
-
header.appendChild(label);
|
|
56
|
-
header.appendChild(nodeIdEl);
|
|
57
|
-
panel.appendChild(header);
|
|
58
|
-
|
|
59
|
-
// Properties section
|
|
60
|
-
const propKeys = Object.keys(node.properties);
|
|
61
|
-
if (propKeys.length > 0) {
|
|
62
|
-
const section = createSection("Properties");
|
|
63
|
-
const table = document.createElement("dl");
|
|
64
|
-
table.className = "info-props";
|
|
65
|
-
|
|
66
|
-
for (const key of propKeys) {
|
|
67
|
-
const dt = document.createElement("dt");
|
|
68
|
-
dt.textContent = key;
|
|
69
|
-
|
|
70
|
-
const dd = document.createElement("dd");
|
|
71
|
-
dd.appendChild(renderValue(node.properties[key]));
|
|
72
|
-
|
|
73
|
-
table.appendChild(dt);
|
|
74
|
-
table.appendChild(dd);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
section.appendChild(table);
|
|
78
|
-
panel.appendChild(section);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Connections section
|
|
82
|
-
if (connectedEdges.length > 0) {
|
|
83
|
-
const section = createSection(
|
|
84
|
-
`Connections (${connectedEdges.length})`
|
|
85
|
-
);
|
|
86
|
-
const list = document.createElement("ul");
|
|
87
|
-
list.className = "info-connections";
|
|
88
|
-
|
|
89
|
-
for (const edge of connectedEdges) {
|
|
90
|
-
const isOutgoing = edge.sourceId === nodeId;
|
|
91
|
-
const otherId = isOutgoing ? edge.targetId : edge.sourceId;
|
|
92
|
-
const otherNode = data.nodes.find((n) => n.id === otherId);
|
|
93
|
-
const otherLabel = otherNode ? nodeLabel(otherNode) : otherId;
|
|
94
|
-
|
|
95
|
-
const li = document.createElement("li");
|
|
96
|
-
li.className = "info-connection";
|
|
97
|
-
|
|
98
|
-
const arrow = document.createElement("span");
|
|
99
|
-
arrow.className = "info-arrow";
|
|
100
|
-
arrow.textContent = isOutgoing ? "\u2192" : "\u2190";
|
|
101
|
-
|
|
102
|
-
const edgeType = document.createElement("span");
|
|
103
|
-
edgeType.className = "info-edge-type";
|
|
104
|
-
edgeType.textContent = edge.type;
|
|
105
|
-
|
|
106
|
-
const target = document.createElement("span");
|
|
107
|
-
target.className = "info-target";
|
|
108
|
-
target.textContent = otherLabel;
|
|
109
|
-
|
|
110
|
-
if (otherNode) {
|
|
111
|
-
const dot = document.createElement("span");
|
|
112
|
-
dot.className = "info-target-dot";
|
|
113
|
-
dot.style.backgroundColor = getColor(otherNode.type);
|
|
114
|
-
li.appendChild(dot);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
li.appendChild(arrow);
|
|
118
|
-
li.appendChild(edgeType);
|
|
119
|
-
li.appendChild(target);
|
|
120
|
-
|
|
121
|
-
// Edge properties (if any)
|
|
122
|
-
const edgePropKeys = Object.keys(edge.properties);
|
|
123
|
-
if (edgePropKeys.length > 0) {
|
|
124
|
-
const edgeProps = document.createElement("div");
|
|
125
|
-
edgeProps.className = "info-edge-props";
|
|
126
|
-
for (const key of edgePropKeys) {
|
|
127
|
-
const prop = document.createElement("span");
|
|
128
|
-
prop.className = "info-edge-prop";
|
|
129
|
-
prop.textContent = `${key}: ${formatValue(edge.properties[key])}`;
|
|
130
|
-
edgeProps.appendChild(prop);
|
|
131
|
-
}
|
|
132
|
-
li.appendChild(edgeProps);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
list.appendChild(li);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
section.appendChild(list);
|
|
139
|
-
panel.appendChild(section);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Timestamps
|
|
143
|
-
const section = createSection("Timestamps");
|
|
144
|
-
const timestamps = document.createElement("dl");
|
|
145
|
-
timestamps.className = "info-props";
|
|
146
|
-
|
|
147
|
-
const createdDt = document.createElement("dt");
|
|
148
|
-
createdDt.textContent = "created";
|
|
149
|
-
const createdDd = document.createElement("dd");
|
|
150
|
-
createdDd.textContent = formatTimestamp(node.createdAt);
|
|
151
|
-
|
|
152
|
-
const updatedDt = document.createElement("dt");
|
|
153
|
-
updatedDt.textContent = "updated";
|
|
154
|
-
const updatedDd = document.createElement("dd");
|
|
155
|
-
updatedDd.textContent = formatTimestamp(node.updatedAt);
|
|
156
|
-
|
|
157
|
-
timestamps.appendChild(createdDt);
|
|
158
|
-
timestamps.appendChild(createdDd);
|
|
159
|
-
timestamps.appendChild(updatedDt);
|
|
160
|
-
timestamps.appendChild(updatedDd);
|
|
161
|
-
section.appendChild(timestamps);
|
|
162
|
-
panel.appendChild(section);
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
hide() {
|
|
166
|
-
panel.classList.add("hidden");
|
|
167
|
-
panel.innerHTML = "";
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
get visible() {
|
|
171
|
-
return !panel.classList.contains("hidden");
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function createSection(title: string): HTMLElement {
|
|
177
|
-
const section = document.createElement("div");
|
|
178
|
-
section.className = "info-section";
|
|
179
|
-
|
|
180
|
-
const heading = document.createElement("h4");
|
|
181
|
-
heading.className = "info-section-title";
|
|
182
|
-
heading.textContent = title;
|
|
183
|
-
section.appendChild(heading);
|
|
184
|
-
|
|
185
|
-
return section;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Render any property value into a DOM element. Handles strings, arrays, numbers, objects. */
|
|
189
|
-
function renderValue(value: unknown): HTMLElement {
|
|
190
|
-
if (Array.isArray(value)) {
|
|
191
|
-
const container = document.createElement("div");
|
|
192
|
-
container.className = "info-array";
|
|
193
|
-
for (const item of value) {
|
|
194
|
-
const tag = document.createElement("span");
|
|
195
|
-
tag.className = "info-tag";
|
|
196
|
-
tag.textContent = String(item);
|
|
197
|
-
container.appendChild(tag);
|
|
198
|
-
}
|
|
199
|
-
return container;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (value !== null && typeof value === "object") {
|
|
203
|
-
const pre = document.createElement("pre");
|
|
204
|
-
pre.className = "info-json";
|
|
205
|
-
pre.textContent = JSON.stringify(value, null, 2);
|
|
206
|
-
return pre;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const span = document.createElement("span");
|
|
210
|
-
span.className = "info-value";
|
|
211
|
-
span.textContent = String(value ?? "");
|
|
212
|
-
return span;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Format a value for inline display. */
|
|
216
|
-
function formatValue(value: unknown): string {
|
|
217
|
-
if (Array.isArray(value)) return value.map(String).join(", ");
|
|
218
|
-
if (value !== null && typeof value === "object")
|
|
219
|
-
return JSON.stringify(value);
|
|
220
|
-
return String(value ?? "");
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function formatTimestamp(iso: string): string {
|
|
224
|
-
try {
|
|
225
|
-
const d = new Date(iso);
|
|
226
|
-
return d.toLocaleString();
|
|
227
|
-
} catch {
|
|
228
|
-
return iso;
|
|
229
|
-
}
|
|
230
|
-
}
|