living-documentation 4.2.0 → 4.4.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.

Potentially problematic release.


This version of living-documentation might be problematic. Click here for more details.

@@ -4,8 +4,8 @@
4
4
  // (vis-network caches the ctxRenderer reference and never re-reads it from the
5
5
  // DataSet, so dimensions/rotation/alignment must be fetched at draw time).
6
6
 
7
- import { NODE_COLORS } from './constants.js';
8
- import { st } from './state.js';
7
+ import { NODE_COLORS } from "./constants.js";
8
+ import { st } from "./state.js";
9
9
 
10
10
  // ── Link indicator ────────────────────────────────────────────────────────────
11
11
  // Small chain icon drawn at bottom-right of any node that has a nodeLink.
@@ -16,48 +16,95 @@ function drawLinkIndicator(ctx, id, W, H) {
16
16
  const bx = W / 2 - r;
17
17
  const by = H / 2 - r;
18
18
  ctx.save();
19
- ctx.fillStyle = n.nodeLink.type === 'url' ? '#3b82f6' : '#f97316';
20
- ctx.strokeStyle = '#fff';
21
- ctx.lineWidth = 1;
22
- ctx.beginPath(); ctx.arc(bx, by, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
23
- ctx.strokeStyle = '#fff';
24
- ctx.lineWidth = 1.2;
25
- ctx.lineCap = 'round';
19
+ ctx.fillStyle = n.nodeLink.type === "url" ? "#3b82f6" : "#f97316";
20
+ ctx.strokeStyle = "#fff";
21
+ ctx.lineWidth = 1;
22
+ ctx.beginPath();
23
+ ctx.arc(bx, by, r, 0, Math.PI * 2);
24
+ ctx.fill();
25
+ ctx.stroke();
26
+ ctx.strokeStyle = "#fff";
27
+ ctx.lineWidth = 1.2;
28
+ ctx.lineCap = "round";
26
29
  // Tiny link icon inside the badge
27
30
  ctx.beginPath();
28
- ctx.moveTo(bx - 1.5, by + 1.5); ctx.lineTo(bx + 1.5, by - 1.5);
29
- ctx.moveTo(bx - 2.5, by - 0.5); ctx.lineTo(bx - 0.5, by - 2.5);
30
- ctx.moveTo(bx + 0.5, by + 2.5); ctx.lineTo(bx + 2.5, by + 0.5);
31
+ ctx.moveTo(bx - 1.5, by + 1.5);
32
+ ctx.lineTo(bx + 1.5, by - 1.5);
33
+ ctx.moveTo(bx - 2.5, by - 0.5);
34
+ ctx.lineTo(bx - 0.5, by - 2.5);
35
+ ctx.moveTo(bx + 0.5, by + 2.5);
36
+ ctx.lineTo(bx + 2.5, by + 0.5);
31
37
  ctx.stroke();
32
38
  ctx.restore();
33
39
  }
34
40
 
35
41
  // ── Drawing helpers ───────────────────────────────────────────────────────────
36
42
 
43
+ // Break a single text line into wrapped sub-lines that fit within maxWidth pixels.
44
+ // Requires ctx.font to be set before calling.
45
+ function wrapWords(ctx, text, maxWidth) {
46
+ if (!text) return [""];
47
+ const words = text.split(" ");
48
+ const result = [];
49
+ let current = "";
50
+ for (const word of words) {
51
+ const candidate = current ? current + " " + word : word;
52
+ if (current && ctx.measureText(candidate).width > maxWidth) {
53
+ result.push(current);
54
+ current = word;
55
+ } else {
56
+ current = candidate;
57
+ }
58
+ }
59
+ if (current) result.push(current);
60
+ return result.length ? result : [""];
61
+ }
62
+
37
63
  // Draw multi-line label centred at (0,0) in the current (possibly rotated) ctx.
38
64
  // labelRotation is applied around the label's own centre (independent of shape rotation).
39
- function drawLabel(ctx, label, fontSize, color, textAlign, textValign, W, H, labelRotation) {
65
+ function drawLabel(
66
+ ctx,
67
+ label,
68
+ fontSize,
69
+ color,
70
+ textAlign,
71
+ textValign,
72
+ W,
73
+ H,
74
+ labelRotation,
75
+ ) {
40
76
  if (!label) return;
41
77
  const pad = 8;
42
78
  ctx.save();
43
- ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
44
- ctx.fillStyle = color;
45
- ctx.textBaseline = 'middle';
79
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
80
+ ctx.fillStyle = color;
81
+ ctx.textBaseline = "middle";
46
82
 
47
83
  let xPos = 0;
48
- if (textAlign === 'left') { ctx.textAlign = 'left'; xPos = W ? -W / 2 + pad : -40; }
49
- else if (textAlign === 'right') { ctx.textAlign = 'right'; xPos = W ? W / 2 - pad : 40; }
50
- else { ctx.textAlign = 'center'; xPos = 0; }
84
+ if (textAlign === "left") {
85
+ ctx.textAlign = "left";
86
+ xPos = W ? -W / 2 + pad : -40;
87
+ } else if (textAlign === "right") {
88
+ ctx.textAlign = "right";
89
+ xPos = W ? W / 2 - pad : 40;
90
+ } else {
91
+ ctx.textAlign = "center";
92
+ xPos = 0;
93
+ }
51
94
 
52
95
  let yOff = 0;
53
96
  if (W && H) {
54
- if (textValign === 'top') yOff = -(H / 2 - fontSize / 2 - pad);
55
- else if (textValign === 'bottom') yOff = H / 2 - fontSize / 2 - pad;
97
+ if (textValign === "top") yOff = -(H / 2 - fontSize / 2 - pad);
98
+ else if (textValign === "bottom") yOff = H / 2 - fontSize / 2 - pad;
56
99
  }
57
100
 
58
- const lines = String(label).split('\n');
59
- const lineH = fontSize * 1.3;
60
- const startY = yOff - (lines.length - 1) * lineH / 2;
101
+ // Auto-wrap: split each explicit \n line further if it overflows the shape width.
102
+ const maxW = W ? W - pad * 2 : Infinity;
103
+ const lines = String(label)
104
+ .split("\n")
105
+ .flatMap((l) => wrapWords(ctx, l, maxW));
106
+ const lineH = fontSize * 1.3;
107
+ const startY = yOff - ((lines.length - 1) * lineH) / 2;
61
108
 
62
109
  // Apply independent label rotation around the label centre.
63
110
  if (labelRotation) ctx.rotate(labelRotation);
@@ -87,18 +134,18 @@ function roundRect(ctx, x, y, w, h, r) {
87
134
  // nodes.update(), so colour, font size, dimensions, rotation, and alignment
88
135
  // must all be fetched here to reflect the latest values.
89
136
  function nodeData(id, defaultW, defaultH, defaultColorKey) {
90
- const n = st.nodes && st.nodes.get(id);
91
- const colorKey = (n && n.colorKey) || defaultColorKey || 'c-gray';
137
+ const n = st.nodes && st.nodes.get(id);
138
+ const colorKey = (n && n.colorKey) || defaultColorKey || "c-gray";
92
139
  return {
93
- W: (n && n.nodeWidth) || defaultW,
94
- H: (n && n.nodeHeight) || defaultH,
95
- rotation: (n && n.rotation) || 0,
96
- labelRotation: (n && n.labelRotation) || 0,
97
- textAlign: (n && n.textAlign) || 'center',
98
- textValign: (n && n.textValign) || 'middle',
99
- fontSize: (n && n.fontSize) || 13,
140
+ W: (n && n.nodeWidth) || defaultW,
141
+ H: (n && n.nodeHeight) || defaultH,
142
+ rotation: (n && n.rotation) || 0,
143
+ labelRotation: (n && n.labelRotation) || 0,
144
+ textAlign: (n && n.textAlign) || "center",
145
+ textValign: (n && n.textValign) || "middle",
146
+ fontSize: (n && n.fontSize) || 13,
100
147
  colorKey,
101
- c: NODE_COLORS[colorKey] || NODE_COLORS['c-gray'],
148
+ c: NODE_COLORS[colorKey] || NODE_COLORS["c-gray"],
102
149
  };
103
150
  }
104
151
 
@@ -106,16 +153,38 @@ function nodeData(id, defaultW, defaultH, defaultColorKey) {
106
153
 
107
154
  export function makeBoxRenderer(colorKey) {
108
155
  return function ({ ctx, x, y, id, state: visState, label }) {
109
- const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 100, 40, colorKey);
156
+ const {
157
+ W,
158
+ H,
159
+ rotation,
160
+ labelRotation,
161
+ textAlign,
162
+ textValign,
163
+ fontSize,
164
+ c,
165
+ } = nodeData(id, 100, 40, colorKey);
110
166
  return {
111
167
  drawNode() {
112
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
113
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
114
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
115
- ctx.lineWidth = 1.5;
168
+ ctx.save();
169
+ ctx.translate(x, y);
170
+ ctx.rotate(rotation);
171
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
172
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
173
+ ctx.lineWidth = 1.5;
116
174
  roundRect(ctx, -W / 2, -H / 2, W, H, 4);
117
- ctx.fill(); ctx.stroke();
118
- drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
175
+ ctx.fill();
176
+ ctx.stroke();
177
+ drawLabel(
178
+ ctx,
179
+ label,
180
+ fontSize,
181
+ c.font,
182
+ textAlign,
183
+ textValign,
184
+ W,
185
+ H,
186
+ labelRotation,
187
+ );
119
188
  drawLinkIndicator(ctx, id, W, H);
120
189
  ctx.restore();
121
190
  },
@@ -126,16 +195,39 @@ export function makeBoxRenderer(colorKey) {
126
195
 
127
196
  export function makeEllipseRenderer(colorKey) {
128
197
  return function ({ ctx, x, y, id, state: visState, label }) {
129
- const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 110, 50, colorKey);
198
+ const {
199
+ W,
200
+ H,
201
+ rotation,
202
+ labelRotation,
203
+ textAlign,
204
+ textValign,
205
+ fontSize,
206
+ c,
207
+ } = nodeData(id, 110, 50, colorKey);
130
208
  return {
131
209
  drawNode() {
132
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
133
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
134
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
135
- ctx.lineWidth = 1.5;
136
- ctx.beginPath(); ctx.ellipse(0, 0, W / 2, H / 2, 0, 0, Math.PI * 2);
137
- ctx.fill(); ctx.stroke();
138
- drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
210
+ ctx.save();
211
+ ctx.translate(x, y);
212
+ ctx.rotate(rotation);
213
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
214
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
215
+ ctx.lineWidth = 1.5;
216
+ ctx.beginPath();
217
+ ctx.ellipse(0, 0, W / 2, H / 2, 0, 0, Math.PI * 2);
218
+ ctx.fill();
219
+ ctx.stroke();
220
+ drawLabel(
221
+ ctx,
222
+ label,
223
+ fontSize,
224
+ c.font,
225
+ textAlign,
226
+ textValign,
227
+ W,
228
+ H,
229
+ labelRotation,
230
+ );
139
231
  drawLinkIndicator(ctx, id, W, H);
140
232
  ctx.restore();
141
233
  },
@@ -146,17 +238,32 @@ export function makeEllipseRenderer(colorKey) {
146
238
 
147
239
  export function makeCircleRenderer(colorKey) {
148
240
  return function ({ ctx, x, y, id, state: visState, label }) {
149
- const { W, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 55, 55, colorKey);
241
+ const { W, rotation, labelRotation, textAlign, textValign, fontSize, c } =
242
+ nodeData(id, 55, 55, colorKey);
150
243
  const R = W / 2;
151
244
  return {
152
245
  drawNode() {
153
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
154
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
155
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
156
- ctx.lineWidth = 1.5;
157
- ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2);
158
- ctx.fill(); ctx.stroke();
159
- drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, W, labelRotation);
246
+ ctx.save();
247
+ ctx.translate(x, y);
248
+ ctx.rotate(rotation);
249
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
250
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
251
+ ctx.lineWidth = 1.5;
252
+ ctx.beginPath();
253
+ ctx.arc(0, 0, R, 0, Math.PI * 2);
254
+ ctx.fill();
255
+ ctx.stroke();
256
+ drawLabel(
257
+ ctx,
258
+ label,
259
+ fontSize,
260
+ c.font,
261
+ textAlign,
262
+ textValign,
263
+ W,
264
+ W,
265
+ labelRotation,
266
+ );
160
267
  drawLinkIndicator(ctx, id, W, W);
161
268
  ctx.restore();
162
269
  },
@@ -167,27 +274,54 @@ export function makeCircleRenderer(colorKey) {
167
274
 
168
275
  export function makeDatabaseRenderer(colorKey) {
169
276
  return function ({ ctx, x, y, id, state: visState, label }) {
170
- const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 50, 70, colorKey);
277
+ const {
278
+ W,
279
+ H,
280
+ rotation,
281
+ labelRotation,
282
+ textAlign,
283
+ textValign,
284
+ fontSize,
285
+ c,
286
+ } = nodeData(id, 50, 70, colorKey);
171
287
  const rx = W / 2;
172
288
  const ry = Math.max(H * 0.12, 6);
173
- const bodyTop = -H / 2 + ry;
174
- const bodyBottom = H / 2 - ry;
289
+ const bodyTop = -H / 2 + ry;
290
+ const bodyBottom = H / 2 - ry;
175
291
  return {
176
292
  drawNode() {
177
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
178
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
179
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
180
- ctx.lineWidth = 1.5;
293
+ ctx.save();
294
+ ctx.translate(x, y);
295
+ ctx.rotate(rotation);
296
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
297
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
298
+ ctx.lineWidth = 1.5;
181
299
  ctx.fillRect(-rx, bodyTop, W, bodyBottom - bodyTop);
182
- ctx.beginPath(); ctx.ellipse(0, bodyBottom, rx, ry, 0, 0, Math.PI * 2);
183
- ctx.fill(); ctx.stroke();
184
300
  ctx.beginPath();
185
- ctx.moveTo(-rx, bodyTop); ctx.lineTo(-rx, bodyBottom);
186
- ctx.moveTo( rx, bodyTop); ctx.lineTo( rx, bodyBottom);
301
+ ctx.ellipse(0, bodyBottom, rx, ry, 0, 0, Math.PI * 2);
302
+ ctx.fill();
303
+ ctx.stroke();
304
+ ctx.beginPath();
305
+ ctx.moveTo(-rx, bodyTop);
306
+ ctx.lineTo(-rx, bodyBottom);
307
+ ctx.moveTo(rx, bodyTop);
308
+ ctx.lineTo(rx, bodyBottom);
309
+ ctx.stroke();
310
+ ctx.beginPath();
311
+ ctx.ellipse(0, bodyTop, rx, ry, 0, 0, Math.PI * 2);
312
+ ctx.fill();
187
313
  ctx.stroke();
188
- ctx.beginPath(); ctx.ellipse(0, bodyTop, rx, ry, 0, 0, Math.PI * 2);
189
- ctx.fill(); ctx.stroke();
190
- drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
314
+ drawLabel(
315
+ ctx,
316
+ label,
317
+ fontSize,
318
+ c.font,
319
+ textAlign,
320
+ textValign,
321
+ W,
322
+ H,
323
+ labelRotation,
324
+ );
191
325
  drawLinkIndicator(ctx, id, W, H);
192
326
  ctx.restore();
193
327
  },
@@ -199,27 +333,39 @@ export function makeDatabaseRenderer(colorKey) {
199
333
  // Post-IT: sticky note with folded top-right corner.
200
334
  export function makePostItRenderer(colorKey) {
201
335
  return function ({ ctx, x, y, id, state: visState, label }) {
202
- const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 120, 100, colorKey || 'c-amber');
336
+ const {
337
+ W,
338
+ H,
339
+ rotation,
340
+ labelRotation,
341
+ textAlign,
342
+ textValign,
343
+ fontSize,
344
+ c,
345
+ } = nodeData(id, 120, 100, colorKey || "c-amber");
203
346
  const fold = Math.min(W, H) * 0.18;
204
347
  return {
205
348
  drawNode() {
206
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
207
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
208
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
209
- ctx.lineWidth = 1.5;
349
+ ctx.save();
350
+ ctx.translate(x, y);
351
+ ctx.rotate(rotation);
352
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
353
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
354
+ ctx.lineWidth = 1.5;
210
355
  ctx.beginPath();
211
- ctx.moveTo(-W / 2, -H / 2);
212
- ctx.lineTo( W / 2 - fold, -H / 2);
213
- ctx.lineTo( W / 2, -H / 2 + fold);
214
- ctx.lineTo( W / 2, H / 2);
215
- ctx.lineTo(-W / 2, H / 2);
356
+ ctx.moveTo(-W / 2, -H / 2);
357
+ ctx.lineTo(W / 2 - fold, -H / 2);
358
+ ctx.lineTo(W / 2, -H / 2 + fold);
359
+ ctx.lineTo(W / 2, H / 2);
360
+ ctx.lineTo(-W / 2, H / 2);
216
361
  ctx.closePath();
217
- ctx.fill(); ctx.stroke();
362
+ ctx.fill();
363
+ ctx.stroke();
218
364
  ctx.globalAlpha = 0.3;
219
- ctx.fillStyle = c.border;
365
+ ctx.fillStyle = c.border;
220
366
  ctx.beginPath();
221
367
  ctx.moveTo(W / 2 - fold, -H / 2);
222
- ctx.lineTo(W / 2, -H / 2 + fold);
368
+ ctx.lineTo(W / 2, -H / 2 + fold);
223
369
  ctx.lineTo(W / 2 - fold, -H / 2 + fold);
224
370
  ctx.closePath();
225
371
  ctx.fill();
@@ -227,9 +373,19 @@ export function makePostItRenderer(colorKey) {
227
373
  ctx.beginPath();
228
374
  ctx.moveTo(W / 2 - fold, -H / 2);
229
375
  ctx.lineTo(W / 2 - fold, -H / 2 + fold);
230
- ctx.lineTo(W / 2, -H / 2 + fold);
376
+ ctx.lineTo(W / 2, -H / 2 + fold);
231
377
  ctx.stroke();
232
- drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
378
+ drawLabel(
379
+ ctx,
380
+ label,
381
+ fontSize,
382
+ c.font,
383
+ textAlign,
384
+ textValign,
385
+ W,
386
+ H,
387
+ labelRotation,
388
+ );
233
389
  drawLinkIndicator(ctx, id, W, H);
234
390
  ctx.restore();
235
391
  },
@@ -241,19 +397,40 @@ export function makePostItRenderer(colorKey) {
241
397
  // Free Text: no visible border or background — just the label.
242
398
  export function makeTextFreeRenderer(colorKey) {
243
399
  return function ({ ctx, x, y, id, state: visState, label }) {
244
- const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 80, 30, colorKey);
400
+ const {
401
+ W,
402
+ H,
403
+ rotation,
404
+ labelRotation,
405
+ textAlign,
406
+ textValign,
407
+ fontSize,
408
+ c,
409
+ } = nodeData(id, 80, 30, colorKey);
245
410
  return {
246
411
  drawNode() {
247
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
412
+ ctx.save();
413
+ ctx.translate(x, y);
414
+ ctx.rotate(rotation);
248
415
  if (visState.selected || visState.hover) {
249
- ctx.strokeStyle = '#f97316';
250
- ctx.lineWidth = 1;
416
+ ctx.strokeStyle = "#f97316";
417
+ ctx.lineWidth = 1;
251
418
  ctx.setLineDash([4, 3]);
252
419
  roundRect(ctx, -W / 2, -H / 2, W, H, 3);
253
420
  ctx.stroke();
254
421
  ctx.setLineDash([]);
255
422
  }
256
- drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
423
+ drawLabel(
424
+ ctx,
425
+ label,
426
+ fontSize,
427
+ c.font,
428
+ textAlign,
429
+ textValign,
430
+ W,
431
+ H,
432
+ labelRotation,
433
+ );
257
434
  drawLinkIndicator(ctx, id, W, H);
258
435
  ctx.restore();
259
436
  },
@@ -268,31 +445,53 @@ const ACTOR_H0 = 52;
268
445
 
269
446
  export function makeActorRenderer(colorKey) {
270
447
  return function ({ ctx, x, y, id, state: visState, label }) {
271
- const { W, H, rotation, labelRotation, fontSize, c } = nodeData(id, ACTOR_W0, ACTOR_H0, colorKey);
448
+ const { W, H, rotation, labelRotation, fontSize, c } = nodeData(
449
+ id,
450
+ ACTOR_W0,
451
+ ACTOR_H0,
452
+ colorKey,
453
+ );
272
454
  const sx = W / ACTOR_W0;
273
455
  const sy = H / ACTOR_H0;
274
456
  return {
275
457
  drawNode() {
276
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
277
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
278
- ctx.fillStyle = visState.selected ? c.hbg : c.bg;
279
- ctx.lineWidth = 2;
280
- ctx.lineCap = 'round';
281
- ctx.beginPath(); ctx.arc(0, -20 * sy, 8 * sy, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
282
- ctx.beginPath(); ctx.moveTo(0, -12 * sy); ctx.lineTo(0, 8 * sy); ctx.stroke();
283
- ctx.beginPath(); ctx.moveTo(-13 * sx, -3 * sy); ctx.lineTo(13 * sx, -3 * sy); ctx.stroke();
284
- ctx.beginPath(); ctx.moveTo(0, 8 * sy); ctx.lineTo(-10 * sx, 24 * sy); ctx.stroke();
285
- ctx.beginPath(); ctx.moveTo(0, 8 * sy); ctx.lineTo( 10 * sx, 24 * sy); ctx.stroke();
458
+ ctx.save();
459
+ ctx.translate(x, y);
460
+ ctx.rotate(rotation);
461
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
462
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
463
+ ctx.lineWidth = 2;
464
+ ctx.lineCap = "round";
465
+ ctx.beginPath();
466
+ ctx.arc(0, -20 * sy, 8 * sy, 0, Math.PI * 2);
467
+ ctx.fill();
468
+ ctx.stroke();
469
+ ctx.beginPath();
470
+ ctx.moveTo(0, -12 * sy);
471
+ ctx.lineTo(0, 8 * sy);
472
+ ctx.stroke();
473
+ ctx.beginPath();
474
+ ctx.moveTo(-13 * sx, 0 * sy);
475
+ ctx.lineTo(13 * sx, 0 * sy);
476
+ ctx.stroke();
477
+ ctx.beginPath();
478
+ ctx.moveTo(0, 8 * sy);
479
+ ctx.lineTo(-10 * sx, 24 * sy);
480
+ ctx.stroke();
481
+ ctx.beginPath();
482
+ ctx.moveTo(0, 8 * sy);
483
+ ctx.lineTo(10 * sx, 24 * sy);
484
+ ctx.stroke();
286
485
  // Label below figure (rotates with the actor)
287
486
  if (label) {
288
487
  ctx.save();
289
488
  if (labelRotation) ctx.rotate(labelRotation);
290
- ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
291
- ctx.fillStyle = c.font;
292
- ctx.textAlign = 'center';
293
- ctx.textBaseline = 'top';
294
- const lines = String(label).split('\n');
295
- const lineH = fontSize * 1.3;
489
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
490
+ ctx.fillStyle = c.font;
491
+ ctx.textAlign = "center";
492
+ ctx.textBaseline = "top";
493
+ const lines = String(label).split("\n");
494
+ const lineH = fontSize * 1.3;
296
495
  const startY = 24 * sy + 4;
297
496
  lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
298
497
  ctx.restore();
@@ -314,28 +513,44 @@ const _imgCache = new Map(); // src → HTMLImageElement | 'loading' | 'error'
314
513
  function getCachedImage(src, redrawFn) {
315
514
  if (!src) return null;
316
515
  const cached = _imgCache.get(src);
317
- if (cached === 'loading' || cached === 'error') return null;
516
+ if (cached === "loading" || cached === "error") return null;
318
517
  if (cached) return cached;
319
- _imgCache.set(src, 'loading');
518
+ _imgCache.set(src, "loading");
320
519
  const img = new Image();
321
- img.onload = () => { _imgCache.set(src, img); redrawFn && redrawFn(); };
322
- img.onerror = () => { _imgCache.set(src, 'error'); };
520
+ img.onload = () => {
521
+ _imgCache.set(src, img);
522
+ redrawFn && redrawFn();
523
+ };
524
+ img.onerror = () => {
525
+ _imgCache.set(src, "error");
526
+ };
323
527
  img.src = src;
324
528
  return null;
325
529
  }
326
530
 
327
531
  export function makeImageRenderer(colorKey) {
328
532
  return function ({ ctx, x, y, id, state: visState, label }) {
329
- const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 160, 120, colorKey || 'c-gray');
330
- const n = st.nodes && st.nodes.get(id);
331
- const src = n && n.imageSrc;
332
- const img = getCachedImage(src, () => st.network && st.network.redraw());
533
+ const {
534
+ W,
535
+ H,
536
+ rotation,
537
+ labelRotation,
538
+ textAlign,
539
+ textValign,
540
+ fontSize,
541
+ c,
542
+ } = nodeData(id, 160, 120, colorKey || "c-gray");
543
+ const n = st.nodes && st.nodes.get(id);
544
+ const src = n && n.imageSrc;
545
+ const img = getCachedImage(src, () => st.network && st.network.redraw());
333
546
  return {
334
547
  drawNode() {
335
- ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
548
+ ctx.save();
549
+ ctx.translate(x, y);
550
+ ctx.rotate(rotation);
336
551
  // Border (always visible, orange when selected)
337
- ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
338
- ctx.lineWidth = visState.selected ? 2 : 1;
552
+ ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
553
+ ctx.lineWidth = visState.selected ? 2 : 1;
339
554
  roundRect(ctx, -W / 2, -H / 2, W, H, 4);
340
555
  ctx.stroke();
341
556
 
@@ -353,44 +568,48 @@ export function makeImageRenderer(colorKey) {
353
568
  ctx.fill();
354
569
  ctx.fillStyle = c.border;
355
570
  ctx.font = `${Math.round(Math.min(W, H) * 0.25)}px system-ui`;
356
- ctx.textAlign = 'center';
357
- ctx.textBaseline = 'middle';
358
- ctx.fillText(src ? '' : '🖼', 0, 0);
571
+ ctx.textAlign = "center";
572
+ ctx.textBaseline = "middle";
573
+ ctx.fillText(src ? "" : "🖼", 0, 0);
359
574
  }
360
575
 
361
576
  if (label) {
362
- const lines = String(label).split('\n');
363
- const lineH = fontSize * 1.3;
364
- const pad = 6;
577
+ const lines = String(label).split("\n");
578
+ const lineH = fontSize * 1.3;
579
+ const pad = 6;
365
580
  const stripH = lines.length * lineH + pad * 2 - (lineH - fontSize);
366
581
  ctx.save();
367
582
  ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
368
- const maxTextW = Math.max(...lines.map((l) => ctx.measureText(l).width));
369
- const stripW = maxTextW + pad * 2;
583
+ const maxTextW = Math.max(
584
+ ...lines.map((l) => ctx.measureText(l).width),
585
+ );
586
+ const stripW = maxTextW + pad * 2;
370
587
  const M = 5; // margin from image edge
371
588
  // Horizontal position based on textAlign
372
589
  let stripX;
373
- if (textAlign === 'left') stripX = -W / 2 + M;
374
- else if (textAlign === 'right') stripX = W / 2 - stripW - M;
375
- else stripX = -stripW / 2;
590
+ if (textAlign === "left") stripX = -W / 2 + M;
591
+ else if (textAlign === "right") stripX = W / 2 - stripW - M;
592
+ else stripX = -stripW / 2;
376
593
  // Vertical position based on textValign
377
594
  let stripY;
378
- if (textValign === 'top') stripY = -H / 2 + M;
379
- else if (textValign === 'bottom') stripY = H / 2 - stripH - M;
380
- else stripY = -stripH / 2;
595
+ if (textValign === "top") stripY = -H / 2 + M;
596
+ else if (textValign === "bottom") stripY = H / 2 - stripH - M;
597
+ else stripY = -stripH / 2;
381
598
  ctx.globalAlpha = 0.7;
382
- ctx.fillStyle = '#000';
599
+ ctx.fillStyle = "#000";
383
600
  roundRect(ctx, stripX, stripY, stripW, stripH, 4);
384
601
  ctx.fill();
385
602
  ctx.globalAlpha = 1;
386
603
  // Draw text centered inside the box
387
604
  if (labelRotation) ctx.rotate(labelRotation);
388
- ctx.fillStyle = '#fff';
389
- ctx.textAlign = 'center';
390
- ctx.textBaseline = 'middle';
605
+ ctx.fillStyle = "#fff";
606
+ ctx.textAlign = "center";
607
+ ctx.textBaseline = "middle";
391
608
  const textCX = stripX + stripW / 2;
392
609
  const startY = stripY + pad + fontSize / 2;
393
- lines.forEach((line, i) => ctx.fillText(line, textCX, startY + i * lineH));
610
+ lines.forEach((line, i) =>
611
+ ctx.fillText(line, textCX, startY + i * lineH),
612
+ );
394
613
  ctx.restore();
395
614
  }
396
615
  drawLinkIndicator(ctx, id, W, H);
@@ -412,55 +631,109 @@ export function getActualNodeHeight(id) {
412
631
  }
413
632
 
414
633
  // Kept for backward compat (called by node-panel but irrelevant for ctxRenderers).
415
- export function computeVadjust() { return 0; }
634
+ export function computeVadjust() {
635
+ return 0;
636
+ }
637
+
638
+ // ── Anchor renderer — small dot endpoint for free-floating edges ──────────────
639
+ // nodeDimensions matches the visual radius so vis-network places the arrowhead
640
+ // exactly at the dot's border ("planted" effect).
641
+ // The dot is filled with the canvas background colour to mask the line that
642
+ // vis-network draws from the arrowhead to the node centre.
643
+ function makeAnchorRenderer() {
644
+ return ({ ctx, x, y, state: { selected, hover } }) => {
645
+ const r = 4;
646
+ return {
647
+ drawNode() {
648
+ if (!selected && !hover) return; // invisible at rest
649
+ ctx.save();
650
+ ctx.translate(x, y);
651
+ ctx.beginPath();
652
+ ctx.arc(0, 0, r, 0, Math.PI * 2);
653
+ ctx.fillStyle = "#f97316";
654
+ ctx.fill();
655
+ ctx.restore();
656
+ },
657
+ nodeDimensions: { width: 0, height: 0 },
658
+ };
659
+ };
660
+ }
416
661
 
417
662
  const RENDERER_MAP = {
418
- box: makeBoxRenderer,
419
- ellipse: makeEllipseRenderer,
420
- circle: makeCircleRenderer,
421
- database: makeDatabaseRenderer,
422
- 'post-it': makePostItRenderer,
423
- 'text-free':makeTextFreeRenderer,
424
- actor: makeActorRenderer,
425
- image: makeImageRenderer,
663
+ box: makeBoxRenderer,
664
+ ellipse: makeEllipseRenderer,
665
+ circle: makeCircleRenderer,
666
+ database: makeDatabaseRenderer,
667
+ "post-it": makePostItRenderer,
668
+ "text-free": makeTextFreeRenderer,
669
+ actor: makeActorRenderer,
670
+ image: makeImageRenderer,
671
+ anchor: makeAnchorRenderer,
426
672
  };
427
673
 
428
674
  // Default dimensions per shape type (used when nodeWidth/nodeHeight are null).
429
675
  export const SHAPE_DEFAULTS = {
430
- box: [100, 40],
431
- ellipse: [110, 50],
432
- circle: [55, 55],
433
- database: [50, 70],
434
- actor: [30, 52],
435
- 'post-it': [120, 100],
436
- 'text-free':[80, 30],
437
- image: [160, 120],
676
+ box: [100, 40],
677
+ ellipse: [110, 50],
678
+ circle: [55, 55],
679
+ database: [50, 70],
680
+ actor: [30, 52],
681
+ "post-it": [120, 100],
682
+ "text-free": [80, 30],
683
+ image: [160, 120],
684
+ anchor: [8, 8],
438
685
  };
439
686
 
440
687
  // Builds the full vis.js node property object.
441
688
  // All shapes are rendered via ctxRenderer so rotation works uniformly.
442
- export function visNodeProps(shapeType, colorKey, nodeWidth, nodeHeight, fontSize, textAlign, _textValign) {
443
- const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
444
- const size = fontSize || 13;
445
- const align = textAlign || 'center';
689
+ export function visNodeProps(
690
+ shapeType,
691
+ colorKey,
692
+ nodeWidth,
693
+ nodeHeight,
694
+ fontSize,
695
+ textAlign,
696
+ _textValign,
697
+ ) {
698
+ const c = NODE_COLORS[colorKey] || NODE_COLORS["c-gray"];
699
+ const size = fontSize || 13;
700
+ const align = textAlign || "center";
446
701
 
447
702
  const colorP = {
448
703
  color: {
449
- background: c.bg, border: c.border,
450
- highlight: { background: c.hbg, border: c.hborder },
451
- hover: { background: c.hbg, border: c.hborder },
704
+ background: c.bg,
705
+ border: c.border,
706
+ highlight: { background: c.hbg, border: c.hborder },
707
+ hover: { background: c.hbg, border: c.hborder },
708
+ },
709
+ font: {
710
+ color: c.font,
711
+ size,
712
+ face: "system-ui,-apple-system,sans-serif",
713
+ align,
452
714
  },
453
- font: { color: c.font, size, face: 'system-ui,-apple-system,sans-serif', align },
454
715
  };
455
716
 
456
717
  const sizeP = {};
457
- if (nodeWidth) sizeP.widthConstraint = { minimum: nodeWidth, maximum: nodeWidth };
458
- if (nodeHeight) sizeP.heightConstraint = { minimum: nodeHeight, maximum: nodeHeight };
718
+ if (nodeWidth)
719
+ sizeP.widthConstraint = { minimum: nodeWidth, maximum: nodeWidth };
720
+ if (nodeHeight)
721
+ sizeP.heightConstraint = { minimum: nodeHeight, maximum: nodeHeight };
459
722
 
460
723
  const factory = RENDERER_MAP[shapeType];
461
724
  if (factory) {
462
- return { shape: 'custom', ctxRenderer: factory(colorKey), ...colorP, ...sizeP };
725
+ return {
726
+ shape: "custom",
727
+ ctxRenderer: factory(colorKey),
728
+ ...colorP,
729
+ ...sizeP,
730
+ };
463
731
  }
464
732
  // Unknown shape — fall back to box
465
- return { shape: 'custom', ctxRenderer: makeBoxRenderer(colorKey), ...colorP, ...sizeP };
733
+ return {
734
+ shape: "custom",
735
+ ctxRenderer: makeBoxRenderer(colorKey),
736
+ ...colorP,
737
+ ...sizeP,
738
+ };
466
739
  }