@sentropic/design-system-vue 0.1.0 → 0.3.0
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/BackToTop.d.ts +79 -0
- package/dist/BackToTop.d.ts.map +1 -0
- package/dist/BackToTop.js +72 -0
- package/dist/BackToTop.js.map +1 -0
- package/dist/DisplaySettings.d.ts +90 -0
- package/dist/DisplaySettings.d.ts.map +1 -0
- package/dist/DisplaySettings.js +91 -0
- package/dist/DisplaySettings.js.map +1 -0
- package/dist/ForceGraph.d.ts +142 -13
- package/dist/ForceGraph.d.ts.map +1 -1
- package/dist/ForceGraph.js +574 -36
- package/dist/ForceGraph.js.map +1 -1
- package/dist/GraphLegend.d.ts +42 -0
- package/dist/GraphLegend.d.ts.map +1 -0
- package/dist/GraphLegend.js +71 -0
- package/dist/GraphLegend.js.map +1 -0
- package/dist/MediaContent.d.ts +130 -0
- package/dist/MediaContent.d.ts.map +1 -0
- package/dist/MediaContent.js +53 -0
- package/dist/MediaContent.js.map +1 -0
- package/dist/Notification.d.ts +71 -0
- package/dist/Notification.d.ts.map +1 -0
- package/dist/Notification.js +43 -0
- package/dist/Notification.js.map +1 -0
- package/dist/TableOfContents.d.ts +53 -0
- package/dist/TableOfContents.d.ts.map +1 -0
- package/dist/TableOfContents.js +39 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Transcription.d.ts +75 -0
- package/dist/Transcription.d.ts.map +1 -0
- package/dist/Transcription.js +59 -0
- package/dist/Transcription.js.map +1 -0
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +415 -3
- package/package.json +1 -1
package/dist/ForceGraph.js
CHANGED
|
@@ -1,61 +1,599 @@
|
|
|
1
|
-
import { defineComponent, h } from "vue";
|
|
1
|
+
import { defineComponent, h, ref, computed, onMounted, onUnmounted, watch, } from "vue";
|
|
2
2
|
import { classNames } from "./classNames.js";
|
|
3
|
-
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// SVG path helpers for the various node shapes.
|
|
5
|
+
// All shapes are centered at (0,0) and sized to inscribe within radius r.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export function nodeShapePath(shape, r) {
|
|
8
|
+
const s = shape ?? "dot";
|
|
9
|
+
if (s === "dot" || s === "circle")
|
|
10
|
+
return null; // use <circle>
|
|
11
|
+
if (s === "diamond") {
|
|
12
|
+
return `M 0 ${-r} L ${r} 0 L 0 ${r} L ${-r} 0 Z`;
|
|
13
|
+
}
|
|
14
|
+
if (s === "star") {
|
|
15
|
+
const outer = r;
|
|
16
|
+
const inner = r * 0.42;
|
|
17
|
+
const pts = [];
|
|
18
|
+
for (let i = 0; i < 10; i++) {
|
|
19
|
+
const angle = (i * Math.PI) / 5 - Math.PI / 2;
|
|
20
|
+
const rad = i % 2 === 0 ? outer : inner;
|
|
21
|
+
pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
|
|
22
|
+
}
|
|
23
|
+
return `M ${pts.join(" L ")} Z`;
|
|
24
|
+
}
|
|
25
|
+
if (s === "hexagon") {
|
|
26
|
+
const pts = [];
|
|
27
|
+
for (let i = 0; i < 6; i++) {
|
|
28
|
+
const angle = (i * Math.PI) / 3 - Math.PI / 6;
|
|
29
|
+
pts.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
|
|
30
|
+
}
|
|
31
|
+
return `M ${pts.join(" L ")} Z`;
|
|
32
|
+
}
|
|
33
|
+
if (s === "box" || s === "square") {
|
|
34
|
+
const hh = r * 0.85;
|
|
35
|
+
return `M ${-hh} ${-hh} L ${hh} ${-hh} L ${hh} ${hh} L ${-hh} ${hh} Z`;
|
|
36
|
+
}
|
|
37
|
+
if (s === "triangle") {
|
|
38
|
+
const hh = r * 1.1;
|
|
39
|
+
return `M 0 ${-hh} L ${hh * 0.9} ${hh * 0.6} L ${-hh * 0.9} ${hh * 0.6} Z`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const TONES = [
|
|
4
44
|
"category1", "category2", "category3", "category4",
|
|
5
45
|
"category5", "category6", "category7", "category8",
|
|
6
46
|
];
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Tone assignment: explicit tone wins, else stable per-group, else per-index.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
function buildToneMap(ns) {
|
|
51
|
+
const groups = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const n of ns) {
|
|
54
|
+
if (n.group === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
if (seen.has(n.group))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(n.group);
|
|
59
|
+
groups.push(n.group);
|
|
60
|
+
}
|
|
61
|
+
const groupTone = new Map();
|
|
62
|
+
groups.forEach((g, i) => groupTone.set(g, TONES[i % TONES.length]));
|
|
63
|
+
const map = new Map();
|
|
64
|
+
ns.forEach((n, i) => {
|
|
65
|
+
if (n.tone)
|
|
66
|
+
map.set(n.id, n.tone);
|
|
67
|
+
else if (n.group !== undefined && groupTone.has(n.group))
|
|
68
|
+
map.set(n.id, groupTone.get(n.group));
|
|
69
|
+
else
|
|
70
|
+
map.set(n.id, TONES[i % TONES.length]);
|
|
71
|
+
});
|
|
72
|
+
return map;
|
|
73
|
+
}
|
|
74
|
+
function mulberry32(seed) {
|
|
75
|
+
let a = seed >>> 0;
|
|
76
|
+
return () => {
|
|
77
|
+
a |= 0;
|
|
78
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
79
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
80
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
81
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function runSimulation(ns, es, w, h, ticks, nodeRadius) {
|
|
85
|
+
const cx = w / 2;
|
|
86
|
+
const cy = h / 2;
|
|
87
|
+
const rand = mulberry32(ns.length * 2654435761 + es.length);
|
|
88
|
+
const idIndex = new Map();
|
|
89
|
+
const sim = ns.map((n, i) => {
|
|
90
|
+
idIndex.set(n.id, i);
|
|
91
|
+
const fixed = typeof n.fx === "number" && typeof n.fy === "number";
|
|
92
|
+
// Seed on a loose ring so the first ticks fan the graph out predictably.
|
|
93
|
+
const angle = (i / Math.max(ns.length, 1)) * Math.PI * 2;
|
|
94
|
+
const r = Math.min(w, h) * 0.3 * (0.5 + rand() * 0.5);
|
|
95
|
+
return {
|
|
96
|
+
id: n.id,
|
|
97
|
+
x: fixed ? n.fx : cx + Math.cos(angle) * r,
|
|
98
|
+
y: fixed ? n.fy : cy + Math.sin(angle) * r,
|
|
99
|
+
vx: 0,
|
|
100
|
+
vy: 0,
|
|
101
|
+
fixed,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
const links = es
|
|
105
|
+
.map((e) => ({ s: idIndex.get(e.source), t: idIndex.get(e.target) }))
|
|
106
|
+
.filter((l) => l.s !== undefined && l.t !== undefined);
|
|
107
|
+
const area = w * h;
|
|
108
|
+
const k = Math.sqrt(area / Math.max(ns.length, 1)); // ideal node distance
|
|
109
|
+
const repulsion = k * k * 0.9;
|
|
110
|
+
const restLength = k * 0.8;
|
|
111
|
+
const springK = 0.04;
|
|
112
|
+
const gravity = 0.012;
|
|
113
|
+
const damping = 0.85;
|
|
114
|
+
let temperature = Math.min(w, h) * 0.08;
|
|
115
|
+
const cooling = ticks > 0 ? Math.pow(0.02, 1 / ticks) : 0.95;
|
|
116
|
+
for (let step = 0; step < ticks; step++) {
|
|
117
|
+
// Repulsion between all node pairs.
|
|
118
|
+
for (let i = 0; i < sim.length; i++) {
|
|
119
|
+
for (let j = i + 1; j < sim.length; j++) {
|
|
120
|
+
let dx = sim[i].x - sim[j].x;
|
|
121
|
+
let dy = sim[i].y - sim[j].y;
|
|
122
|
+
let dist2 = dx * dx + dy * dy;
|
|
123
|
+
if (dist2 < 0.01) {
|
|
124
|
+
dx = (rand() - 0.5) * 0.1;
|
|
125
|
+
dy = (rand() - 0.5) * 0.1;
|
|
126
|
+
dist2 = dx * dx + dy * dy + 0.01;
|
|
127
|
+
}
|
|
128
|
+
const dist = Math.sqrt(dist2);
|
|
129
|
+
const force = repulsion / dist2;
|
|
130
|
+
const fx = (dx / dist) * force;
|
|
131
|
+
const fy = (dy / dist) * force;
|
|
132
|
+
sim[i].vx += fx;
|
|
133
|
+
sim[i].vy += fy;
|
|
134
|
+
sim[j].vx -= fx;
|
|
135
|
+
sim[j].vy -= fy;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Spring attraction along links.
|
|
139
|
+
for (const l of links) {
|
|
140
|
+
const a = sim[l.s];
|
|
141
|
+
const b = sim[l.t];
|
|
142
|
+
const dx = b.x - a.x;
|
|
143
|
+
const dy = b.y - a.y;
|
|
144
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
145
|
+
const force = (dist - restLength) * springK;
|
|
146
|
+
const fx = (dx / dist) * force;
|
|
147
|
+
const fy = (dy / dist) * force;
|
|
148
|
+
a.vx += fx;
|
|
149
|
+
a.vy += fy;
|
|
150
|
+
b.vx -= fx;
|
|
151
|
+
b.vy -= fy;
|
|
152
|
+
}
|
|
153
|
+
// Gravity toward centre + integrate with capped, cooling step.
|
|
154
|
+
for (const node of sim) {
|
|
155
|
+
if (node.fixed) {
|
|
156
|
+
node.vx = 0;
|
|
157
|
+
node.vy = 0;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
node.vx += (cx - node.x) * gravity;
|
|
161
|
+
node.vy += (cy - node.y) * gravity;
|
|
162
|
+
node.vx *= damping;
|
|
163
|
+
node.vy *= damping;
|
|
164
|
+
const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
|
|
165
|
+
if (speed > temperature) {
|
|
166
|
+
node.vx = (node.vx / speed) * temperature;
|
|
167
|
+
node.vy = (node.vy / speed) * temperature;
|
|
168
|
+
}
|
|
169
|
+
node.x += node.vx;
|
|
170
|
+
node.y += node.vy;
|
|
171
|
+
// Keep inside a padded viewport.
|
|
172
|
+
node.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, node.x));
|
|
173
|
+
node.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, node.y));
|
|
174
|
+
}
|
|
175
|
+
temperature *= cooling;
|
|
176
|
+
}
|
|
177
|
+
const out = new Map();
|
|
178
|
+
for (const node of sim)
|
|
179
|
+
out.set(node.id, { x: node.x, y: node.y });
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
7
182
|
export const ForceGraph = defineComponent({
|
|
8
183
|
name: "ForceGraph",
|
|
9
184
|
props: {
|
|
10
185
|
nodes: { type: Array, required: true },
|
|
11
186
|
edges: { type: Array, required: true },
|
|
12
187
|
label: { type: String, default: "Force graph" },
|
|
188
|
+
width: { type: Number, default: 480 },
|
|
189
|
+
height: { type: Number, default: 360 },
|
|
190
|
+
nodeRadius: { type: Number, default: 7 },
|
|
191
|
+
showLabels: { type: Boolean, default: true },
|
|
192
|
+
iterations: { type: Number, default: 300 },
|
|
13
193
|
selectedIds: { type: Array, default: () => [] },
|
|
14
194
|
focusId: { type: String, default: null },
|
|
195
|
+
legend: { type: Array, default: undefined },
|
|
196
|
+
onSelect: { type: Function, default: undefined },
|
|
197
|
+
onOpenEntity: { type: Function, default: undefined },
|
|
198
|
+
onEdgeHover: { type: Function, default: undefined },
|
|
15
199
|
class: { type: String, default: undefined },
|
|
16
200
|
},
|
|
17
|
-
emits:
|
|
201
|
+
emits: {
|
|
202
|
+
select: (_id) => true,
|
|
203
|
+
openEntity: (_id) => true,
|
|
204
|
+
edgeHover: (_edge) => true,
|
|
205
|
+
},
|
|
18
206
|
setup(props, { emit, attrs }) {
|
|
207
|
+
// SSR-safe reduced-motion check (window may be undefined during SSR/tests).
|
|
208
|
+
const prefersReducedMotion = typeof window !== "undefined" &&
|
|
209
|
+
typeof window.matchMedia === "function" &&
|
|
210
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
211
|
+
// ---- Live layout state, animated via requestAnimationFrame ----
|
|
212
|
+
// The layout map is reactive so the render reflects each cooling frame.
|
|
213
|
+
// Seeded synchronously so the very first render already has positions
|
|
214
|
+
// (keeps SSR/tests stable — the static frame is the settled target under
|
|
215
|
+
// reduced motion, otherwise a warmed-up frame the rAF tail eases from).
|
|
216
|
+
const layout = ref(new Map());
|
|
217
|
+
let rafId = null;
|
|
218
|
+
// Number of synchronous warmup ticks. Under reduced motion we settle the
|
|
219
|
+
// layout fully (the full iteration count); otherwise we run the bulk of the
|
|
220
|
+
// cooling synchronously and animate the remaining tail.
|
|
221
|
+
function warmupTicks(ticks) {
|
|
222
|
+
return prefersReducedMotion ? ticks : Math.max(1, Math.floor(ticks * 0.6));
|
|
223
|
+
}
|
|
224
|
+
// Synchronous part: populate `layout` immediately. Called once at setup and
|
|
225
|
+
// again whenever inputs change.
|
|
226
|
+
function warmupLayout() {
|
|
227
|
+
const ticks = Math.max(1, Math.round(props.iterations));
|
|
228
|
+
layout.value = runSimulation(props.nodes, props.edges, props.width, props.height, warmupTicks(ticks), props.nodeRadius);
|
|
229
|
+
}
|
|
230
|
+
// Run a synchronous warmup then animate the remaining cooling unless
|
|
231
|
+
// reduced motion is requested, in which case we settle fully and paint
|
|
232
|
+
// a single static frame (no rAF loop, no jitter).
|
|
233
|
+
function startLayout() {
|
|
234
|
+
if (rafId !== null) {
|
|
235
|
+
cancelAnimationFrame(rafId);
|
|
236
|
+
rafId = null;
|
|
237
|
+
}
|
|
238
|
+
const ticks = Math.max(1, Math.round(props.iterations));
|
|
239
|
+
// Settled / warmed-up synchronous frame.
|
|
240
|
+
warmupLayout();
|
|
241
|
+
if (prefersReducedMotion || typeof requestAnimationFrame !== "function") {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Animated cooling: recompute at increasing tick counts toward `ticks`.
|
|
245
|
+
const warmup = warmupTicks(ticks);
|
|
246
|
+
let current = warmup;
|
|
247
|
+
const stride = Math.max(1, Math.ceil((ticks - warmup) / 30));
|
|
248
|
+
const tick = () => {
|
|
249
|
+
current = Math.min(ticks, current + stride);
|
|
250
|
+
layout.value = runSimulation(props.nodes, props.edges, props.width, props.height, current, props.nodeRadius);
|
|
251
|
+
if (current < ticks) {
|
|
252
|
+
rafId = requestAnimationFrame(tick);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
rafId = null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
rafId = requestAnimationFrame(tick);
|
|
259
|
+
}
|
|
260
|
+
// Seed the layout synchronously so the initial render is positioned.
|
|
261
|
+
warmupLayout();
|
|
262
|
+
const toneMap = computed(() => buildToneMap(props.nodes));
|
|
263
|
+
const positionedNodes = computed(() => props.nodes.map((n, i) => {
|
|
264
|
+
const p = layout.value.get(n.id) ?? { x: props.width / 2, y: props.height / 2 };
|
|
265
|
+
const r = props.nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25));
|
|
266
|
+
const shapePath = nodeShapePath(n.shape, r);
|
|
267
|
+
return {
|
|
268
|
+
node: n,
|
|
269
|
+
i,
|
|
270
|
+
x: p.x,
|
|
271
|
+
y: p.y,
|
|
272
|
+
r,
|
|
273
|
+
tone: toneMap.value.get(n.id) ?? "category1",
|
|
274
|
+
title: n.label ?? n.id,
|
|
275
|
+
shapePath,
|
|
276
|
+
};
|
|
277
|
+
}));
|
|
278
|
+
const positionedEdges = computed(() => {
|
|
279
|
+
const nodeById = new Map(props.nodes.map((n) => [n.id, n]));
|
|
280
|
+
return props.edges
|
|
281
|
+
.map((e, i) => {
|
|
282
|
+
const a = layout.value.get(e.source);
|
|
283
|
+
const b = layout.value.get(e.target);
|
|
284
|
+
if (!a || !b)
|
|
285
|
+
return null;
|
|
286
|
+
const srcNode = nodeById.get(e.source);
|
|
287
|
+
const tgtNode = nodeById.get(e.target);
|
|
288
|
+
return {
|
|
289
|
+
edge: e,
|
|
290
|
+
i,
|
|
291
|
+
x1: a.x,
|
|
292
|
+
y1: a.y,
|
|
293
|
+
x2: b.x,
|
|
294
|
+
y2: b.y,
|
|
295
|
+
srcLabel: srcNode?.label ?? e.source,
|
|
296
|
+
tgtLabel: tgtNode?.label ?? e.target,
|
|
297
|
+
};
|
|
298
|
+
})
|
|
299
|
+
.filter((e) => e !== null);
|
|
300
|
+
});
|
|
301
|
+
const hoveredNodeIndex = ref(null);
|
|
302
|
+
const hoveredEdgeIndex = ref(null);
|
|
303
|
+
const selectedSet = computed(() => new Set(props.selectedIds ?? []));
|
|
304
|
+
function fireSelect(id) {
|
|
305
|
+
props.onSelect?.(id);
|
|
306
|
+
emit("select", id);
|
|
307
|
+
}
|
|
308
|
+
function fireOpenEntity(id) {
|
|
309
|
+
props.onOpenEntity?.(id);
|
|
310
|
+
emit("openEntity", id);
|
|
311
|
+
}
|
|
312
|
+
function fireEdgeHover(edge) {
|
|
313
|
+
props.onEdgeHover?.(edge);
|
|
314
|
+
emit("edgeHover", edge);
|
|
315
|
+
}
|
|
316
|
+
// Keyboard handler for a node: Space/Enter → select, Enter → openEntity.
|
|
317
|
+
function handleNodeKeydown(id, e) {
|
|
318
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
fireSelect(id);
|
|
321
|
+
}
|
|
322
|
+
if (e.key === "Enter") {
|
|
323
|
+
fireOpenEntity(id);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
// Zoom + pan state.
|
|
328
|
+
// vbW = width / zoomScale, vbH = height / zoomScale
|
|
329
|
+
// panX / panY = pan offset in SVG coordinate space
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
const zoomScale = ref(1);
|
|
332
|
+
const panX = ref(0);
|
|
333
|
+
const panY = ref(0);
|
|
334
|
+
const isPanning = ref(false);
|
|
335
|
+
let panStart = { x: 0, y: 0, panX: 0, panY: 0 };
|
|
336
|
+
const svgEl = ref(null);
|
|
337
|
+
const vbW = computed(() => props.width / zoomScale.value);
|
|
338
|
+
const vbH = computed(() => props.height / zoomScale.value);
|
|
339
|
+
const vbX = computed(() => panX.value);
|
|
340
|
+
const vbY = computed(() => panY.value);
|
|
341
|
+
function resetView() {
|
|
342
|
+
zoomScale.value = 1;
|
|
343
|
+
panX.value = 0;
|
|
344
|
+
panY.value = 0;
|
|
345
|
+
}
|
|
346
|
+
function handleWheel(ev) {
|
|
347
|
+
if (prefersReducedMotion)
|
|
348
|
+
return;
|
|
349
|
+
ev.preventDefault();
|
|
350
|
+
// Zoom factor: ~10% per step.
|
|
351
|
+
const factor = ev.deltaY > 0 ? 0.9 : 1.1;
|
|
352
|
+
// Clamp zoom: 0.2x – 8x.
|
|
353
|
+
const newScale = Math.min(Math.max(zoomScale.value * factor, 0.2), 8);
|
|
354
|
+
// Anchor zoom around the cursor position in SVG coords.
|
|
355
|
+
if (svgEl.value) {
|
|
356
|
+
const rect = svgEl.value.getBoundingClientRect();
|
|
357
|
+
const cursorSvgX = panX.value + ((ev.clientX - rect.left) / rect.width) * (props.width / zoomScale.value);
|
|
358
|
+
const cursorSvgY = panY.value + ((ev.clientY - rect.top) / rect.height) * (props.height / zoomScale.value);
|
|
359
|
+
const newVbW = props.width / newScale;
|
|
360
|
+
const newVbH = props.height / newScale;
|
|
361
|
+
const ratioX = (cursorSvgX - panX.value) / (props.width / zoomScale.value);
|
|
362
|
+
const ratioY = (cursorSvgY - panY.value) / (props.height / zoomScale.value);
|
|
363
|
+
panX.value = cursorSvgX - ratioX * newVbW;
|
|
364
|
+
panY.value = cursorSvgY - ratioY * newVbH;
|
|
365
|
+
}
|
|
366
|
+
zoomScale.value = newScale;
|
|
367
|
+
}
|
|
368
|
+
function handleBgMouseDown(ev) {
|
|
369
|
+
// Only start pan when clicking the background (not a node/edge element).
|
|
370
|
+
if (ev.target.closest(".st-forceGraph__node"))
|
|
371
|
+
return;
|
|
372
|
+
if (prefersReducedMotion)
|
|
373
|
+
return;
|
|
374
|
+
isPanning.value = true;
|
|
375
|
+
panStart = { x: ev.clientX, y: ev.clientY, panX: panX.value, panY: panY.value };
|
|
376
|
+
}
|
|
377
|
+
function handleMouseMove(ev) {
|
|
378
|
+
if (!isPanning.value || !svgEl.value)
|
|
379
|
+
return;
|
|
380
|
+
const rect = svgEl.value.getBoundingClientRect();
|
|
381
|
+
const dx = ((ev.clientX - panStart.x) / rect.width) * vbW.value;
|
|
382
|
+
const dy = ((ev.clientY - panStart.y) / rect.height) * vbH.value;
|
|
383
|
+
panX.value = panStart.panX - dx;
|
|
384
|
+
panY.value = panStart.panY - dy;
|
|
385
|
+
}
|
|
386
|
+
function handleMouseUp() {
|
|
387
|
+
isPanning.value = false;
|
|
388
|
+
}
|
|
389
|
+
const viewBox = computed(() => `${vbX.value} ${vbY.value} ${vbW.value} ${vbH.value}`);
|
|
390
|
+
const isZoomed = computed(() => zoomScale.value !== 1 || panX.value !== 0 || panY.value !== 0);
|
|
391
|
+
// -------------------------------------------------------------------------
|
|
392
|
+
// Lifecycle: start the simulation on mount, re-run when inputs change,
|
|
393
|
+
// tear down the rAF loop on unmount.
|
|
394
|
+
// -------------------------------------------------------------------------
|
|
395
|
+
onMounted(() => {
|
|
396
|
+
startLayout();
|
|
397
|
+
});
|
|
398
|
+
watch(() => [props.nodes, props.edges, props.width, props.height, props.nodeRadius, props.iterations], () => {
|
|
399
|
+
startLayout();
|
|
400
|
+
}, { deep: true });
|
|
401
|
+
onUnmounted(() => {
|
|
402
|
+
if (rafId !== null) {
|
|
403
|
+
cancelAnimationFrame(rafId);
|
|
404
|
+
rafId = null;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
19
407
|
return () => {
|
|
20
408
|
const label = props.label ?? "Force graph";
|
|
21
|
-
const selectedIds = props.selectedIds ?? [];
|
|
22
409
|
const focusId = props.focusId ?? null;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
410
|
+
const children = [];
|
|
411
|
+
// ---- SVG canvas ----
|
|
412
|
+
const edgeVNodes = [];
|
|
413
|
+
for (const e of positionedEdges.value) {
|
|
414
|
+
// Invisible wider hit area for edge hover.
|
|
415
|
+
edgeVNodes.push(h("line", {
|
|
416
|
+
key: `hit-${e.i}`,
|
|
417
|
+
class: "st-forceGraph__edgeHit",
|
|
418
|
+
role: "presentation",
|
|
419
|
+
x1: e.x1,
|
|
420
|
+
y1: e.y1,
|
|
421
|
+
x2: e.x2,
|
|
422
|
+
y2: e.y2,
|
|
423
|
+
onMouseenter: () => {
|
|
424
|
+
hoveredEdgeIndex.value = e.i;
|
|
425
|
+
fireEdgeHover(e.edge);
|
|
426
|
+
},
|
|
427
|
+
onMouseleave: () => {
|
|
428
|
+
hoveredEdgeIndex.value = null;
|
|
429
|
+
},
|
|
430
|
+
}));
|
|
431
|
+
edgeVNodes.push(h("line", {
|
|
432
|
+
key: `edge-${e.i}`,
|
|
433
|
+
class: classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", hoveredEdgeIndex.value === e.i && "st-forceGraph__edge--hovered"),
|
|
434
|
+
x1: e.x1,
|
|
435
|
+
y1: e.y1,
|
|
436
|
+
x2: e.x2,
|
|
437
|
+
y2: e.y2,
|
|
438
|
+
"pointer-events": "none",
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
const nodeVNodes = positionedNodes.value.map((p) => {
|
|
442
|
+
const isSelected = selectedSet.value.has(p.node.id);
|
|
443
|
+
const ariaLabel = `${p.title}${p.node.group !== undefined ? `: ${p.node.group}` : ""}`;
|
|
444
|
+
// Interactive attributes live on the dot/shape (the focusable element),
|
|
445
|
+
// mirroring the Svelte template. Click/dblclick are handled on the
|
|
446
|
+
// wrapping group so a click anywhere within the node (dot or label)
|
|
447
|
+
// selects it and fires exactly once.
|
|
448
|
+
const dotHandlers = {
|
|
449
|
+
tabindex: 0,
|
|
450
|
+
role: "button",
|
|
451
|
+
"aria-label": ariaLabel,
|
|
452
|
+
"aria-pressed": isSelected ? "true" : "false",
|
|
453
|
+
onMouseenter: () => { hoveredNodeIndex.value = p.i; },
|
|
454
|
+
onMouseleave: () => { hoveredNodeIndex.value = null; },
|
|
455
|
+
onFocus: () => { hoveredNodeIndex.value = p.i; },
|
|
456
|
+
onBlur: () => { hoveredNodeIndex.value = null; },
|
|
457
|
+
onKeydown: (ev) => handleNodeKeydown(p.node.id, ev),
|
|
458
|
+
};
|
|
459
|
+
const shapeEl = p.shapePath
|
|
460
|
+
? h("path", { class: "st-forceGraph__dot st-forceGraph__shape", d: p.shapePath, ...dotHandlers })
|
|
461
|
+
: h("circle", { class: "st-forceGraph__dot", r: p.r, ...dotHandlers });
|
|
462
|
+
const inner = [shapeEl];
|
|
463
|
+
if (props.showLabels) {
|
|
464
|
+
inner.push(h("text", { class: "st-forceGraph__label", x: p.r + 3, y: 0, "dominant-baseline": "middle" }, p.title));
|
|
465
|
+
}
|
|
466
|
+
return h("g", {
|
|
467
|
+
key: p.node.id,
|
|
468
|
+
class: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, hoveredNodeIndex.value !== null && hoveredNodeIndex.value !== p.i && "st-forceGraph__node--dim", isSelected && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus"),
|
|
469
|
+
transform: `translate(${p.x} ${p.y})`,
|
|
470
|
+
onClick: () => fireSelect(p.node.id),
|
|
471
|
+
onDblclick: () => fireOpenEntity(p.node.id),
|
|
472
|
+
}, inner);
|
|
473
|
+
});
|
|
474
|
+
children.push(h("svg", {
|
|
475
|
+
ref: svgEl,
|
|
476
|
+
viewBox: viewBox.value,
|
|
477
|
+
preserveAspectRatio: "xMidYMid meet",
|
|
478
|
+
width: "100%",
|
|
479
|
+
height: "100%",
|
|
480
|
+
focusable: "false",
|
|
481
|
+
"aria-hidden": "true",
|
|
482
|
+
class: classNames(isPanning.value && "st-forceGraph__svg--panning"),
|
|
483
|
+
onWheel: handleWheel,
|
|
484
|
+
onMousedown: handleBgMouseDown,
|
|
485
|
+
onMousemove: handleMouseMove,
|
|
486
|
+
onMouseup: handleMouseUp,
|
|
487
|
+
onMouseleave: handleMouseUp,
|
|
27
488
|
}, [
|
|
28
|
-
h("
|
|
29
|
-
h("
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
489
|
+
h("g", { class: "st-forceGraph__edges" }, edgeVNodes),
|
|
490
|
+
h("g", { class: "st-forceGraph__nodes" }, nodeVNodes),
|
|
491
|
+
]));
|
|
492
|
+
// ---- Node tooltip ----
|
|
493
|
+
const hni = hoveredNodeIndex.value;
|
|
494
|
+
if (hni !== null && positionedNodes.value[hni]) {
|
|
495
|
+
const p = positionedNodes.value[hni];
|
|
496
|
+
const relCount = positionedEdges.value.filter((e) => e.edge.source === p.node.id || e.edge.target === p.node.id).length;
|
|
497
|
+
const tipChildren = [
|
|
498
|
+
h("span", { class: "st-forceGraph__tooltipLabel" }, p.title),
|
|
499
|
+
];
|
|
500
|
+
if (p.node.group !== undefined) {
|
|
501
|
+
tipChildren.push(h("span", { class: "st-forceGraph__tooltipMeta" }, String(p.node.group)));
|
|
502
|
+
}
|
|
503
|
+
if (relCount > 0) {
|
|
504
|
+
tipChildren.push(h("span", { class: "st-forceGraph__tooltipMeta" }, `${relCount} relation${relCount === 1 ? "" : "s"}`));
|
|
505
|
+
}
|
|
506
|
+
children.push(h("div", {
|
|
507
|
+
class: "st-forceGraph__tooltip",
|
|
508
|
+
role: "presentation",
|
|
509
|
+
style: `left: ${((p.x - vbX.value) / vbW.value) * 100}%; top: ${((p.y - vbY.value) / vbH.value) * 100}%`,
|
|
510
|
+
}, tipChildren));
|
|
511
|
+
}
|
|
512
|
+
// ---- Edge tooltip ----
|
|
513
|
+
const hei = hoveredEdgeIndex.value;
|
|
514
|
+
if (hei !== null) {
|
|
515
|
+
const e = positionedEdges.value.find((pe) => pe.i === hei);
|
|
516
|
+
if (e) {
|
|
517
|
+
const midX = (e.x1 + e.x2) / 2;
|
|
518
|
+
const midY = (e.y1 + e.y2) / 2;
|
|
519
|
+
const tipChildren = [
|
|
520
|
+
h("span", { class: "st-forceGraph__tooltipLabel" }, e.srcLabel),
|
|
521
|
+
];
|
|
522
|
+
if (e.edge.relation) {
|
|
523
|
+
tipChildren.push(h("span", { class: "st-forceGraph__tooltipRelation" }, e.edge.relation));
|
|
524
|
+
}
|
|
525
|
+
tipChildren.push(h("span", { class: "st-forceGraph__tooltipLabel" }, e.tgtLabel));
|
|
526
|
+
children.push(h("div", {
|
|
527
|
+
class: "st-forceGraph__tooltip st-forceGraph__tooltip--edge",
|
|
528
|
+
role: "presentation",
|
|
529
|
+
style: `left: ${((midX - vbX.value) / vbW.value) * 100}%; top: ${((midY - vbY.value) / vbH.value) * 100}%`,
|
|
530
|
+
}, tipChildren));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// ---- Reset view button (only when zoomed/panned) ----
|
|
534
|
+
if (isZoomed.value) {
|
|
535
|
+
children.push(h("button", {
|
|
536
|
+
class: "st-forceGraph__resetBtn",
|
|
537
|
+
type: "button",
|
|
538
|
+
"aria-label": "Reset view",
|
|
539
|
+
onClick: resetView,
|
|
540
|
+
}, "↺"));
|
|
541
|
+
}
|
|
542
|
+
// ---- Legend overlay ----
|
|
543
|
+
if (props.legend && props.legend.length > 0) {
|
|
544
|
+
const entries = props.legend.map((entry, idx) => {
|
|
545
|
+
const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
|
|
546
|
+
const swatchTone = entry.tone ?? "category1";
|
|
547
|
+
let swatch;
|
|
548
|
+
if (entry.shape !== undefined) {
|
|
549
|
+
swatch = h("svg", {
|
|
550
|
+
class: "st-forceGraph__legendSwatch",
|
|
551
|
+
viewBox: "-8 -8 16 16",
|
|
552
|
+
width: "16",
|
|
553
|
+
height: "16",
|
|
554
|
+
"aria-hidden": "true",
|
|
47
555
|
}, [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
556
|
+
swatchPath
|
|
557
|
+
? h("path", {
|
|
558
|
+
d: swatchPath,
|
|
559
|
+
class: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}`,
|
|
560
|
+
})
|
|
561
|
+
: h("circle", {
|
|
562
|
+
r: "7",
|
|
563
|
+
class: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}`,
|
|
564
|
+
}),
|
|
565
|
+
]);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
swatch = h("svg", {
|
|
569
|
+
class: "st-forceGraph__legendSwatch",
|
|
570
|
+
viewBox: "0 0 16 8",
|
|
571
|
+
width: "16",
|
|
572
|
+
height: "8",
|
|
573
|
+
"aria-hidden": "true",
|
|
574
|
+
}, [
|
|
575
|
+
h("line", {
|
|
576
|
+
x1: "0",
|
|
577
|
+
y1: "4",
|
|
578
|
+
x2: "16",
|
|
579
|
+
y2: "4",
|
|
580
|
+
class: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak"),
|
|
53
581
|
}),
|
|
54
|
-
h("text", { class: "st-forceGraph__label", x: x + 12, y: y + 4 }, graphNode.label ?? graphNode.id),
|
|
55
582
|
]);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
583
|
+
}
|
|
584
|
+
return h("div", { key: idx, class: "st-forceGraph__legendEntry" }, [
|
|
585
|
+
swatch,
|
|
586
|
+
h("span", { class: "st-forceGraph__legendLabel" }, entry.label),
|
|
587
|
+
]);
|
|
588
|
+
});
|
|
589
|
+
children.push(h("div", { class: "st-forceGraph__legend", "aria-label": "Graph legend" }, entries));
|
|
590
|
+
}
|
|
591
|
+
return h("figure", {
|
|
592
|
+
...attrs,
|
|
593
|
+
class: classNames("st-forceGraph", props.class),
|
|
594
|
+
role: "img",
|
|
595
|
+
"aria-label": label,
|
|
596
|
+
}, children);
|
|
59
597
|
};
|
|
60
598
|
},
|
|
61
599
|
});
|