living-documentation 3.5.0 → 3.7.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.

@@ -0,0 +1,168 @@
1
+ // ── Network initialisation & vis.js event handlers ────────────────────────────
2
+ // Creates the vis.js Network, patches _drawNodes for canonical z-order,
3
+ // and wires all network-level events.
4
+
5
+ import { st, markDirty } from './state.js';
6
+ import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
7
+ import { visEdgeProps } from './edge-rendering.js';
8
+ import { showNodePanel, hideNodePanel } from './node-panel.js';
9
+ import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
10
+ import { startLabelEdit, startEdgeLabelEdit, commitLabelEdit, hideLabelInput } from './label-editor.js';
11
+ import { updateSelectionOverlay, hideSelectionOverlay } from './selection-overlay.js';
12
+ import { drawGrid, onDragEnd } from './grid.js';
13
+ import { drawDebugOverlay } from './debug.js';
14
+ import { updateZoomDisplay } from './zoom.js';
15
+
16
+ export function initNetwork(savedNodes, savedEdges) {
17
+ const container = document.getElementById('vis-canvas');
18
+
19
+ st.nodes = new vis.DataSet(
20
+ savedNodes.map((n) => ({
21
+ ...n,
22
+ ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
23
+ }))
24
+ );
25
+ st.edges = new vis.DataSet(
26
+ savedEdges.map((e) => ({
27
+ ...e,
28
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
29
+ ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
30
+ }))
31
+ );
32
+
33
+ const options = {
34
+ physics: {
35
+ enabled: st.physicsEnabled,
36
+ stabilization: { enabled: false },
37
+ barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3, springLength: 150, springConstant: 0.04, damping: 0.09 },
38
+ },
39
+ interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
40
+ nodes: { font: { size: 13, face: 'system-ui,-apple-system,sans-serif' }, borderWidth: 1.5, borderWidthSelected: 2.5, shadow: false, widthConstraint: { minimum: 60 }, heightConstraint: { minimum: 28 } },
41
+ edges: { smooth: { type: 'continuous' }, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
42
+ manipulation: {
43
+ enabled: false,
44
+ addEdge(data, callback) {
45
+ data.id = 'e' + Date.now();
46
+ data.arrowDir = 'to';
47
+ data.dashes = false;
48
+ Object.assign(data, visEdgeProps('to', false));
49
+ callback(data);
50
+ markDirty();
51
+ setTimeout(() => st.network.addEdgeMode(), 0);
52
+ },
53
+ },
54
+ };
55
+
56
+ if (st.network) st.network.destroy();
57
+ st.network = new vis.Network(container, { nodes: st.nodes, edges: st.edges }, options);
58
+
59
+ // ── Z-order patch ──────────────────────────────────────────────────────────
60
+ // vis.js renders in 3 passes (normal → selected → hovered), which breaks
61
+ // user-defined stacking. We replace _drawNodes with a single pass in
62
+ // canonicalOrder so hover/selection never override the user's z-order.
63
+ st.canonicalOrder = [...st.network.body.nodeIndices];
64
+ st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
65
+ const bodyNodes = this.body.nodes;
66
+ const margin = 20;
67
+ const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
68
+ const bottomRight = this.canvas.DOMtoCanvas({ x: this.canvas.frame.canvas.clientWidth + margin, y: this.canvas.frame.canvas.clientHeight + margin });
69
+ const viewableArea = { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x };
70
+ const drawExternalLabelCallbacks = [];
71
+
72
+ for (const id of st.canonicalOrder) {
73
+ const node = bodyNodes[id];
74
+ if (!node) continue;
75
+ if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
76
+ const r = node.draw(ctx);
77
+ // All shapes are ctxRenderer (shape:'custom') and draw their own labels.
78
+ // Skip drawExternalLabel entirely to avoid double-rendering.
79
+ } else {
80
+ node.updateBoundingBox(ctx, node.selected);
81
+ }
82
+ }
83
+ return { drawExternalLabels() { for (const draw of drawExternalLabelCallbacks) draw(); } };
84
+ };
85
+
86
+ // Keep canonicalOrder in sync with DataSet add/remove events
87
+ st.nodes.on('add', (_, { items }) => {
88
+ const existing = new Set(st.canonicalOrder);
89
+ items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
90
+ });
91
+ st.nodes.on('remove', (_, { items }) => {
92
+ const removed = new Set(items);
93
+ st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
94
+ });
95
+
96
+ st.network.on('doubleClick', onDoubleClick);
97
+ st.network.on('selectNode', onSelectNode);
98
+ st.network.on('deselectNode', onDeselectAll);
99
+ st.network.on('selectEdge', onSelectEdge);
100
+ st.network.on('deselectEdge', onDeselectAll);
101
+ st.network.on('zoom', updateZoomDisplay);
102
+ st.network.on('dragEnd', onDragEnd);
103
+ st.network.on('beforeDrawing', drawGrid);
104
+ st.network.on('afterDrawing', updateSelectionOverlay);
105
+ st.network.on('afterDrawing', () => drawDebugOverlay());
106
+
107
+ document.getElementById('emptyState').classList.add('hidden');
108
+ updateZoomDisplay();
109
+ }
110
+
111
+ // ── Network event handlers ────────────────────────────────────────────────────
112
+
113
+ function onDoubleClick(params) {
114
+ if (params.nodes.length > 0) {
115
+ st.selectedNodeIds = params.nodes;
116
+ st.network.selectNodes(st.selectedNodeIds);
117
+ showNodePanel();
118
+ startLabelEdit();
119
+ } else if (params.edges.length > 0 && params.nodes.length === 0) {
120
+ st.selectedEdgeIds = [params.edges[0]];
121
+ showEdgePanel();
122
+ startEdgeLabelEdit();
123
+ } else if (st.currentTool === 'addNode') {
124
+ const id = 'n' + Date.now();
125
+ const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
126
+ const defaultColor = st.pendingShape === 'post-it' ? 'c-amber' : 'c-gray';
127
+ st.nodes.add({
128
+ id, label: st.pendingShape === 'text-free' ? 'Texte' : 'Node',
129
+ shapeType: st.pendingShape, colorKey: defaultColor,
130
+ nodeWidth: defaults[0], nodeHeight: defaults[1],
131
+ fontSize: null, rotation: 0, labelRotation: 0,
132
+ x: params.pointer.canvas.x, y: params.pointer.canvas.y,
133
+ ...visNodeProps(st.pendingShape, defaultColor, defaults[0], defaults[1], null, null, null),
134
+ });
135
+ markDirty();
136
+ setTimeout(() => {
137
+ st.network.selectNodes([id]);
138
+ st.selectedNodeIds = [id];
139
+ showNodePanel();
140
+ startLabelEdit();
141
+ }, 50);
142
+ }
143
+ }
144
+
145
+ function onSelectNode(params) {
146
+ st.selectedNodeIds = params.nodes;
147
+ st.selectedEdgeIds = [];
148
+ hideEdgePanel();
149
+ showNodePanel();
150
+ }
151
+
152
+ function onSelectEdge(params) {
153
+ if (st.selectedNodeIds.length > 0) return; // node takes priority
154
+ st.selectedEdgeIds = params.edges;
155
+ st.selectedNodeIds = [];
156
+ hideNodePanel();
157
+ showEdgePanel();
158
+ }
159
+
160
+ function onDeselectAll() {
161
+ st.selectedNodeIds = [];
162
+ st.selectedEdgeIds = [];
163
+ hideNodePanel();
164
+ hideEdgePanel();
165
+ commitLabelEdit();
166
+ hideLabelInput();
167
+ hideSelectionOverlay();
168
+ }
@@ -0,0 +1,171 @@
1
+ // ── Node panel ────────────────────────────────────────────────────────────────
2
+ // Floating formatting toolbar for selected nodes (color, font, alignment, z-order).
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { SHAPE_DEFAULTS } from './node-rendering.js';
6
+
7
+ // All shapes are ctxRenderers — vis-network never re-reads the closure after
8
+ // nodes.update(). Force refreshNeeded + redraw so the new colorKey/fontSize/
9
+ // textAlign/textValign values are picked up on the next draw call via st.nodes.get(id).
10
+ function forceRedraw() {
11
+ st.selectedNodeIds.forEach((id) => {
12
+ const bn = st.network && st.network.body.nodes[id];
13
+ if (bn) bn.refreshNeeded = true;
14
+ });
15
+ if (st.network) st.network.redraw();
16
+ }
17
+
18
+ export function showNodePanel() {
19
+ document.getElementById('nodePanel').classList.remove('hidden');
20
+ }
21
+
22
+ export function hideNodePanel() {
23
+ document.getElementById('nodePanel').classList.add('hidden');
24
+ }
25
+
26
+ export function setNodeColor(colorKey) {
27
+ if (!st.selectedNodeIds.length) return;
28
+ st.selectedNodeIds.forEach((id) => {
29
+ const n = st.nodes.get(id);
30
+ if (!n) return;
31
+ st.nodes.update({ id, colorKey });
32
+ });
33
+ forceRedraw();
34
+ markDirty();
35
+ }
36
+
37
+ export function changeNodeFontSize(delta) {
38
+ if (!st.selectedNodeIds.length) return;
39
+ st.selectedNodeIds.forEach((id) => {
40
+ const n = st.nodes.get(id);
41
+ if (!n) return;
42
+ const newSize = Math.max(8, Math.min(48, (n.fontSize || 13) + delta));
43
+ st.nodes.update({ id, fontSize: newSize });
44
+ });
45
+ forceRedraw();
46
+ markDirty();
47
+ }
48
+
49
+ export function setTextAlign(align) {
50
+ if (!st.selectedNodeIds.length) return;
51
+ st.selectedNodeIds.forEach((id) => {
52
+ const n = st.nodes.get(id);
53
+ if (!n) return;
54
+ st.nodes.update({ id, textAlign: align });
55
+ });
56
+ forceRedraw();
57
+ markDirty();
58
+ }
59
+
60
+ export function setTextValign(valign) {
61
+ if (!st.selectedNodeIds.length) return;
62
+ st.selectedNodeIds.forEach((id) => {
63
+ const n = st.nodes.get(id);
64
+ if (!n) return;
65
+ st.nodes.update({ id, textValign: valign });
66
+ });
67
+ forceRedraw();
68
+ markDirty();
69
+ }
70
+
71
+ // ── Stamp (format painter) ────────────────────────────────────────────────────
72
+ // Uses a transparent DOM overlay (#stampOverlay) that intercepts canvas clicks
73
+ // during stamp mode. This bypasses vis-network's event system entirely, avoiding
74
+ // the deselectNode/click ordering problems that make st.activeStamp unreliable.
75
+
76
+ const STAMP_BTNS = { color: 'btnStampColor', rotation: 'btnStampRotation', fontSize: 'btnStampFontSize' };
77
+
78
+ export function activateStamp(type) {
79
+ if (!st.stampTargetIds.length) return; // targets were saved on mousedown
80
+ st.activeStamp = type;
81
+ const overlay = document.getElementById('stampOverlay');
82
+ overlay.style.display = 'block';
83
+ Object.entries(STAMP_BTNS).forEach(([t, id]) =>
84
+ document.getElementById(id).classList.toggle('tool-active', t === type)
85
+ );
86
+ }
87
+
88
+ export function cancelStamp() {
89
+ st.activeStamp = null;
90
+ st.stampTargetIds = [];
91
+ document.getElementById('stampOverlay').style.display = 'none';
92
+ Object.values(STAMP_BTNS).forEach((id) =>
93
+ document.getElementById(id).classList.remove('tool-active')
94
+ );
95
+ }
96
+
97
+ function applyStamp(sourceId) {
98
+ const source = st.nodes.get(sourceId);
99
+ if (!source || !st.activeStamp || !st.stampTargetIds.length) return;
100
+ const type = st.activeStamp;
101
+ const targets = [...st.stampTargetIds]; // snapshot before cancelStamp clears the array
102
+ cancelStamp();
103
+
104
+ targets.forEach((id) => {
105
+ if (id === sourceId) return;
106
+ const target = st.nodes.get(id);
107
+ if (!target) return;
108
+ if (type === 'color') st.nodes.update({ id, colorKey: source.colorKey || 'c-gray' });
109
+ if (type === 'rotation') st.nodes.update({ id, rotation: source.rotation || 0 });
110
+ if (type === 'fontSize') st.nodes.update({ id, fontSize: source.fontSize || 13 });
111
+ const bn = st.network && st.network.body.nodes[id];
112
+ if (bn) bn.refreshNeeded = true;
113
+ });
114
+ if (st.network) st.network.redraw();
115
+ markDirty();
116
+ }
117
+
118
+ // getNodeAt() is unreliable for shape:'custom' (bounding box near-zero).
119
+ // Manual AABB hit test using DOMtoCanvas + node dimensions, topmost node first.
120
+ function getNodeAtDOMPoint(domX, domY) {
121
+ if (!st.network || !st.nodes) return undefined;
122
+ const cp = st.network.DOMtoCanvas({ x: domX, y: domY });
123
+ for (let i = st.canonicalOrder.length - 1; i >= 0; i--) {
124
+ const id = st.canonicalOrder[i];
125
+ const n = st.nodes.get(id);
126
+ const bn = st.network.body.nodes[id];
127
+ if (!n || !bn) continue;
128
+ const defaults = SHAPE_DEFAULTS[n.shapeType] || [100, 40];
129
+ const w = n.nodeWidth || defaults[0];
130
+ const h = n.nodeHeight || defaults[1];
131
+ const rot = n.rotation || 0;
132
+ let hw, hh;
133
+ if (rot === 0) {
134
+ hw = w / 2; hh = h / 2;
135
+ } else {
136
+ const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
137
+ hw = (w * cos + h * sin) / 2;
138
+ hh = (w * sin + h * cos) / 2;
139
+ }
140
+ if (Math.abs(cp.x - bn.x) <= hw && Math.abs(cp.y - bn.y) <= hh) return id;
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ // Wire the stamp overlay click.
146
+ document.getElementById('stampOverlay').addEventListener('click', (e) => {
147
+ if (!st.activeStamp || !st.network) return;
148
+ const rect = document.getElementById('vis-canvas').getBoundingClientRect();
149
+ const nodeId = getNodeAtDOMPoint(e.clientX - rect.left, e.clientY - rect.top);
150
+ if (nodeId !== undefined) {
151
+ applyStamp(nodeId);
152
+ } else {
153
+ cancelStamp();
154
+ }
155
+ });
156
+
157
+ export function changeZOrder(direction) {
158
+ // direction: +1 = bring to front (last in canonicalOrder = drawn on top)
159
+ // -1 = send to back (first in canonicalOrder = drawn below)
160
+ if (!st.selectedNodeIds.length) return;
161
+ st.selectedNodeIds.forEach((id) => {
162
+ const idx = st.canonicalOrder.indexOf(id);
163
+ if (idx === -1) return;
164
+ st.canonicalOrder.splice(idx, 1);
165
+ if (direction > 0) st.canonicalOrder.push(id);
166
+ else st.canonicalOrder.unshift(id);
167
+ });
168
+ st.network.redraw();
169
+ st.network.selectNodes(st.selectedNodeIds);
170
+ markDirty();
171
+ }
@@ -0,0 +1,336 @@
1
+ // ── Node rendering ────────────────────────────────────────────────────────────
2
+ // All shapes are ctxRenderers so rotation (ctx.rotate) works uniformly.
3
+ // Each renderer reads live node data from st.nodes.get(id) on every draw call
4
+ // (vis-network caches the ctxRenderer reference and never re-reads it from the
5
+ // DataSet, so dimensions/rotation/alignment must be fetched at draw time).
6
+
7
+ import { NODE_COLORS } from './constants.js';
8
+ import { st } from './state.js';
9
+
10
+ // ── Drawing helpers ───────────────────────────────────────────────────────────
11
+
12
+ // Draw multi-line label centred at (0,0) in the current (possibly rotated) ctx.
13
+ // labelRotation is applied around the label's own centre (independent of shape rotation).
14
+ function drawLabel(ctx, label, fontSize, color, textAlign, textValign, W, H, labelRotation) {
15
+ if (!label) return;
16
+ const pad = 8;
17
+ ctx.save();
18
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
19
+ ctx.fillStyle = color;
20
+ ctx.textBaseline = 'middle';
21
+
22
+ let xPos = 0;
23
+ if (textAlign === 'left') { ctx.textAlign = 'left'; xPos = W ? -W / 2 + pad : -40; }
24
+ else if (textAlign === 'right') { ctx.textAlign = 'right'; xPos = W ? W / 2 - pad : 40; }
25
+ else { ctx.textAlign = 'center'; xPos = 0; }
26
+
27
+ let yOff = 0;
28
+ if (W && H) {
29
+ if (textValign === 'top') yOff = -(H / 2 - fontSize / 2 - pad);
30
+ else if (textValign === 'bottom') yOff = H / 2 - fontSize / 2 - pad;
31
+ }
32
+
33
+ const lines = String(label).split('\n');
34
+ const lineH = fontSize * 1.3;
35
+ const startY = yOff - (lines.length - 1) * lineH / 2;
36
+
37
+ // Apply independent label rotation around the label centre.
38
+ if (labelRotation) ctx.rotate(labelRotation);
39
+
40
+ lines.forEach((line, i) => ctx.fillText(line, xPos, startY + i * lineH));
41
+ ctx.restore();
42
+ }
43
+
44
+ // Polyfill-safe rounded rectangle (ctx.roundRect not universally available).
45
+ function roundRect(ctx, x, y, w, h, r) {
46
+ r = Math.min(r, w / 2, h / 2);
47
+ ctx.beginPath();
48
+ ctx.moveTo(x + r, y);
49
+ ctx.lineTo(x + w - r, y);
50
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
51
+ ctx.lineTo(x + w, y + h - r);
52
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
53
+ ctx.lineTo(x + r, y + h);
54
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
55
+ ctx.lineTo(x, y + r);
56
+ ctx.quadraticCurveTo(x, y, x + r, y);
57
+ ctx.closePath();
58
+ }
59
+
60
+ // Read ALL live node data from the DataSet on every draw call.
61
+ // vis-network caches the ctxRenderer closure and never re-reads it after
62
+ // nodes.update(), so colour, font size, dimensions, rotation, and alignment
63
+ // must all be fetched here to reflect the latest values.
64
+ function nodeData(id, defaultW, defaultH, defaultColorKey) {
65
+ const n = st.nodes && st.nodes.get(id);
66
+ const colorKey = (n && n.colorKey) || defaultColorKey || 'c-gray';
67
+ return {
68
+ W: (n && n.nodeWidth) || defaultW,
69
+ H: (n && n.nodeHeight) || defaultH,
70
+ rotation: (n && n.rotation) || 0,
71
+ labelRotation: (n && n.labelRotation) || 0,
72
+ textAlign: (n && n.textAlign) || 'center',
73
+ textValign: (n && n.textValign) || 'middle',
74
+ fontSize: (n && n.fontSize) || 13,
75
+ colorKey,
76
+ c: NODE_COLORS[colorKey] || NODE_COLORS['c-gray'],
77
+ };
78
+ }
79
+
80
+ // ── Shape renderers ───────────────────────────────────────────────────────────
81
+
82
+ export function makeBoxRenderer(colorKey) {
83
+ return function ({ ctx, x, y, id, state: visState, label }) {
84
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 100, 40, colorKey);
85
+ return {
86
+ drawNode() {
87
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
88
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
89
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
90
+ ctx.lineWidth = 1.5;
91
+ roundRect(ctx, -W / 2, -H / 2, W, H, 4);
92
+ ctx.fill(); ctx.stroke();
93
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
94
+ ctx.restore();
95
+ },
96
+ nodeDimensions: { width: W, height: H },
97
+ };
98
+ };
99
+ }
100
+
101
+ export function makeEllipseRenderer(colorKey) {
102
+ return function ({ ctx, x, y, id, state: visState, label }) {
103
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 110, 50, colorKey);
104
+ return {
105
+ drawNode() {
106
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
107
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
108
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
109
+ ctx.lineWidth = 1.5;
110
+ ctx.beginPath(); ctx.ellipse(0, 0, W / 2, H / 2, 0, 0, Math.PI * 2);
111
+ ctx.fill(); ctx.stroke();
112
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
113
+ ctx.restore();
114
+ },
115
+ nodeDimensions: { width: W, height: H },
116
+ };
117
+ };
118
+ }
119
+
120
+ export function makeCircleRenderer(colorKey) {
121
+ return function ({ ctx, x, y, id, state: visState, label }) {
122
+ const { W, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 55, 55, colorKey);
123
+ const R = W / 2;
124
+ return {
125
+ drawNode() {
126
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
127
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
128
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
129
+ ctx.lineWidth = 1.5;
130
+ ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2);
131
+ ctx.fill(); ctx.stroke();
132
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, W, labelRotation);
133
+ ctx.restore();
134
+ },
135
+ nodeDimensions: { width: W, height: W },
136
+ };
137
+ };
138
+ }
139
+
140
+ export function makeDatabaseRenderer(colorKey) {
141
+ return function ({ ctx, x, y, id, state: visState, label }) {
142
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 50, 70, colorKey);
143
+ const rx = W / 2;
144
+ const ry = Math.max(H * 0.12, 6);
145
+ const bodyTop = -H / 2 + ry;
146
+ const bodyBottom = H / 2 - ry;
147
+ return {
148
+ drawNode() {
149
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
150
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
151
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
152
+ ctx.lineWidth = 1.5;
153
+ ctx.fillRect(-rx, bodyTop, W, bodyBottom - bodyTop);
154
+ ctx.beginPath(); ctx.ellipse(0, bodyBottom, rx, ry, 0, 0, Math.PI * 2);
155
+ ctx.fill(); ctx.stroke();
156
+ ctx.beginPath();
157
+ ctx.moveTo(-rx, bodyTop); ctx.lineTo(-rx, bodyBottom);
158
+ ctx.moveTo( rx, bodyTop); ctx.lineTo( rx, bodyBottom);
159
+ ctx.stroke();
160
+ ctx.beginPath(); ctx.ellipse(0, bodyTop, rx, ry, 0, 0, Math.PI * 2);
161
+ ctx.fill(); ctx.stroke();
162
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
163
+ ctx.restore();
164
+ },
165
+ nodeDimensions: { width: W, height: H },
166
+ };
167
+ };
168
+ }
169
+
170
+ // Post-IT: sticky note with folded top-right corner.
171
+ export function makePostItRenderer(colorKey) {
172
+ return function ({ ctx, x, y, id, state: visState, label }) {
173
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 120, 100, colorKey || 'c-amber');
174
+ const fold = Math.min(W, H) * 0.18;
175
+ return {
176
+ 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;
181
+ ctx.beginPath();
182
+ ctx.moveTo(-W / 2, -H / 2);
183
+ ctx.lineTo( W / 2 - fold, -H / 2);
184
+ ctx.lineTo( W / 2, -H / 2 + fold);
185
+ ctx.lineTo( W / 2, H / 2);
186
+ ctx.lineTo(-W / 2, H / 2);
187
+ ctx.closePath();
188
+ ctx.fill(); ctx.stroke();
189
+ ctx.globalAlpha = 0.3;
190
+ ctx.fillStyle = c.border;
191
+ ctx.beginPath();
192
+ ctx.moveTo(W / 2 - fold, -H / 2);
193
+ ctx.lineTo(W / 2, -H / 2 + fold);
194
+ ctx.lineTo(W / 2 - fold, -H / 2 + fold);
195
+ ctx.closePath();
196
+ ctx.fill();
197
+ ctx.globalAlpha = 1;
198
+ ctx.beginPath();
199
+ ctx.moveTo(W / 2 - fold, -H / 2);
200
+ ctx.lineTo(W / 2 - fold, -H / 2 + fold);
201
+ ctx.lineTo(W / 2, -H / 2 + fold);
202
+ ctx.stroke();
203
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
204
+ ctx.restore();
205
+ },
206
+ nodeDimensions: { width: W, height: H },
207
+ };
208
+ };
209
+ }
210
+
211
+ // Free Text: no visible border or background — just the label.
212
+ export function makeTextFreeRenderer(colorKey) {
213
+ return function ({ ctx, x, y, id, state: visState, label }) {
214
+ const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 80, 30, colorKey);
215
+ return {
216
+ drawNode() {
217
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
218
+ if (visState.selected || visState.hover) {
219
+ ctx.strokeStyle = '#f97316';
220
+ ctx.lineWidth = 1;
221
+ ctx.setLineDash([4, 3]);
222
+ roundRect(ctx, -W / 2, -H / 2, W, H, 3);
223
+ ctx.stroke();
224
+ ctx.setLineDash([]);
225
+ }
226
+ drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
227
+ ctx.restore();
228
+ },
229
+ nodeDimensions: { width: W, height: H },
230
+ };
231
+ };
232
+ }
233
+
234
+ // ── Actor (stick figure) ──────────────────────────────────────────────────────
235
+ const ACTOR_W0 = 30;
236
+ const ACTOR_H0 = 52;
237
+
238
+ export function makeActorRenderer(colorKey) {
239
+ return function ({ ctx, x, y, id, state: visState, label }) {
240
+ const { W, H, rotation, labelRotation, fontSize, c } = nodeData(id, ACTOR_W0, ACTOR_H0, colorKey);
241
+ const sx = W / ACTOR_W0;
242
+ const sy = H / ACTOR_H0;
243
+ return {
244
+ drawNode() {
245
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
246
+ ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
247
+ ctx.fillStyle = visState.selected ? c.hbg : c.bg;
248
+ ctx.lineWidth = 2;
249
+ ctx.lineCap = 'round';
250
+ ctx.beginPath(); ctx.arc(0, -20 * sy, 8 * sy, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
251
+ ctx.beginPath(); ctx.moveTo(0, -12 * sy); ctx.lineTo(0, 8 * sy); ctx.stroke();
252
+ ctx.beginPath(); ctx.moveTo(-13 * sx, -3 * sy); ctx.lineTo(13 * sx, -3 * sy); ctx.stroke();
253
+ ctx.beginPath(); ctx.moveTo(0, 8 * sy); ctx.lineTo(-10 * sx, 24 * sy); ctx.stroke();
254
+ ctx.beginPath(); ctx.moveTo(0, 8 * sy); ctx.lineTo( 10 * sx, 24 * sy); ctx.stroke();
255
+ // Label below figure (rotates with the actor)
256
+ if (label) {
257
+ ctx.save();
258
+ if (labelRotation) ctx.rotate(labelRotation);
259
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
260
+ ctx.fillStyle = c.font;
261
+ ctx.textAlign = 'center';
262
+ ctx.textBaseline = 'top';
263
+ const lines = String(label).split('\n');
264
+ const lineH = fontSize * 1.3;
265
+ const startY = 24 * sy + 4;
266
+ lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
267
+ ctx.restore();
268
+ }
269
+ ctx.restore();
270
+ },
271
+ nodeDimensions: { width: W, height: H },
272
+ };
273
+ };
274
+ }
275
+
276
+ // ── Public API ────────────────────────────────────────────────────────────────
277
+
278
+ // Returns the rendered height from vis-network internals (used by node-panel
279
+ // for vadjust calculations — legacy, kept for API compat).
280
+ export function getActualNodeHeight(id) {
281
+ if (!st.network) return null;
282
+ const bn = st.network.body.nodes[id];
283
+ return bn && bn.shape && bn.shape.height ? bn.shape.height : null;
284
+ }
285
+
286
+ // Kept for backward compat (called by node-panel but irrelevant for ctxRenderers).
287
+ export function computeVadjust() { return 0; }
288
+
289
+ const RENDERER_MAP = {
290
+ box: makeBoxRenderer,
291
+ ellipse: makeEllipseRenderer,
292
+ circle: makeCircleRenderer,
293
+ database: makeDatabaseRenderer,
294
+ 'post-it': makePostItRenderer,
295
+ 'text-free':makeTextFreeRenderer,
296
+ actor: makeActorRenderer,
297
+ };
298
+
299
+ // Default dimensions per shape type (used when nodeWidth/nodeHeight are null).
300
+ export const SHAPE_DEFAULTS = {
301
+ box: [100, 40],
302
+ ellipse: [110, 50],
303
+ circle: [55, 55],
304
+ database: [50, 70],
305
+ actor: [30, 52],
306
+ 'post-it': [120, 100],
307
+ 'text-free':[80, 30],
308
+ };
309
+
310
+ // Builds the full vis.js node property object.
311
+ // All shapes are rendered via ctxRenderer so rotation works uniformly.
312
+ export function visNodeProps(shapeType, colorKey, nodeWidth, nodeHeight, fontSize, textAlign, _textValign) {
313
+ const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
314
+ const size = fontSize || 13;
315
+ const align = textAlign || 'center';
316
+
317
+ const colorP = {
318
+ color: {
319
+ background: c.bg, border: c.border,
320
+ highlight: { background: c.hbg, border: c.hborder },
321
+ hover: { background: c.hbg, border: c.hborder },
322
+ },
323
+ font: { color: c.font, size, face: 'system-ui,-apple-system,sans-serif', align },
324
+ };
325
+
326
+ const sizeP = {};
327
+ if (nodeWidth) sizeP.widthConstraint = { minimum: nodeWidth, maximum: nodeWidth };
328
+ if (nodeHeight) sizeP.heightConstraint = { minimum: nodeHeight, maximum: nodeHeight };
329
+
330
+ const factory = RENDERER_MAP[shapeType];
331
+ if (factory) {
332
+ return { shape: 'custom', ctxRenderer: factory(colorKey), ...colorP, ...sizeP };
333
+ }
334
+ // Unknown shape — fall back to box
335
+ return { shape: 'custom', ctxRenderer: makeBoxRenderer(colorKey), ...colorP, ...sizeP };
336
+ }