@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.
@@ -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
- const DATA_TONES = [
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: ["select", "openEntity"],
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
- return h("figure", {
24
- ...attrs,
25
- class: classNames("st-forceGraph st-forceGraph--static", props.class),
26
- "aria-label": label,
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("span", { class: "st-visually-hidden" }, label),
29
- h("svg", { viewBox: "0 0 360 220", "aria-hidden": "true" }, [
30
- h("g", { class: "st-forceGraph__edges" }, props.edges.map((edge, index) => h("line", {
31
- key: `${edge.source}-${edge.target}-${index}`,
32
- class: classNames("st-forceGraph__edge", edge.weak && "st-forceGraph__edge--weak"),
33
- x1: "40",
34
- y1: 40 + index * 20,
35
- x2: "260",
36
- y2: 80 + index * 20,
37
- }))),
38
- h("g", { class: "st-forceGraph__nodes" }, props.nodes.map((graphNode, index) => {
39
- const x = graphNode.fx ?? 48 + (index % 5) * 64;
40
- const y = graphNode.fy ?? 56 + Math.floor(index / 5) * 56;
41
- return h("g", {
42
- key: graphNode.id,
43
- class: classNames("st-forceGraph__node", `st-forceGraph__node--${graphNode.tone ?? DATA_TONES[index % DATA_TONES.length]}`, selectedIds.includes(graphNode.id) && "st-forceGraph__node--selected", focusId === graphNode.id && "st-forceGraph__node--focus"),
44
- tabindex: 0,
45
- onClick: () => emit("select", graphNode.id),
46
- onDblclick: () => emit("openEntity", graphNode.id),
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
- h("circle", {
49
- class: "st-forceGraph__dot",
50
- cx: x,
51
- cy: y,
52
- r: 8 * (graphNode.weight ?? 1),
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
  });