living-documentation 3.2.0 → 3.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.
@@ -0,0 +1,1183 @@
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Diagram — Living Documentation</title>
7
+ <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
8
+ <script>tailwind.config = { darkMode: 'class', theme: { extend: {} } };</script>
9
+ <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
10
+ <style>
11
+ #vis-canvas > div { border: none !important; }
12
+ .tool-btn {
13
+ display: flex; align-items: center; justify-content: center;
14
+ width: 2rem; height: 2rem; border-radius: 0.5rem;
15
+ font-size: 0.875rem; cursor: pointer; flex-shrink: 0;
16
+ transition: background-color 0.15s, color 0.15s;
17
+ color: #4b5563; background: none; border: none;
18
+ }
19
+ .dark .tool-btn { color: #9ca3af; }
20
+ .tool-btn:hover { background: #f3f4f6; }
21
+ .dark .tool-btn:hover { background: #1f2937; }
22
+ .tool-active { background: #fff7ed !important; color: #ea580c !important; }
23
+ .dark .tool-active { background: rgba(124,45,18,0.3) !important; color: #fb923c !important; }
24
+ #vis-canvas.cursor-crosshair canvas { cursor: crosshair !important; }
25
+
26
+ /* Floating panels */
27
+ .float-panel {
28
+ position: absolute; top: 0.75rem; left: 50%; transform: translateX(-50%);
29
+ display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.5rem;
30
+ background: white; border: 1px solid #e5e7eb; border-radius: 0.5rem;
31
+ box-shadow: 0 4px 12px rgba(0,0,0,.10); z-index: 10;
32
+ }
33
+ .dark .float-panel { background: #1f2937; border-color: #374151; }
34
+ .float-panel.hidden { display: none !important; }
35
+ .panel-sep { width:1px; height:1rem; background:#e5e7eb; margin:0 .125rem; flex-shrink:0; }
36
+ .dark .panel-sep { background:#374151; }
37
+ .edge-btn-active { background:#fff7ed!important; color:#ea580c!important; }
38
+ .dark .edge-btn-active { background:rgba(124,45,18,.3)!important; color:#fb923c!important; }
39
+
40
+ /* Floating label textarea */
41
+ #labelInput {
42
+ position: absolute; z-index: 20;
43
+ padding: 4px 8px; font-size: 13px;
44
+ font-family: system-ui,-apple-system,sans-serif; font-weight: 500;
45
+ text-align: center;
46
+ background: rgba(255,255,255,.95); color: #1f2937;
47
+ border: 2px solid #3b82f6; border-radius: .5rem;
48
+ box-shadow: 0 4px 16px rgba(0,0,0,.15);
49
+ outline: none; resize: none; overflow: hidden;
50
+ line-height: 1.4; min-width: 80px; min-height: 28px;
51
+ }
52
+ .dark #labelInput { background: rgba(17,24,39,.95); color: #f3f4f6; }
53
+ #labelInput.hidden { display: none; }
54
+
55
+ /* Resize / selection overlay */
56
+ #selectionOverlay {
57
+ position: absolute; display: none;
58
+ border: 2px dashed #f97316; border-radius: 3px;
59
+ pointer-events: none; z-index: 15; box-sizing: border-box;
60
+ }
61
+ .resize-handle {
62
+ position: absolute; width: 10px; height: 10px;
63
+ background: white; border: 2px solid #f97316; border-radius: 2px;
64
+ pointer-events: all; z-index: 1;
65
+ }
66
+ .dark .resize-handle { background: #374151; }
67
+ </style>
68
+ </head>
69
+ <body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 flex flex-col h-screen overflow-hidden">
70
+
71
+ <script>
72
+ const _d = localStorage.getItem('ld-dark') === 'true';
73
+ document.documentElement.classList.toggle('dark', _d);
74
+ </script>
75
+
76
+ <!-- ── Top bar ── -->
77
+ <header class="flex items-center gap-1 px-2 h-12 shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm z-10">
78
+ <a href="/" class="tool-btn !w-auto px-2 text-xs font-medium text-gray-500 dark:text-gray-400">← Docs</a>
79
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
80
+ <button onclick="toggleSidebar()" class="tool-btn" title="Mes diagrammes">☰</button>
81
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
82
+
83
+ <button id="toolSelect" onclick="setTool('select')" class="tool-btn tool-active" title="Sélectionner (S)">
84
+ <svg width="11" height="13" viewBox="0 0 11 13" fill="currentColor" stroke="none"><path d="M1 1 L1 12 L4 9 L6.5 13 L8 12.2 L5.5 8.2 L10 8.2 Z"/></svg>
85
+ </button>
86
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
87
+
88
+ <button id="toolBox" onclick="setTool('addNode','box')" class="tool-btn" title="Rectangle (R)">
89
+ <svg width="15" height="10" viewBox="0 0 15 10" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="13" height="8" rx="1"/></svg>
90
+ </button>
91
+ <button id="toolEllipse" onclick="setTool('addNode','ellipse')" class="tool-btn" title="Ellipse (E)">
92
+ <svg width="16" height="10" viewBox="0 0 16 10" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="5" rx="7" ry="4"/></svg>
93
+ </button>
94
+ <button id="toolDatabase" onclick="setTool('addNode','database')" class="tool-btn" title="Base de données (D)">
95
+ <svg width="12" height="16" viewBox="0 0 12 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
96
+ <ellipse cx="6" cy="3" rx="5" ry="2"/>
97
+ <path d="M1 3v10c0 1.1 2.2 2 5 2s5-.9 5-2V3"/>
98
+ <path d="M11 7.5c0 1.1-2.2 2-5 2s-5-.9-5-2"/>
99
+ </svg>
100
+ </button>
101
+ <button id="toolCircle" onclick="setTool('addNode','circle')" class="tool-btn" title="Cercle (C)">
102
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6.5" cy="6.5" r="5.5"/></svg>
103
+ </button>
104
+ <button id="toolActor" onclick="setTool('addNode','actor')" class="tool-btn" title="Acteur (A)">
105
+ <svg width="12" height="17" viewBox="0 0 12 17" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
106
+ <circle cx="6" cy="3" r="2.2"/>
107
+ <line x1="6" y1="5.2" x2="6" y2="10"/>
108
+ <line x1="1.5" y1="7.5" x2="10.5" y2="7.5"/>
109
+ <line x1="6" y1="10" x2="2.5" y2="15"/>
110
+ <line x1="6" y1="10" x2="9.5" y2="15"/>
111
+ </svg>
112
+ </button>
113
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
114
+
115
+ <button id="toolArrow" onclick="setTool('addEdge')" class="tool-btn" title="Flèche (F)">→</button>
116
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
117
+
118
+ <button onclick="deleteSelected()" class="tool-btn" title="Supprimer (Suppr)">
119
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
120
+ <line x1="1" y1="1" x2="10" y2="10"/><line x1="10" y1="1" x2="1" y2="10"/>
121
+ </svg>
122
+ </button>
123
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
124
+
125
+ <button id="btnPhysics" onclick="togglePhysics()" class="tool-btn tool-active" title="Auto-espacement actif">
126
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4">
127
+ <circle cx="7" cy="7" r="2"/>
128
+ <path d="M7 1.5v2M7 10.5v2M1.5 7h2M10.5 7h2M3.4 3.4l1.4 1.4M9.2 9.2l1.4 1.4M10.6 3.4l-1.4 1.4M4.8 9.2l-1.4 1.4"/>
129
+ </svg>
130
+ </button>
131
+ <button id="btnGrid" onclick="toggleGrid()" class="tool-btn" title="Grille / Snap to grid (G)">
132
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2">
133
+ <line x1="5" y1="1" x2="5" y2="13"/><line x1="9" y1="1" x2="9" y2="13"/>
134
+ <line x1="1" y1="5" x2="13" y2="5"/><line x1="1" y1="9" x2="13" y2="9"/>
135
+ <rect x="1" y="1" width="12" height="12" rx="1"/>
136
+ </svg>
137
+ </button>
138
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
139
+
140
+ <input id="diagramTitle" type="text" placeholder="Titre du diagramme"
141
+ class="flex-1 min-w-0 px-2 py-1 text-sm bg-transparent border-0 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-gray-700 dark:text-gray-300 placeholder:text-gray-400"/>
142
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
143
+
144
+ <button onclick="adjustZoom(-0.2)" class="tool-btn" title="Zoom −">−</button>
145
+ <span id="zoomLevel" class="text-xs text-gray-500 dark:text-gray-400 w-10 text-center tabular-nums select-none">100%</span>
146
+ <button onclick="adjustZoom(0.2)" class="tool-btn" title="Zoom +">+</button>
147
+ <button onclick="resetZoom()" class="tool-btn" title="Ajuster la vue">⊡</button>
148
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
149
+
150
+ <button onclick="toggleDark()" class="tool-btn"><span id="darkIcon">☽</span></button>
151
+ <div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
152
+
153
+ <button id="btnSave" onclick="saveDiagram()" disabled
154
+ class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40 disabled:cursor-not-allowed shrink-0 transition-colors">
155
+ Enregistrer
156
+ </button>
157
+ </header>
158
+
159
+ <!-- ── Body ── -->
160
+ <div class="flex flex-1 overflow-hidden">
161
+
162
+ <!-- Sidebar -->
163
+ <div id="sidebar"
164
+ class="w-56 shrink-0 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex flex-col overflow-hidden"
165
+ style="transition: width 0.2s ease;">
166
+ <div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700 shrink-0">
167
+ <span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Diagrammes</span>
168
+ <button onclick="newDiagram()" class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors">+ Nouveau</button>
169
+ </div>
170
+ <div id="diagramList" class="flex-1 overflow-y-auto py-1"></div>
171
+ </div>
172
+
173
+ <!-- Canvas area -->
174
+ <div class="relative flex-1 overflow-hidden bg-gray-50 dark:bg-gray-950">
175
+
176
+ <div id="vis-canvas" class="w-full h-full"></div>
177
+
178
+ <!-- Selection / resize overlay -->
179
+ <div id="selectionOverlay">
180
+ <div id="rh-tl" class="resize-handle" style="top:-5px;left:-5px; cursor:nw-resize;"></div>
181
+ <div id="rh-tr" class="resize-handle" style="top:-5px;right:-5px; cursor:ne-resize;"></div>
182
+ <div id="rh-bl" class="resize-handle" style="bottom:-5px;left:-5px; cursor:sw-resize;"></div>
183
+ <div id="rh-br" class="resize-handle" style="bottom:-5px;right:-5px; cursor:se-resize;"></div>
184
+ </div>
185
+
186
+ <!-- Node panel -->
187
+ <div id="nodePanel" class="float-panel hidden">
188
+ <button onclick="setNodeColor('c-gray')" class="w-5 h-5 rounded-full border-2 border-stone-400 bg-stone-200 hover:scale-125 transition-transform" title="Gris"></button>
189
+ <button onclick="setNodeColor('c-blue')" class="w-5 h-5 rounded-full border-2 border-blue-500 bg-blue-200 hover:scale-125 transition-transform" title="Bleu"></button>
190
+ <button onclick="setNodeColor('c-green')" class="w-5 h-5 rounded-full border-2 border-green-500 bg-green-200 hover:scale-125 transition-transform" title="Vert"></button>
191
+ <button onclick="setNodeColor('c-amber')" class="w-5 h-5 rounded-full border-2 border-amber-500 bg-amber-200 hover:scale-125 transition-transform" title="Ambre"></button>
192
+ <button onclick="setNodeColor('c-rose')" class="w-5 h-5 rounded-full border-2 border-rose-500 bg-rose-200 hover:scale-125 transition-transform" title="Rose"></button>
193
+ <button onclick="setNodeColor('c-purple')" class="w-5 h-5 rounded-full border-2 border-purple-500 bg-purple-200 hover:scale-125 transition-transform" title="Violet"></button>
194
+ <button onclick="setNodeColor('c-teal')" class="w-5 h-5 rounded-full border-2 border-teal-500 bg-teal-200 hover:scale-125 transition-transform" title="Teal"></button>
195
+ <div class="panel-sep"></div>
196
+ <button onclick="startLabelEdit()" class="tool-btn !w-6 !h-6" title="Modifier le texte (double-clic)">✎</button>
197
+ <div class="panel-sep"></div>
198
+ <button onclick="changeNodeFontSize(-1)" class="tool-btn !w-8 !h-6" style="font-size:10px;" title="Réduire la police">Aa−</button>
199
+ <button onclick="changeNodeFontSize( 1)" class="tool-btn !w-8 !h-6" style="font-size:10px;" title="Agrandir la police">Aa+</button>
200
+ <div class="panel-sep"></div>
201
+ <!-- Horizontal align -->
202
+ <button onclick="setTextAlign('left')" class="tool-btn !w-6 !h-6" title="Aligner à gauche">
203
+ <svg width="12" height="10" viewBox="0 0 12 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
204
+ <line x1="1" y1="2" x2="11" y2="2"/><line x1="1" y1="5" x2="7" y2="5"/><line x1="1" y1="8" x2="9" y2="8"/>
205
+ </svg>
206
+ </button>
207
+ <button onclick="setTextAlign('center')" class="tool-btn !w-6 !h-6" title="Centrer horizontalement">
208
+ <svg width="12" height="10" viewBox="0 0 12 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
209
+ <line x1="1" y1="2" x2="11" y2="2"/><line x1="2.5" y1="5" x2="9.5" y2="5"/><line x1="1.5" y1="8" x2="10.5" y2="8"/>
210
+ </svg>
211
+ </button>
212
+ <button onclick="setTextAlign('right')" class="tool-btn !w-6 !h-6" title="Aligner à droite">
213
+ <svg width="12" height="10" viewBox="0 0 12 10" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
214
+ <line x1="1" y1="2" x2="11" y2="2"/><line x1="5" y1="5" x2="11" y2="5"/><line x1="3" y1="8" x2="11" y2="8"/>
215
+ </svg>
216
+ </button>
217
+ <div class="panel-sep"></div>
218
+ <!-- Vertical align -->
219
+ <button onclick="setTextValign('top')" class="tool-btn !w-6 !h-6" title="Aligner en haut">
220
+ <svg width="10" height="12" viewBox="0 0 10 12" fill="none" stroke="currentColor" stroke-linecap="round">
221
+ <line x1="1" y1="1.5" x2="9" y2="1.5" stroke-width="2"/>
222
+ <line x1="1" y1="5" x2="8" y2="5" stroke-width="1.3"/>
223
+ <line x1="1" y1="8.5" x2="6" y2="8.5" stroke-width="1.3"/>
224
+ </svg>
225
+ </button>
226
+ <button onclick="setTextValign('middle')" class="tool-btn !w-6 !h-6" title="Centrer verticalement">
227
+ <svg width="10" height="12" viewBox="0 0 10 12" fill="none" stroke="currentColor" stroke-linecap="round">
228
+ <line x1="1" y1="2.5" x2="8" y2="2.5" stroke-width="1.3"/>
229
+ <line x1="1" y1="6" x2="9" y2="6" stroke-width="2"/>
230
+ <line x1="1" y1="9.5" x2="6" y2="9.5" stroke-width="1.3"/>
231
+ </svg>
232
+ </button>
233
+ <button onclick="setTextValign('bottom')" class="tool-btn !w-6 !h-6" title="Aligner en bas">
234
+ <svg width="10" height="12" viewBox="0 0 10 12" fill="none" stroke="currentColor" stroke-linecap="round">
235
+ <line x1="1" y1="3.5" x2="8" y2="3.5" stroke-width="1.3"/>
236
+ <line x1="1" y1="7" x2="6" y2="7" stroke-width="1.3"/>
237
+ <line x1="1" y1="10.5" x2="9" y2="10.5" stroke-width="2"/>
238
+ </svg>
239
+ </button>
240
+ <div class="panel-sep"></div>
241
+ <button onclick="changeZOrder(-1)" class="tool-btn !w-7 !h-6" title="Envoyer en arrière">
242
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
243
+ <rect x="1" y="5" width="8" height="8" rx="1"/>
244
+ <rect x="5" y="1" width="8" height="8" rx="1" stroke-opacity="0.35"/>
245
+ </svg>
246
+ </button>
247
+ <button onclick="changeZOrder( 1)" class="tool-btn !w-7 !h-6" title="Amener au premier plan">
248
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
249
+ <rect x="1" y="5" width="8" height="8" rx="1" stroke-opacity="0.35"/>
250
+ <rect x="5" y="1" width="8" height="8" rx="1"/>
251
+ </svg>
252
+ </button>
253
+ </div>
254
+
255
+ <!-- Edge panel -->
256
+ <div id="edgePanel" class="float-panel hidden">
257
+ <button id="edgeBtnNone" onclick="setEdgeArrow('none')" class="tool-btn !w-7 !h-6 font-mono text-xs" title="Trait simple">—</button>
258
+ <button id="edgeBtnTo" onclick="setEdgeArrow('to')" class="tool-btn !w-7 !h-6 text-xs" title="Flèche directionnelle">→</button>
259
+ <button id="edgeBtnBoth" onclick="setEdgeArrow('both')" class="tool-btn !w-8 !h-6 text-xs" title="Bidirectionnelle">←→</button>
260
+ <div class="panel-sep"></div>
261
+ <button id="edgeBtnSolid" onclick="setEdgeDashes(false)" class="tool-btn !w-8 !h-6" title="Trait plein">
262
+ <svg width="22" height="4" viewBox="0 0 22 4"><line x1="1" y1="2" x2="21" y2="2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
263
+ </button>
264
+ <button id="edgeBtnDashed" onclick="setEdgeDashes(true)" class="tool-btn !w-8 !h-6" title="Pointillé">
265
+ <svg width="22" height="4" viewBox="0 0 22 4"><line x1="1" y1="2" x2="21" y2="2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="4,3"/></svg>
266
+ </button>
267
+ <div class="panel-sep"></div>
268
+ <button onclick="changeEdgeFontSize(-1)" class="tool-btn !w-8 !h-6" style="font-size:10px;" title="Réduire la police">Aa−</button>
269
+ <button onclick="changeEdgeFontSize( 1)" class="tool-btn !w-8 !h-6" style="font-size:10px;" title="Agrandir la police">Aa+</button>
270
+ <div class="panel-sep"></div>
271
+ <button onclick="startEdgeLabelEdit()" class="tool-btn !w-6 !h-6" title="Modifier le libellé de la flèche">✎</button>
272
+ </div>
273
+
274
+ <!-- Floating label textarea -->
275
+ <textarea id="labelInput" class="hidden" rows="1" placeholder="Label…"></textarea>
276
+
277
+ <!-- Empty state -->
278
+ <div id="emptyState"
279
+ class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 dark:text-gray-600 pointer-events-none select-none">
280
+ <svg width="52" height="44" viewBox="0 0 52 44" fill="none" stroke="currentColor" stroke-width="1.5" class="mb-3 opacity-50">
281
+ <rect x="2" y="4" width="18" height="12" rx="2"/>
282
+ <rect x="32" y="28" width="18" height="12" rx="2"/>
283
+ <line x1="20" y1="10" x2="32" y2="34" stroke-dasharray="4,3"/>
284
+ <rect x="17" y="16" width="18" height="12" rx="2"/>
285
+ <line x1="26" y1="16" x2="26" y2="10" stroke-dasharray="4,3"/>
286
+ </svg>
287
+ <p class="text-sm">Sélectionne ou crée un diagramme</p>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <script>
293
+ // ── State ─────────────────────────────────────────────────────────────────
294
+ let network = null;
295
+ let nodes = null;
296
+ let edges = null;
297
+ let diagrams = [];
298
+ let currentDiagramId = null;
299
+ let currentTool = 'select';
300
+ let pendingShape = 'box';
301
+ let selectedNodeIds = [];
302
+ let selectedEdgeIds = [];
303
+ let physicsEnabled = true;
304
+ let gridEnabled = false;
305
+ const GRID_SIZE = 40;
306
+ let isDirty = false;
307
+ let sidebarOpen = true;
308
+ let editingNodeId = null;
309
+ let editingEdgeId = null;
310
+ let resizeDrag = null;
311
+ let clipboard = null; // { nodes: [], edges: [] }
312
+ let _canonicalOrder = []; // canonical z-order (DataSet-independent, immune to vis.js hover reordering)
313
+
314
+ // ── Colors ────────────────────────────────────────────────────────────────
315
+ const NODE_COLORS = {
316
+ 'c-gray': { bg:'#f5f5f4', border:'#a8a29e', font:'#292524', hbg:'#e7e5e4', hborder:'#78716c' },
317
+ 'c-blue': { bg:'#dbeafe', border:'#3b82f6', font:'#1e40af', hbg:'#bfdbfe', hborder:'#2563eb' },
318
+ 'c-green': { bg:'#dcfce7', border:'#22c55e', font:'#166534', hbg:'#bbf7d0', hborder:'#16a34a' },
319
+ 'c-amber': { bg:'#fef9c3', border:'#f59e0b', font:'#78350f', hbg:'#fef08a', hborder:'#d97706' },
320
+ 'c-rose': { bg:'#ffe4e6', border:'#f43f5e', font:'#881337', hbg:'#fecdd3', hborder:'#e11d48' },
321
+ 'c-purple': { bg:'#ede9fe', border:'#8b5cf6', font:'#4c1d95', hbg:'#ddd6fe', hborder:'#7c3aed' },
322
+ 'c-teal': { bg:'#ccfbf1', border:'#14b8a6', font:'#134e4a', hbg:'#99f6e4', hborder:'#0d9488' },
323
+ };
324
+
325
+ // ── Actor ctxRenderer ─────────────────────────────────────────────────────
326
+ function makeActorRenderer(colorKey) {
327
+ return function({ ctx, x, y, state: { selected } }) {
328
+ const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
329
+ return {
330
+ drawNode() {
331
+ ctx.save();
332
+ ctx.strokeStyle = selected ? '#f97316' : c.border;
333
+ ctx.fillStyle = selected ? c.hbg : c.bg;
334
+ ctx.lineWidth = 2; ctx.lineCap = 'round';
335
+ ctx.beginPath(); ctx.arc(x, y - 20, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
336
+ ctx.beginPath(); ctx.moveTo(x, y - 12); ctx.lineTo(x, y + 8); ctx.stroke();
337
+ ctx.beginPath(); ctx.moveTo(x - 13, y - 3); ctx.lineTo(x + 13, y - 3); ctx.stroke();
338
+ ctx.beginPath(); ctx.moveTo(x, y + 8); ctx.lineTo(x - 10, y + 24); ctx.stroke();
339
+ ctx.beginPath(); ctx.moveTo(x, y + 8); ctx.lineTo(x + 10, y + 24); ctx.stroke();
340
+ ctx.restore();
341
+ },
342
+ nodeDimensions: { width: 30, height: 52 },
343
+ };
344
+ };
345
+ }
346
+
347
+ // ── Build vis.js node props ───────────────────────────────────────────────
348
+ function computeVadjust(textValign, nodeHeight, fontSize) {
349
+ if (!textValign || textValign === 'middle') return 0;
350
+ const h = nodeHeight || 50;
351
+ const fs = fontSize || 13;
352
+ const pad = 8;
353
+ if (textValign === 'top') return -(h / 2 - fs / 2 - pad);
354
+ if (textValign === 'bottom') return (h / 2 - fs / 2 - pad);
355
+ return 0;
356
+ }
357
+
358
+ function visNodeProps(shapeType, colorKey, nodeWidth, nodeHeight, fontSize, textAlign, textValign) {
359
+ const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
360
+ const size = fontSize || 13;
361
+ const align = textAlign || 'center';
362
+ const vadjust = computeVadjust(textValign, nodeHeight, size);
363
+ const colorP = {
364
+ color: {
365
+ background: c.bg, border: c.border,
366
+ highlight: { background: c.hbg, border: c.hborder },
367
+ hover: { background: c.hbg, border: c.hborder },
368
+ },
369
+ font: { color: c.font, size, face: 'system-ui,-apple-system,sans-serif', align, vadjust },
370
+ };
371
+ const sizeP = {};
372
+ if (nodeWidth) sizeP.widthConstraint = { minimum: nodeWidth, maximum: nodeWidth };
373
+ if (nodeHeight) sizeP.heightConstraint = { minimum: nodeHeight, maximum: nodeHeight };
374
+
375
+ if (shapeType === 'actor') {
376
+ return { shape: 'custom', ctxRenderer: makeActorRenderer(colorKey), ...colorP, ...sizeP };
377
+ }
378
+ return { shape: shapeType, ...colorP, ...sizeP };
379
+ }
380
+
381
+ // ── Build vis.js edge props ───────────────────────────────────────────────
382
+ function visEdgeProps(arrowDir, dashes) {
383
+ return {
384
+ arrows: {
385
+ to: { enabled: arrowDir === 'to' || arrowDir === 'both', scaleFactor: 0.7 },
386
+ from: { enabled: arrowDir === 'both', scaleFactor: 0.7 },
387
+ },
388
+ dashes: dashes === true,
389
+ };
390
+ }
391
+
392
+ // ── Network init ──────────────────────────────────────────────────────────
393
+ function initNetwork(savedNodes, savedEdges) {
394
+ const container = document.getElementById('vis-canvas');
395
+
396
+ nodes = new vis.DataSet(savedNodes.map(n => ({
397
+ ...n,
398
+ ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
399
+ })));
400
+ edges = new vis.DataSet(savedEdges.map(e => ({
401
+ ...e,
402
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
403
+ ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
404
+ })));
405
+
406
+ const options = {
407
+ physics: {
408
+ enabled: physicsEnabled, stabilization: { enabled: false },
409
+ barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3, springLength: 150, springConstant: 0.04, damping: 0.09 },
410
+ },
411
+ interaction: {
412
+ hover: true, navigationButtons: false, keyboard: false,
413
+ multiselect: true, // Ctrl/Cmd+click for multi-select
414
+ },
415
+ nodes: {
416
+ font: { size: 13, face: 'system-ui,-apple-system,sans-serif' },
417
+ borderWidth: 1.5, borderWidthSelected: 2.5, shadow: false,
418
+ widthConstraint: { minimum: 60 }, heightConstraint: { minimum: 28 },
419
+ },
420
+ edges: {
421
+ smooth: { type: 'continuous' },
422
+ color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' },
423
+ width: 1.5, selectionWidth: 2.5,
424
+ font: { size: 11, align: 'middle', color: '#6b7280' },
425
+ },
426
+ manipulation: {
427
+ enabled: false,
428
+ addEdge(data, callback) {
429
+ data.id = 'e' + Date.now();
430
+ data.arrowDir = 'to';
431
+ data.dashes = false;
432
+ Object.assign(data, visEdgeProps('to', false));
433
+ callback(data);
434
+ markDirty();
435
+ // Stay in arrow mode
436
+ setTimeout(() => network.addEdgeMode(), 0);
437
+ },
438
+ },
439
+ };
440
+
441
+ if (network) network.destroy();
442
+ network = new vis.Network(container, { nodes, edges }, options);
443
+
444
+ // Preserve canonical z-order by patching the renderer instead of nodeIndices.
445
+ // vis.js _drawNodes does 3 passes (normal → selected → hovered), always drawing
446
+ // hover/selected nodes on top regardless of z-order. We replace it with a single
447
+ // pass in _canonicalOrder so the user-defined stacking is always respected.
448
+ _canonicalOrder = [...network.body.nodeIndices];
449
+ network.renderer._drawNodes = function(ctx, alwaysShow = false) {
450
+ const bodyNodes = this.body.nodes;
451
+ const margin = 20;
452
+ const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
453
+ const bottomRight = this.canvas.DOMtoCanvas({
454
+ x: this.canvas.frame.canvas.clientWidth + margin,
455
+ y: this.canvas.frame.canvas.clientHeight + margin,
456
+ });
457
+ const viewableArea = {
458
+ top: topLeft.y, left: topLeft.x,
459
+ bottom: bottomRight.y, right: bottomRight.x,
460
+ };
461
+ const drawExternalLabelCallbacks = [];
462
+ for (const id of _canonicalOrder) {
463
+ const node = bodyNodes[id];
464
+ if (!node) continue;
465
+ if (alwaysShow === true) {
466
+ const r = node.draw(ctx);
467
+ if (r.drawExternalLabel != null) drawExternalLabelCallbacks.push(r.drawExternalLabel);
468
+ } else if (node.isBoundingBoxOverlappingWith(viewableArea) === true) {
469
+ const r = node.draw(ctx);
470
+ if (r.drawExternalLabel != null) drawExternalLabelCallbacks.push(r.drawExternalLabel);
471
+ } else {
472
+ node.updateBoundingBox(ctx, node.selected);
473
+ }
474
+ }
475
+ return {
476
+ drawExternalLabels() {
477
+ for (const draw of drawExternalLabelCallbacks) draw();
478
+ },
479
+ };
480
+ };
481
+
482
+ // Keep _canonicalOrder in sync when nodes are added or removed via the DataSet
483
+ nodes.on('add', (_, { items }) => {
484
+ const existing = new Set(_canonicalOrder);
485
+ items.forEach(id => { if (!existing.has(id)) _canonicalOrder.push(id); });
486
+ });
487
+ nodes.on('remove', (_, { items }) => {
488
+ const removed = new Set(items);
489
+ _canonicalOrder = _canonicalOrder.filter(id => !removed.has(id));
490
+ });
491
+
492
+ network.on('doubleClick', onDoubleClick);
493
+ network.on('selectNode', onSelectNode);
494
+ network.on('deselectNode', onDeselectAll);
495
+ network.on('selectEdge', onSelectEdge);
496
+ network.on('deselectEdge', onDeselectAll);
497
+ network.on('zoom', updateZoomDisplay);
498
+ network.on('dragEnd', onDragEnd);
499
+ network.on('beforeDrawing', drawGrid);
500
+ network.on('afterDrawing', updateSelectionOverlay);
501
+
502
+ document.getElementById('emptyState').classList.add('hidden');
503
+ updateZoomDisplay();
504
+ }
505
+
506
+ // ── Network events ────────────────────────────────────────────────────────
507
+ function onDoubleClick(params) {
508
+ if (params.nodes.length > 0) {
509
+ // Edit label of double-clicked node
510
+ selectedNodeIds = params.nodes;
511
+ network.selectNodes(selectedNodeIds);
512
+ showNodePanel();
513
+ startLabelEdit();
514
+ } else if (params.edges.length > 0 && params.nodes.length === 0) {
515
+ // Edit label of double-clicked edge
516
+ selectedEdgeIds = [params.edges[0]];
517
+ showEdgePanel();
518
+ startEdgeLabelEdit();
519
+ } else if (currentTool === 'addNode') {
520
+ // Add node at double-click position
521
+ const id = 'n' + Date.now();
522
+ nodes.add({
523
+ id, label: 'Node',
524
+ shapeType: pendingShape, colorKey: 'c-gray',
525
+ nodeWidth: null, nodeHeight: null, fontSize: null,
526
+ x: params.pointer.canvas.x, y: params.pointer.canvas.y,
527
+ ...visNodeProps(pendingShape, 'c-gray', null, null, null, null, null),
528
+ });
529
+ markDirty();
530
+ setTimeout(() => {
531
+ network.selectNodes([id]);
532
+ selectedNodeIds = [id];
533
+ showNodePanel();
534
+ startLabelEdit();
535
+ }, 50);
536
+ }
537
+ }
538
+
539
+ function onSelectNode(params) {
540
+ selectedNodeIds = params.nodes;
541
+ selectedEdgeIds = [];
542
+ hideEdgePanel();
543
+ showNodePanel();
544
+ }
545
+
546
+ function onSelectEdge(params) {
547
+ if (selectedNodeIds.length > 0) return; // node takes priority
548
+ selectedEdgeIds = params.edges;
549
+ selectedNodeIds = [];
550
+ hideNodePanel();
551
+ showEdgePanel();
552
+ }
553
+
554
+ function onDeselectAll() {
555
+ selectedNodeIds = [];
556
+ selectedEdgeIds = [];
557
+ hideNodePanel();
558
+ hideEdgePanel();
559
+ commitLabelEdit();
560
+ hideLabelInput();
561
+ hideSelectionOverlay();
562
+ }
563
+
564
+ // ── Node panel ────────────────────────────────────────────────────────────
565
+ function showNodePanel() { document.getElementById('nodePanel').classList.remove('hidden'); }
566
+ function hideNodePanel() { document.getElementById('nodePanel').classList.add('hidden'); }
567
+
568
+ function setNodeColor(colorKey) {
569
+ if (!selectedNodeIds.length) return;
570
+ selectedNodeIds.forEach(id => {
571
+ const n = nodes.get(id);
572
+ if (!n) return;
573
+ nodes.update({ id, colorKey, ...visNodeProps(n.shapeType||'box', colorKey, n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign) });
574
+ });
575
+ markDirty();
576
+ }
577
+
578
+ function changeNodeFontSize(delta) {
579
+ if (!selectedNodeIds.length) return;
580
+ selectedNodeIds.forEach(id => {
581
+ const n = nodes.get(id);
582
+ if (!n) return;
583
+ const newSize = Math.max(8, Math.min(48, (n.fontSize || 13) + delta));
584
+ nodes.update({ id, fontSize: newSize, ...visNodeProps(n.shapeType||'box', n.colorKey||'c-gray', n.nodeWidth, n.nodeHeight, newSize, n.textAlign, n.textValign) });
585
+ });
586
+ markDirty();
587
+ }
588
+
589
+ function setTextAlign(align) {
590
+ if (!selectedNodeIds.length) return;
591
+ selectedNodeIds.forEach(id => {
592
+ const n = nodes.get(id); if (!n) return;
593
+ nodes.update({ id, textAlign: align, ...visNodeProps(n.shapeType||'box', n.colorKey||'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, align, n.textValign) });
594
+ });
595
+ markDirty();
596
+ }
597
+
598
+ function setTextValign(valign) {
599
+ if (!selectedNodeIds.length) return;
600
+ selectedNodeIds.forEach(id => {
601
+ const n = nodes.get(id); if (!n) return;
602
+ nodes.update({ id, textValign: valign, ...visNodeProps(n.shapeType||'box', n.colorKey||'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, valign) });
603
+ });
604
+ markDirty();
605
+ }
606
+
607
+ function changeZOrder(direction) {
608
+ // direction: +1 = bring to front (last in _canonicalOrder = drawn on top)
609
+ // -1 = send to back (first in _canonicalOrder = drawn below)
610
+ if (!selectedNodeIds.length) return;
611
+ selectedNodeIds.forEach(id => {
612
+ const idx = _canonicalOrder.indexOf(id);
613
+ if (idx === -1) return;
614
+ _canonicalOrder.splice(idx, 1);
615
+ if (direction > 0) {
616
+ _canonicalOrder.push(id); // bring to front
617
+ } else {
618
+ _canonicalOrder.unshift(id); // send to back
619
+ }
620
+ });
621
+ network.redraw();
622
+ network.selectNodes(selectedNodeIds);
623
+ markDirty();
624
+ }
625
+
626
+ // ── Edge panel ────────────────────────────────────────────────────────────
627
+ function showEdgePanel() {
628
+ if (!selectedEdgeIds.length) return;
629
+ const e = edges.get(selectedEdgeIds[0]);
630
+ if (!e) return;
631
+ const dir = e.arrowDir ?? 'to';
632
+ const dashes = e.dashes ?? false;
633
+ ['edgeBtnNone','edgeBtnTo','edgeBtnBoth'].forEach(id => document.getElementById(id).classList.remove('edge-btn-active'));
634
+ document.getElementById({ none:'edgeBtnNone', to:'edgeBtnTo', both:'edgeBtnBoth' }[dir] || 'edgeBtnTo').classList.add('edge-btn-active');
635
+ ['edgeBtnSolid','edgeBtnDashed'].forEach(id => document.getElementById(id).classList.remove('edge-btn-active'));
636
+ document.getElementById(dashes ? 'edgeBtnDashed' : 'edgeBtnSolid').classList.add('edge-btn-active');
637
+ document.getElementById('edgePanel').classList.remove('hidden');
638
+ }
639
+ function hideEdgePanel() { document.getElementById('edgePanel').classList.add('hidden'); }
640
+
641
+ function setEdgeArrow(dir) {
642
+ if (!selectedEdgeIds.length) return;
643
+ selectedEdgeIds.forEach(id => {
644
+ const e = edges.get(id); if (!e) return;
645
+ edges.update({ id, arrowDir: dir, ...visEdgeProps(dir, e.dashes ?? false) });
646
+ });
647
+ showEdgePanel(); markDirty();
648
+ }
649
+
650
+ function setEdgeDashes(dashes) {
651
+ if (!selectedEdgeIds.length) return;
652
+ selectedEdgeIds.forEach(id => {
653
+ const e = edges.get(id); if (!e) return;
654
+ edges.update({ id, dashes, ...visEdgeProps(e.arrowDir ?? 'to', dashes) });
655
+ });
656
+ showEdgePanel(); markDirty();
657
+ }
658
+
659
+ function changeEdgeFontSize(delta) {
660
+ if (!selectedEdgeIds.length) return;
661
+ selectedEdgeIds.forEach(id => {
662
+ const e = edges.get(id); if (!e) return;
663
+ const newSize = Math.max(8, Math.min(48, (e.fontSize || 11) + delta));
664
+ edges.update({ id, fontSize: newSize, font: { size: newSize, align: 'middle', color: '#6b7280' } });
665
+ });
666
+ markDirty();
667
+ }
668
+
669
+ // ── Label editor ──────────────────────────────────────────────────────────
670
+ function startLabelEdit() {
671
+ if (!selectedNodeIds.length || !network) return;
672
+ const nodeId = selectedNodeIds[0];
673
+ const n = nodes.get(nodeId);
674
+ if (!n) return;
675
+ editingNodeId = nodeId; editingEdgeId = null;
676
+ const pos = network.getPositions([nodeId])[nodeId] || { x: 0, y: 0 };
677
+ const dom = network.canvasToDOM(pos);
678
+ const ta = document.getElementById('labelInput');
679
+ ta.value = n.label || '';
680
+ ta.style.left = (dom.x - 75) + 'px';
681
+ ta.style.top = (dom.y - 30) + 'px';
682
+ ta.style.width = '150px';
683
+ ta.classList.remove('hidden');
684
+ autoResizeTextarea(ta);
685
+ ta.focus(); ta.select();
686
+ }
687
+
688
+ function startEdgeLabelEdit() {
689
+ if (!selectedEdgeIds.length || !network) return;
690
+ const edgeId = selectedEdgeIds[0];
691
+ const e = edges.get(edgeId);
692
+ if (!e) return;
693
+ editingEdgeId = edgeId; editingNodeId = null;
694
+
695
+ // Midpoint between the two endpoints in canvas coords
696
+ const positions = network.getPositions([e.from, e.to]);
697
+ const fp = positions[e.from] || { x: 0, y: 0 };
698
+ const tp = positions[e.to] || { x: 0, y: 0 };
699
+ const mid = network.canvasToDOM({ x: (fp.x + tp.x) / 2, y: (fp.y + tp.y) / 2 });
700
+
701
+ const ta = document.getElementById('labelInput');
702
+ ta.value = e.label || '';
703
+ ta.style.left = (mid.x - 60) + 'px';
704
+ ta.style.top = (mid.y - 14) + 'px';
705
+ ta.style.width = '120px';
706
+ ta.classList.remove('hidden');
707
+ autoResizeTextarea(ta);
708
+ ta.focus(); ta.select();
709
+ }
710
+
711
+ function commitLabelEdit() {
712
+ const ta = document.getElementById('labelInput');
713
+ if (editingNodeId) { nodes.update({ id: editingNodeId, label: ta.value }); markDirty(); }
714
+ else if (editingEdgeId) { edges.update({ id: editingEdgeId, label: ta.value }); markDirty(); }
715
+ editingNodeId = null; editingEdgeId = null;
716
+ }
717
+
718
+ function hideLabelInput() {
719
+ document.getElementById('labelInput').classList.add('hidden');
720
+ editingNodeId = null; editingEdgeId = null;
721
+ }
722
+
723
+ function autoResizeTextarea(ta) {
724
+ ta.style.height = 'auto';
725
+ ta.style.height = ta.scrollHeight + 'px';
726
+ }
727
+
728
+ document.getElementById('labelInput').addEventListener('keydown', e => {
729
+ if (e.key === 'Enter' && !e.shiftKey) { commitLabelEdit(); hideLabelInput(); e.preventDefault(); }
730
+ else if (e.key === 'Escape') { hideLabelInput(); }
731
+ });
732
+ document.getElementById('labelInput').addEventListener('input', e => autoResizeTextarea(e.target));
733
+ document.getElementById('labelInput').addEventListener('blur', () => { commitLabelEdit(); hideLabelInput(); });
734
+
735
+ // ── Selection / resize overlay ─────────────────────────────────────────────
736
+ function updateSelectionOverlay() {
737
+ if (!network || !selectedNodeIds.length) { hideSelectionOverlay(); return; }
738
+
739
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
740
+ for (const id of selectedNodeIds) {
741
+ try {
742
+ const bb = network.getBoundingBox(id);
743
+ minX = Math.min(minX, bb.left); minY = Math.min(minY, bb.top);
744
+ maxX = Math.max(maxX, bb.right); maxY = Math.max(maxY, bb.bottom);
745
+ } catch(_) { /* node still being created */ }
746
+ }
747
+ if (minX === Infinity) { hideSelectionOverlay(); return; }
748
+
749
+ const PAD = 10;
750
+ const tl = network.canvasToDOM({ x: minX, y: minY });
751
+ const br = network.canvasToDOM({ x: maxX, y: maxY });
752
+ const ov = document.getElementById('selectionOverlay');
753
+ ov.style.display = 'block';
754
+ ov.style.left = (tl.x - PAD) + 'px';
755
+ ov.style.top = (tl.y - PAD) + 'px';
756
+ ov.style.width = (br.x - tl.x + PAD * 2) + 'px';
757
+ ov.style.height = (br.y - tl.y + PAD * 2) + 'px';
758
+ }
759
+
760
+ function hideSelectionOverlay() {
761
+ document.getElementById('selectionOverlay').style.display = 'none';
762
+ }
763
+
764
+ // Wire up corner handles
765
+ ['tl','tr','bl','br'].forEach(corner => {
766
+ document.getElementById('rh-' + corner).addEventListener('mousedown', e => onResizeStart(e, corner));
767
+ });
768
+
769
+ function onResizeStart(e, corner) {
770
+ if (!selectedNodeIds.length || !network) return;
771
+ e.preventDefault(); e.stopPropagation();
772
+
773
+ const startBBs = selectedNodeIds.map(id => {
774
+ const bb = network.getBoundingBox(id);
775
+ const n = nodes.get(id);
776
+ return { id, node: n,
777
+ initW: n.nodeWidth || Math.round(bb.right - bb.left),
778
+ initH: n.nodeHeight || Math.round(bb.bottom - bb.top) };
779
+ });
780
+
781
+ const allBBs = selectedNodeIds.map(id => network.getBoundingBox(id));
782
+ const initBoxW = Math.max(...allBBs.map(b => b.right)) - Math.min(...allBBs.map(b => b.left));
783
+ const initBoxH = Math.max(...allBBs.map(b => b.bottom)) - Math.min(...allBBs.map(b => b.top));
784
+
785
+ resizeDrag = { corner, startMouse: { x: e.clientX, y: e.clientY }, startBBs, initBoxW, initBoxH };
786
+
787
+ // Pin nodes during resize so physics doesn't fight us
788
+ selectedNodeIds.forEach(id => nodes.update({ id, fixed: true }));
789
+ // Block vis.js from receiving mouse events during drag
790
+ document.getElementById('vis-canvas').style.pointerEvents = 'none';
791
+
792
+ document.addEventListener('mousemove', onResizeDrag);
793
+ document.addEventListener('mouseup', onResizeEnd);
794
+ }
795
+
796
+ function onResizeDrag(e) {
797
+ if (!resizeDrag || !network) return;
798
+ const scale = network.getScale();
799
+ const cdx = (e.clientX - resizeDrag.startMouse.x) / scale;
800
+ const cdy = (e.clientY - resizeDrag.startMouse.y) / scale;
801
+ const MIN = 40;
802
+ const c = resizeDrag.corner;
803
+
804
+ if (resizeDrag.startBBs.length === 1) {
805
+ // Single node: direct resize
806
+ const { id, node, initW, initH } = resizeDrag.startBBs[0];
807
+ let nW = initW, nH = initH;
808
+ if (c==='br'){nW=initW+cdx; nH=initH+cdy;}
809
+ if (c==='bl'){nW=initW-cdx; nH=initH+cdy;}
810
+ if (c==='tr'){nW=initW+cdx; nH=initH-cdy;}
811
+ if (c==='tl'){nW=initW-cdx; nH=initH-cdy;}
812
+ nW = Math.max(MIN, Math.round(nW));
813
+ nH = Math.max(MIN, Math.round(nH));
814
+ nodes.update({ id, nodeWidth: nW, nodeHeight: nH,
815
+ ...visNodeProps(node.shapeType||'box', node.colorKey||'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
816
+ } else {
817
+ // Multi: proportional scale
818
+ const { initBoxW, initBoxH } = resizeDrag;
819
+ let sx = 1, sy = 1;
820
+ if (c==='br'){sx=(initBoxW+cdx)/initBoxW; sy=(initBoxH+cdy)/initBoxH;}
821
+ if (c==='bl'){sx=(initBoxW-cdx)/initBoxW; sy=(initBoxH+cdy)/initBoxH;}
822
+ if (c==='tr'){sx=(initBoxW+cdx)/initBoxW; sy=(initBoxH-cdy)/initBoxH;}
823
+ if (c==='tl'){sx=(initBoxW-cdx)/initBoxW; sy=(initBoxH-cdy)/initBoxH;}
824
+ sx = Math.max(0.1, sx); sy = Math.max(0.1, sy);
825
+ for (const { id, node, initW, initH } of resizeDrag.startBBs) {
826
+ const nW = Math.max(MIN, Math.round(initW * sx));
827
+ const nH = Math.max(MIN, Math.round(initH * sy));
828
+ nodes.update({ id, nodeWidth: nW, nodeHeight: nH,
829
+ ...visNodeProps(node.shapeType||'box', node.colorKey||'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign) });
830
+ }
831
+ }
832
+ updateSelectionOverlay();
833
+ }
834
+
835
+ function onResizeEnd() {
836
+ if (!resizeDrag) return;
837
+ selectedNodeIds.forEach(id => nodes.update({ id, fixed: false }));
838
+ document.getElementById('vis-canvas').style.pointerEvents = '';
839
+ document.removeEventListener('mousemove', onResizeDrag);
840
+ document.removeEventListener('mouseup', onResizeEnd);
841
+ resizeDrag = null;
842
+ markDirty();
843
+ }
844
+
845
+ // ── Tools ─────────────────────────────────────────────────────────────────
846
+ const TOOL_BTN_MAP = {
847
+ 'select': 'toolSelect',
848
+ 'addNode:box': 'toolBox',
849
+ 'addNode:ellipse': 'toolEllipse',
850
+ 'addNode:database': 'toolDatabase',
851
+ 'addNode:circle': 'toolCircle',
852
+ 'addNode:actor': 'toolActor',
853
+ 'addEdge': 'toolArrow',
854
+ };
855
+
856
+ function setTool(tool, shape) {
857
+ currentTool = tool;
858
+ if (shape) pendingShape = shape;
859
+
860
+ document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('tool-active'));
861
+ if (physicsEnabled) document.getElementById('btnPhysics').classList.add('tool-active');
862
+ const key = tool === 'addNode' ? `addNode:${shape || pendingShape}` : tool;
863
+ const btn = document.getElementById(TOOL_BTN_MAP[key]);
864
+ if (btn) btn.classList.add('tool-active');
865
+
866
+ document.getElementById('vis-canvas').classList.toggle('cursor-crosshair', tool === 'addNode' || tool === 'addEdge');
867
+
868
+ if (tool === 'addEdge' && network) network.addEdgeMode();
869
+ else if (network) network.disableEditMode();
870
+ }
871
+
872
+ function deleteSelected() {
873
+ if (!network) return;
874
+ network.deleteSelected();
875
+ hideNodePanel(); hideEdgePanel(); hideLabelInput(); hideSelectionOverlay();
876
+ editingNodeId = null; editingEdgeId = null;
877
+ markDirty();
878
+ }
879
+
880
+ // ── Physics ───────────────────────────────────────────────────────────────
881
+ function togglePhysics() {
882
+ physicsEnabled = !physicsEnabled;
883
+ if (network) network.setOptions({ physics: { enabled: physicsEnabled } });
884
+ const btn = document.getElementById('btnPhysics');
885
+ btn.classList.toggle('tool-active', physicsEnabled);
886
+ btn.title = physicsEnabled ? 'Auto-espacement actif' : 'Auto-espacement inactif';
887
+ }
888
+
889
+ // ── Grid ─────────────────────────────────────────────────────────────────
890
+ function toggleGrid() {
891
+ gridEnabled = !gridEnabled;
892
+ const btn = document.getElementById('btnGrid');
893
+ btn.classList.toggle('tool-active', gridEnabled);
894
+ btn.title = gridEnabled ? 'Grille activée (snap on)' : 'Grille / Snap to grid (G)';
895
+ if (network) network.redraw();
896
+ }
897
+
898
+ function drawGrid(ctx) {
899
+ if (!gridEnabled || !network) return;
900
+ const isDark = document.documentElement.classList.contains('dark');
901
+ const color = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.15)';
902
+ const scale = network.getScale();
903
+ const center = network.getViewPosition();
904
+ const canvas = ctx.canvas;
905
+ const W = canvas.width, H = canvas.height;
906
+
907
+ // Canvas-space grid step
908
+ const step = GRID_SIZE * scale;
909
+
910
+ // Offset so grid aligns to canvas-space multiples of GRID_SIZE
911
+ // Use positive modulo to avoid negative offsets when panning left/up
912
+ const offsetX = ((W / 2 - center.x * scale) % step + step) % step;
913
+ const offsetY = ((H / 2 - center.y * scale) % step + step) % step;
914
+
915
+ ctx.save();
916
+ // Reset transform — we work in screen (canvas pixel) space
917
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
918
+ ctx.strokeStyle = color;
919
+ ctx.lineWidth = 1;
920
+ ctx.beginPath();
921
+ for (let x = offsetX; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
922
+ for (let y = offsetY; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
923
+ ctx.stroke();
924
+ ctx.restore();
925
+ }
926
+
927
+ function snapToGrid(v) {
928
+ return Math.round(v / GRID_SIZE) * GRID_SIZE;
929
+ }
930
+
931
+ function onDragEnd(params) {
932
+ if (gridEnabled && params.nodes && params.nodes.length > 0) {
933
+ const positions = network.getPositions(params.nodes);
934
+ params.nodes.forEach(id => {
935
+ const p = positions[id];
936
+ if (p) network.moveNode(id, snapToGrid(p.x), snapToGrid(p.y));
937
+ });
938
+ }
939
+ markDirty();
940
+ }
941
+
942
+ // ── Zoom ──────────────────────────────────────────────────────────────────
943
+ function adjustZoom(delta) {
944
+ if (!network) return;
945
+ network.moveTo({ scale: Math.max(0.1, Math.min(3, network.getScale() + delta)), animation: false });
946
+ updateZoomDisplay();
947
+ }
948
+ function resetZoom() {
949
+ if (!network) return;
950
+ network.fit({ animation: { duration: 300, easingFunction: 'easeInOutQuad' } });
951
+ setTimeout(updateZoomDisplay, 350);
952
+ }
953
+ function updateZoomDisplay() {
954
+ if (!network) return;
955
+ document.getElementById('zoomLevel').textContent = Math.round(network.getScale() * 100) + '%';
956
+ }
957
+
958
+ // ── Sidebar ───────────────────────────────────────────────────────────────
959
+ function toggleSidebar() {
960
+ sidebarOpen = !sidebarOpen;
961
+ const sb = document.getElementById('sidebar');
962
+ sb.style.width = sidebarOpen ? '14rem' : '0';
963
+ sb.style.overflow = sidebarOpen ? '' : 'hidden';
964
+ }
965
+
966
+ // ── Dirty state ───────────────────────────────────────────────────────────
967
+ function markDirty() {
968
+ isDirty = true;
969
+ document.getElementById('btnSave').disabled = false;
970
+ }
971
+
972
+ // ── Diagram list ──────────────────────────────────────────────────────────
973
+ function renderDiagramList() {
974
+ const list = document.getElementById('diagramList');
975
+ list.innerHTML = '';
976
+ if (!diagrams.length) {
977
+ list.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-600 px-3 py-3">Aucun diagramme</p>';
978
+ return;
979
+ }
980
+ diagrams.forEach(d => {
981
+ const isActive = d.id === currentDiagramId;
982
+ const item = document.createElement('div');
983
+ item.className = [
984
+ 'group flex items-center gap-1 px-2 py-1.5 mx-1 my-0.5 rounded-md cursor-pointer',
985
+ 'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
986
+ isActive ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'text-gray-700 dark:text-gray-300',
987
+ ].join(' ');
988
+ item.innerHTML = `
989
+ <span class="flex-1 text-sm truncate">${escapeHtml(d.title)}</span>
990
+ <button onclick="deleteDiagram('${escapeHtml(d.id)}', event)" title="Supprimer"
991
+ class="hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-gray-400 hover:text-red-500 shrink-0">✕</button>`;
992
+ item.addEventListener('click', e => { if (e.target.tagName === 'BUTTON') return; openDiagram(d.id); });
993
+ list.appendChild(item);
994
+ });
995
+ }
996
+
997
+ // ── Persistence ───────────────────────────────────────────────────────────
998
+ async function loadDiagramList() {
999
+ const res = await fetch('/api/diagrams');
1000
+ diagrams = await res.json();
1001
+ renderDiagramList();
1002
+ if (diagrams.length > 0) openDiagram(diagrams[0].id);
1003
+ }
1004
+
1005
+ async function openDiagram(id) {
1006
+ const res = await fetch(`/api/diagrams/${id}`);
1007
+ const diagram = await res.json();
1008
+ currentDiagramId = id;
1009
+ isDirty = false;
1010
+ document.getElementById('btnSave').disabled = true;
1011
+ document.getElementById('diagramTitle').value = diagram.title || '';
1012
+ initNetwork(diagram.nodes || [], diagram.edges || []);
1013
+ renderDiagramList();
1014
+ document.getElementById('diagramTitle').oninput = () => markDirty();
1015
+ }
1016
+
1017
+ async function newDiagram() {
1018
+ const id = 'd' + Date.now();
1019
+ await fetch(`/api/diagrams/${id}`, {
1020
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
1021
+ body: JSON.stringify({ title: 'Nouveau diagramme', nodes: [], edges: [] }),
1022
+ });
1023
+ const res = await fetch('/api/diagrams');
1024
+ diagrams = await res.json();
1025
+ renderDiagramList();
1026
+ openDiagram(id);
1027
+ }
1028
+
1029
+ async function deleteDiagram(id, event) {
1030
+ event.stopPropagation();
1031
+ if (!confirm('Supprimer ce diagramme ?')) return;
1032
+ await fetch(`/api/diagrams/${id}`, { method: 'DELETE' });
1033
+ const res = await fetch('/api/diagrams');
1034
+ diagrams = await res.json();
1035
+ if (currentDiagramId === id) {
1036
+ currentDiagramId = null;
1037
+ if (network) { network.destroy(); network = null; }
1038
+ nodes = null; edges = null;
1039
+ document.getElementById('diagramTitle').value = '';
1040
+ document.getElementById('btnSave').disabled = true;
1041
+ hideNodePanel(); hideEdgePanel(); hideLabelInput(); hideSelectionOverlay();
1042
+ if (diagrams.length > 0) openDiagram(diagrams[0].id);
1043
+ else document.getElementById('emptyState').classList.remove('hidden');
1044
+ }
1045
+ renderDiagramList();
1046
+ }
1047
+
1048
+ async function saveDiagram() {
1049
+ if (!currentDiagramId || !network) return;
1050
+ const positions = network.getPositions();
1051
+ // Keep only our custom fields, strip vis.js computed ones.
1052
+ // Use _canonicalOrder so the saved array preserves z-order on reload.
1053
+ const nodeData = _canonicalOrder.map(id => nodes.get(id)).filter(Boolean).map(n => ({
1054
+ id: n.id, label: n.label,
1055
+ shapeType: n.shapeType || 'box',
1056
+ colorKey: n.colorKey || 'c-gray',
1057
+ nodeWidth: n.nodeWidth || null,
1058
+ nodeHeight: n.nodeHeight || null,
1059
+ fontSize: n.fontSize || null,
1060
+ textAlign: n.textAlign || null,
1061
+ textValign: n.textValign || null,
1062
+ x: positions[n.id]?.x ?? n.x,
1063
+ y: positions[n.id]?.y ?? n.y,
1064
+ }));
1065
+ const edgeData = edges.get().map(e => ({
1066
+ id: e.id, from: e.from, to: e.to,
1067
+ label: e.label || '',
1068
+ arrowDir: e.arrowDir || 'to',
1069
+ dashes: e.dashes || false,
1070
+ fontSize: e.fontSize || null,
1071
+ }));
1072
+ const title = document.getElementById('diagramTitle').value || 'Sans titre';
1073
+ await fetch(`/api/diagrams/${currentDiagramId}`, {
1074
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
1075
+ body: JSON.stringify({ title, nodes: nodeData, edges: edgeData }),
1076
+ });
1077
+ isDirty = false;
1078
+ document.getElementById('btnSave').disabled = true;
1079
+ const res = await fetch('/api/diagrams');
1080
+ diagrams = await res.json();
1081
+ renderDiagramList();
1082
+ }
1083
+
1084
+ // ── Dark mode ─────────────────────────────────────────────────────────────
1085
+ function toggleDark() {
1086
+ const dark = document.documentElement.classList.toggle('dark');
1087
+ localStorage.setItem('ld-dark', dark);
1088
+ document.getElementById('darkIcon').textContent = dark ? '☀' : '☽';
1089
+ }
1090
+ document.getElementById('darkIcon').textContent =
1091
+ document.documentElement.classList.contains('dark') ? '☀' : '☽';
1092
+
1093
+ // ── Utils ─────────────────────────────────────────────────────────────────
1094
+ function escapeHtml(s) {
1095
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1096
+ }
1097
+
1098
+ // ── Copy / Paste ──────────────────────────────────────────────────────────
1099
+ function copySelected() {
1100
+ if (!network || !selectedNodeIds.length) return;
1101
+
1102
+ const positions = network.getPositions(selectedNodeIds);
1103
+ const copiedNodes = selectedNodeIds.map(id => {
1104
+ const n = nodes.get(id);
1105
+ const { ctxRenderer, ...rest } = n;
1106
+ return { ...rest, x: positions[id]?.x ?? n.x, y: positions[id]?.y ?? n.y };
1107
+ });
1108
+
1109
+ // Copy edges where BOTH endpoints are in the selection
1110
+ const selectedSet = new Set(selectedNodeIds);
1111
+ const copiedEdges = edges.get().filter(e =>
1112
+ selectedSet.has(e.from) && selectedSet.has(e.to)
1113
+ );
1114
+
1115
+ clipboard = { nodes: copiedNodes, edges: copiedEdges };
1116
+ }
1117
+
1118
+ function pasteClipboard() {
1119
+ if (!clipboard || !clipboard.nodes.length || !network) return;
1120
+
1121
+ const OFFSET = 40;
1122
+ // Map old id → new id
1123
+ const idMap = {};
1124
+ clipboard.nodes.forEach(n => { idMap[n.id] = 'n' + Date.now() + Math.random().toString(36).slice(2); });
1125
+
1126
+ const newNodes = clipboard.nodes.map(n => ({
1127
+ ...n,
1128
+ id: idMap[n.id],
1129
+ x: (n.x || 0) + OFFSET,
1130
+ y: (n.y || 0) + OFFSET,
1131
+ ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
1132
+ }));
1133
+
1134
+ const newEdges = clipboard.edges.map(e => ({
1135
+ ...e,
1136
+ id: 'e' + Date.now() + Math.random().toString(36).slice(2),
1137
+ from: idMap[e.from],
1138
+ to: idMap[e.to],
1139
+ ...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
1140
+ ...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
1141
+ }));
1142
+
1143
+ nodes.add(newNodes);
1144
+ edges.add(newEdges);
1145
+
1146
+ // Select the pasted nodes
1147
+ const newIds = newNodes.map(n => n.id);
1148
+ network.selectNodes(newIds);
1149
+ selectedNodeIds = newIds;
1150
+ selectedEdgeIds = [];
1151
+ showNodePanel();
1152
+ markDirty();
1153
+
1154
+ // Offset clipboard for next paste
1155
+ clipboard = {
1156
+ nodes: clipboard.nodes.map(n => ({ ...n, x: (n.x || 0) + OFFSET, y: (n.y || 0) + OFFSET })),
1157
+ edges: clipboard.edges,
1158
+ };
1159
+ }
1160
+
1161
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────
1162
+ document.addEventListener('keydown', e => {
1163
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1164
+ if (e.key === 'Delete' || e.key === 'Backspace') { deleteSelected(); return; }
1165
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
1166
+ if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
1167
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
1168
+ if (e.key === 'Escape') { setTool('select'); return; }
1169
+ if (e.key === 's' || e.key === 'S') { setTool('select'); return; }
1170
+ if (e.key === 'r' || e.key === 'R') { setTool('addNode','box'); return; }
1171
+ if (e.key === 'e' || e.key === 'E') { setTool('addNode','ellipse'); return; }
1172
+ if (e.key === 'd' || e.key === 'D') { setTool('addNode','database');return; }
1173
+ if (e.key === 'c' || e.key === 'C') { setTool('addNode','circle'); return; }
1174
+ if (e.key === 'a' || e.key === 'A') { setTool('addNode','actor'); return; }
1175
+ if (e.key === 'f' || e.key === 'F') { setTool('addEdge'); return; }
1176
+ if (e.key === 'g' || e.key === 'G') { toggleGrid(); return; }
1177
+ });
1178
+
1179
+ // ── Init ──────────────────────────────────────────────────────────────────
1180
+ loadDiagramList();
1181
+ </script>
1182
+ </body>
1183
+ </html>