living-documentation 7.0.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.
Files changed (173) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +329 -0
  3. package/dist/bin/cli.d.ts +3 -0
  4. package/dist/bin/cli.d.ts.map +1 -0
  5. package/dist/bin/cli.js +62 -0
  6. package/dist/bin/cli.js.map +1 -0
  7. package/dist/src/frontend/admin.html +1073 -0
  8. package/dist/src/frontend/annotations.js +546 -0
  9. package/dist/src/frontend/boot.js +90 -0
  10. package/dist/src/frontend/config.js +19 -0
  11. package/dist/src/frontend/dark-mode.js +20 -0
  12. package/dist/src/frontend/diagram/alignment.js +161 -0
  13. package/dist/src/frontend/diagram/clipboard.js +172 -0
  14. package/dist/src/frontend/diagram/constants.js +109 -0
  15. package/dist/src/frontend/diagram/debug.js +43 -0
  16. package/dist/src/frontend/diagram/edge-panel.js +260 -0
  17. package/dist/src/frontend/diagram/edge-rendering.js +12 -0
  18. package/dist/src/frontend/diagram/grid.js +78 -0
  19. package/dist/src/frontend/diagram/groups.js +102 -0
  20. package/dist/src/frontend/diagram/history.js +153 -0
  21. package/dist/src/frontend/diagram/image-name-modal.js +48 -0
  22. package/dist/src/frontend/diagram/image-upload.js +36 -0
  23. package/dist/src/frontend/diagram/label-editor.js +115 -0
  24. package/dist/src/frontend/diagram/link-panel.js +144 -0
  25. package/dist/src/frontend/diagram/main.js +299 -0
  26. package/dist/src/frontend/diagram/network.js +1473 -0
  27. package/dist/src/frontend/diagram/node-panel.js +267 -0
  28. package/dist/src/frontend/diagram/node-rendering.js +773 -0
  29. package/dist/src/frontend/diagram/persistence.js +161 -0
  30. package/dist/src/frontend/diagram/ports.js +386 -0
  31. package/dist/src/frontend/diagram/selection-overlay.js +336 -0
  32. package/dist/src/frontend/diagram/state.js +39 -0
  33. package/dist/src/frontend/diagram/t.js +3 -0
  34. package/dist/src/frontend/diagram/toast.js +21 -0
  35. package/dist/src/frontend/diagram/unlock-hold.js +182 -0
  36. package/dist/src/frontend/diagram/zoom.js +20 -0
  37. package/dist/src/frontend/diagram-link-modal.js +137 -0
  38. package/dist/src/frontend/diagram.html +1279 -0
  39. package/dist/src/frontend/documents.js +373 -0
  40. package/dist/src/frontend/export.js +338 -0
  41. package/dist/src/frontend/i18n/en.json +406 -0
  42. package/dist/src/frontend/i18n/fr.json +406 -0
  43. package/dist/src/frontend/i18n.js +32 -0
  44. package/dist/src/frontend/image-paste.js +101 -0
  45. package/dist/src/frontend/index.html +2314 -0
  46. package/dist/src/frontend/misc.js +25 -0
  47. package/dist/src/frontend/new-doc-modal.js +260 -0
  48. package/dist/src/frontend/new-folder-modal.js +174 -0
  49. package/dist/src/frontend/search.js +157 -0
  50. package/dist/src/frontend/sidebar-helpers.js +58 -0
  51. package/dist/src/frontend/sidebar.js +182 -0
  52. package/dist/src/frontend/snippet-detect.js +25 -0
  53. package/dist/src/frontend/snippet-table.js +85 -0
  54. package/dist/src/frontend/snippet-tree.js +94 -0
  55. package/dist/src/frontend/snippets.js +534 -0
  56. package/dist/src/frontend/state.js +28 -0
  57. package/dist/src/frontend/utils.js +21 -0
  58. package/dist/src/frontend/vendor/wordcloud2.js +1187 -0
  59. package/dist/src/frontend/wordcloud.js +693 -0
  60. package/dist/src/lib/config.d.ts +17 -0
  61. package/dist/src/lib/config.d.ts.map +1 -0
  62. package/dist/src/lib/config.js +79 -0
  63. package/dist/src/lib/config.js.map +1 -0
  64. package/dist/src/lib/parser.d.ts +11 -0
  65. package/dist/src/lib/parser.d.ts.map +1 -0
  66. package/dist/src/lib/parser.js +111 -0
  67. package/dist/src/lib/parser.js.map +1 -0
  68. package/dist/src/mcp/server.d.ts +3 -0
  69. package/dist/src/mcp/server.d.ts.map +1 -0
  70. package/dist/src/mcp/server.js +986 -0
  71. package/dist/src/mcp/server.js.map +1 -0
  72. package/dist/src/mcp/tools/diagrams.d.ts +44 -0
  73. package/dist/src/mcp/tools/diagrams.d.ts.map +1 -0
  74. package/dist/src/mcp/tools/diagrams.js +245 -0
  75. package/dist/src/mcp/tools/diagrams.js.map +1 -0
  76. package/dist/src/mcp/tools/documents.d.ts +26 -0
  77. package/dist/src/mcp/tools/documents.d.ts.map +1 -0
  78. package/dist/src/mcp/tools/documents.js +127 -0
  79. package/dist/src/mcp/tools/documents.js.map +1 -0
  80. package/dist/src/mcp/tools/source.d.ts +29 -0
  81. package/dist/src/mcp/tools/source.d.ts.map +1 -0
  82. package/dist/src/mcp/tools/source.js +200 -0
  83. package/dist/src/mcp/tools/source.js.map +1 -0
  84. package/dist/src/routes/annotations.d.ts +3 -0
  85. package/dist/src/routes/annotations.d.ts.map +1 -0
  86. package/dist/src/routes/annotations.js +83 -0
  87. package/dist/src/routes/annotations.js.map +1 -0
  88. package/dist/src/routes/browse.d.ts +3 -0
  89. package/dist/src/routes/browse.d.ts.map +1 -0
  90. package/dist/src/routes/browse.js +75 -0
  91. package/dist/src/routes/browse.js.map +1 -0
  92. package/dist/src/routes/config.d.ts +3 -0
  93. package/dist/src/routes/config.d.ts.map +1 -0
  94. package/dist/src/routes/config.js +97 -0
  95. package/dist/src/routes/config.js.map +1 -0
  96. package/dist/src/routes/diagrams.d.ts +3 -0
  97. package/dist/src/routes/diagrams.d.ts.map +1 -0
  98. package/dist/src/routes/diagrams.js +69 -0
  99. package/dist/src/routes/diagrams.js.map +1 -0
  100. package/dist/src/routes/documents.d.ts +8 -0
  101. package/dist/src/routes/documents.d.ts.map +1 -0
  102. package/dist/src/routes/documents.js +332 -0
  103. package/dist/src/routes/documents.js.map +1 -0
  104. package/dist/src/routes/export.d.ts +3 -0
  105. package/dist/src/routes/export.d.ts.map +1 -0
  106. package/dist/src/routes/export.js +277 -0
  107. package/dist/src/routes/export.js.map +1 -0
  108. package/dist/src/routes/images.d.ts +3 -0
  109. package/dist/src/routes/images.d.ts.map +1 -0
  110. package/dist/src/routes/images.js +49 -0
  111. package/dist/src/routes/images.js.map +1 -0
  112. package/dist/src/routes/wordcloud.d.ts +3 -0
  113. package/dist/src/routes/wordcloud.d.ts.map +1 -0
  114. package/dist/src/routes/wordcloud.js +95 -0
  115. package/dist/src/routes/wordcloud.js.map +1 -0
  116. package/dist/src/server.d.ts +7 -0
  117. package/dist/src/server.d.ts.map +1 -0
  118. package/dist/src/server.js +76 -0
  119. package/dist/src/server.js.map +1 -0
  120. package/dist/starting-doc/.annotations.json +3 -0
  121. package/dist/starting-doc/.diagrams.json +1884 -0
  122. package/dist/starting-doc/.living-doc.json +39 -0
  123. package/dist/starting-doc/1_tutorial/2026_04_11_13_25_[General]_crer_vos_dossiers.md +16 -0
  124. package/dist/starting-doc/1_tutorial/2026_04_11_18_58_[General]_creer_un_document_dans_un_dossier.md +9 -0
  125. package/dist/starting-doc/1_tutorial/2026_04_12_09_00_[General]_editer_et_sauvegarder.md +39 -0
  126. package/dist/starting-doc/1_tutorial/2026_04_12_10_00_[General]_utiliser_les_snippets.md +71 -0
  127. package/dist/starting-doc/2026_04_08_20_52_[General]_welcome.md +17 -0
  128. package/dist/starting-doc/2026_04_11_12_55_[General]_premiers_pas.md +271 -0
  129. package/dist/starting-doc/2_guide/2026_04_08_00_04_[DOCUMENT]_utilisation_des_images_plein_ecran_lien_clickable.md +40 -0
  130. package/dist/starting-doc/2_guide/2026_04_08_23_38_[Configuration]_demarrage_de_living_documentation.md +32 -0
  131. package/dist/starting-doc/2_guide/2026_04_09_09_00_[NAVIGATION]_recherche_plein_texte.md +65 -0
  132. package/dist/starting-doc/2_guide/2026_04_09_10_00_[EXPORT]_exporter_en_pdf.md +43 -0
  133. package/dist/starting-doc/2_guide/2026_04_09_11_00_[Configuration]_configurer_le_panneau_admin.md +55 -0
  134. package/dist/starting-doc/2_guide/2026_04_09_12_00_[Configuration]_extra_files.md +68 -0
  135. package/dist/starting-doc/2_guide/2026_04_09_13_00_[WORDCLOUD]_word_cloud.md +54 -0
  136. package/dist/starting-doc/2_guide/2026_04_09_14_00_[DIAGRAM]_creer_et_lier_un_diagramme.md +77 -0
  137. package/dist/starting-doc/3_concept/2026_04_08_20_58_[DOCUMENTING]_ADRS.md +20 -0
  138. package/dist/starting-doc/3_concept/2026_04_08_22_15_[DOCUMENTING]_living_documentation.md +17 -0
  139. package/dist/starting-doc/3_concept/2026_04_08_22_46_[METHODOLOGY]_diataxis_architecture_du_contenu.md +16 -0
  140. package/dist/starting-doc/4_reference/2026_04_08_23_14_[FUNDAMENTALS]_the_living_documentation_tool.md +41 -0
  141. package/dist/starting-doc/4_reference/2026_04_09_01_00_[REFERENCE]_raccourcis_clavier.md +61 -0
  142. package/dist/starting-doc/4_reference/2026_04_09_02_00_[REFERENCE]_tokens_pattern_nommage.md +75 -0
  143. package/dist/starting-doc/4_reference/2026_04_09_03_00_[REFERENCE]_types_de_snippets.md +68 -0
  144. package/dist/starting-doc/4_reference/2026_04_11_17_31_[FUNDAMENTALS]_architecturer_une_documentation.md +12 -0
  145. package/dist/starting-doc/4_reference/2026_04_12_14_07_[FUNDAMENTALS]_dossiers_et_catgories.md +89 -0
  146. package/dist/starting-doc/images/admin_screenshot.png +0 -0
  147. package/dist/starting-doc/images/ajout-document.png +0 -0
  148. package/dist/starting-doc/images/ajouter-document-categorie.png +0 -0
  149. package/dist/starting-doc/images/ajouter_un_document_dans_un_dossier.png +0 -0
  150. package/dist/starting-doc/images/architecturer_une_documentation_reference.png +0 -0
  151. package/dist/starting-doc/images/cr_er_un_document.png +0 -0
  152. package/dist/starting-doc/images/creation-nouveau-dossier.png +0 -0
  153. package/dist/starting-doc/images/creer-document-context-engineering.png +0 -0
  154. package/dist/starting-doc/images/creer-dossier-only-tutoriel.png +0 -0
  155. package/dist/starting-doc/images/creer-dossier-tutoriel.png +0 -0
  156. package/dist/starting-doc/images/creer-dossiers-done.png +0 -0
  157. package/dist/starting-doc/images/creer-un-document.png +0 -0
  158. package/dist/starting-doc/images/creer-vos-dossiers-tutoriel.png +0 -0
  159. package/dist/starting-doc/images/creer-vos-dossiers.png +0 -0
  160. package/dist/starting-doc/images/decouverte_adrs.png +0 -0
  161. package/dist/starting-doc/images/diataxis.png +0 -0
  162. package/dist/starting-doc/images/diataxis_callout.png +0 -0
  163. package/dist/starting-doc/images/document-cree.png +0 -0
  164. package/dist/starting-doc/images/liens_snippets.png +0 -0
  165. package/dist/starting-doc/images/living_documentation.png +0 -0
  166. package/dist/starting-doc/images/npm_logo.png +0 -0
  167. package/dist/starting-doc/images/popup-creer-document.png +0 -0
  168. package/dist/starting-doc/images/popup-creer-dossier.png +0 -0
  169. package/dist/starting-doc/images/popup-dossier-cree.png +0 -0
  170. package/dist/starting-doc/images/quatre-dossiers-crees.png +0 -0
  171. package/dist/starting-doc/images/screenshot-living-doc.png +0 -0
  172. package/dist/starting-doc/images/the_living_documentation_tool.png +0 -0
  173. package/package.json +49 -0
@@ -0,0 +1,161 @@
1
+ // ── Persistence ───────────────────────────────────────────────────────────────
2
+ // API calls (CRUD) for diagrams + sidebar list rendering.
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { initNetwork } from './network.js';
6
+ import { resetHistory } from './history.js';
7
+ import { t } from './t.js';
8
+ import { hideNodePanel } from './node-panel.js';
9
+ import { hideEdgePanel } from './edge-panel.js';
10
+ import { hideLabelInput } from './label-editor.js';
11
+ import { hideSelectionOverlay } from './selection-overlay.js';
12
+ import { applyGridState } from './grid.js';
13
+ import { applyAlignGuidesState } from './alignment.js';
14
+
15
+ function escapeHtml(s) {
16
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
17
+ }
18
+
19
+ export function renderDiagramList() {
20
+ const list = document.getElementById('diagramList');
21
+ list.innerHTML = '';
22
+ if (!st.diagrams.length) {
23
+ list.innerHTML = `<p class="text-xs text-gray-400 dark:text-gray-600 px-3 py-3">${t('diagram.sidebar.empty')}</p>`;
24
+ return;
25
+ }
26
+ st.diagrams.forEach((d) => {
27
+ const isActive = d.id === st.currentDiagramId;
28
+ const item = document.createElement('div');
29
+ item.className = [
30
+ 'group flex items-center gap-1 px-2 py-1.5 mx-1 my-0.5 rounded-md cursor-pointer',
31
+ 'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
32
+ isActive ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'text-gray-700 dark:text-gray-300',
33
+ ].join(' ');
34
+
35
+ const titleSpan = document.createElement('span');
36
+ titleSpan.className = 'flex-1 text-sm truncate';
37
+ titleSpan.textContent = d.title;
38
+
39
+ const deleteBtn = document.createElement('button');
40
+ deleteBtn.title = t('diagram.sidebar.delete_title');
41
+ deleteBtn.className = 'hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-gray-400 hover:text-red-500 shrink-0';
42
+ deleteBtn.textContent = '✕';
43
+ deleteBtn.addEventListener('click', async (e) => {
44
+ e.stopPropagation();
45
+ await deleteDiagram(d.id);
46
+ });
47
+
48
+ item.appendChild(titleSpan);
49
+ item.appendChild(deleteBtn);
50
+ item.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') return; openDiagram(d.id); });
51
+ list.appendChild(item);
52
+ });
53
+ }
54
+
55
+ export async function loadDiagramList() {
56
+ const res = await fetch('/api/diagrams');
57
+ st.diagrams = await res.json();
58
+ renderDiagramList();
59
+ if (!st.diagrams.length) return;
60
+ const urlId = new URLSearchParams(window.location.search).get('id');
61
+ const target = urlId && st.diagrams.find((d) => d.id === urlId) ? urlId : st.diagrams[0].id;
62
+ openDiagram(target);
63
+ }
64
+
65
+ export async function openDiagram(id) {
66
+ const res = await fetch(`/api/diagrams/${id}`);
67
+ const diagram = await res.json();
68
+ st.currentDiagramId = id;
69
+ st.isDirty = false;
70
+ st.edgesStraight = diagram.edgesStraight === true;
71
+ document.getElementById('btnSave').disabled = true;
72
+ document.getElementById('diagramTitle').value = diagram.title || '';
73
+ document.getElementById('btnEdgeStraight').classList.toggle('tool-active', st.edgesStraight);
74
+ applyGridState(diagram.gridEnabled ?? true);
75
+ applyAlignGuidesState(diagram.alignGuides ?? true);
76
+ initNetwork(diagram.nodes || [], diagram.edges || [], st.edgesStraight);
77
+ resetHistory();
78
+ renderDiagramList();
79
+ }
80
+
81
+ export async function newDiagram() {
82
+ const id = 'd' + Date.now();
83
+ await fetch(`/api/diagrams/${id}`, {
84
+ method: 'PUT',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({ title: t('diagram.toast.new_diagram_title'), nodes: [], edges: [] }),
87
+ });
88
+ const res = await fetch('/api/diagrams');
89
+ st.diagrams = await res.json();
90
+ renderDiagramList();
91
+ openDiagram(id);
92
+ }
93
+
94
+ export async function deleteDiagram(id) {
95
+ if (!confirm(t('diagram.toast.confirm_delete'))) return;
96
+ await fetch(`/api/diagrams/${id}`, { method: 'DELETE' });
97
+ const res = await fetch('/api/diagrams');
98
+ st.diagrams = await res.json();
99
+
100
+ if (st.currentDiagramId === id) {
101
+ st.currentDiagramId = null;
102
+ if (st.network) { st.network.destroy(); st.network = null; }
103
+ st.nodes = null;
104
+ st.edges = null;
105
+ document.getElementById('diagramTitle').value = '';
106
+ document.getElementById('btnSave').disabled = true;
107
+ hideNodePanel();
108
+ hideEdgePanel();
109
+ hideLabelInput();
110
+ hideSelectionOverlay();
111
+ if (st.diagrams.length > 0) openDiagram(st.diagrams[0].id);
112
+ else document.getElementById('emptyState').classList.remove('hidden');
113
+ }
114
+ renderDiagramList();
115
+ }
116
+
117
+ export async function saveDiagram() {
118
+ if (!st.currentDiagramId || !st.network) return;
119
+ const positions = st.network.getPositions();
120
+
121
+ // Serialise in canonicalOrder so z-order is restored on next load.
122
+ const nodeData = st.canonicalOrder
123
+ .map((id) => st.nodes.get(id))
124
+ .filter(Boolean)
125
+ .map((n) => ({
126
+ id: n.id, label: n.label,
127
+ shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
128
+ nodeWidth: n.nodeWidth || null, nodeHeight: n.nodeHeight || null,
129
+ fontSize: n.fontSize || null, textAlign: n.textAlign || null, textValign: n.textValign || null,
130
+ bgOpacity: n.bgOpacity ?? null,
131
+ rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
132
+ imageSrc: n.imageSrc || null,
133
+ groupId: n.groupId || null,
134
+ nodeLink: n.nodeLink || null,
135
+ locked: n.locked || false,
136
+ x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
137
+ }));
138
+
139
+ const edgeData = st.edges.get().map((e) => ({
140
+ id: e.id, from: e.from, to: e.to,
141
+ label: e.label || '', arrowDir: e.arrowDir || 'to',
142
+ dashes: e.dashes || false, fontSize: e.fontSize || null,
143
+ labelRotation: e.labelRotation || 0,
144
+ edgeLabelOffsetX: e.edgeLabelOffsetX || 0, edgeLabelOffsetY: e.edgeLabelOffsetY || 0,
145
+ fromPort: e.fromPort || null, toPort: e.toPort || null,
146
+ edgeColor: e.edgeColor || null, edgeWidth: e.edgeWidth || null,
147
+ edgeLocked: e.edgeLocked || false, edgeLabelWidth: e.edgeLabelWidth || null,
148
+ }));
149
+
150
+ const title = document.getElementById('diagramTitle').value || t('diagram.toast.untitled');
151
+ await fetch(`/api/diagrams/${st.currentDiagramId}`, {
152
+ method: 'PUT',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ title, nodes: nodeData, edges: edgeData, edgesStraight: st.edgesStraight, gridEnabled: st.gridEnabled, alignGuides: st.alignGuides }),
155
+ });
156
+ st.isDirty = false;
157
+ document.getElementById('btnSave').disabled = true;
158
+ const res = await fetch('/api/diagrams');
159
+ st.diagrams = await res.json();
160
+ renderDiagramList();
161
+ }
@@ -0,0 +1,386 @@
1
+ // ── Connection ports (attachment points) ──────────────────────────────────────
2
+ // Each node exposes 8 attachment points: N, NE, E, SE, S, SW, W, NW.
3
+ // Port edges bypass vis-network's centre-to-centre rendering with custom bezier
4
+ // curves rooted at the chosen port positions.
5
+ //
6
+ // Backwards-compatible: edges without fromPort/toPort continue to use
7
+ // vis-network's native rendering unchanged.
8
+
9
+ import { st } from './state.js';
10
+ import { SHAPE_DEFAULTS } from './node-rendering.js';
11
+
12
+ /**
13
+ * Splits `text` into lines that each fit within `maxWidth` canvas units.
14
+ * Requires ctx.font to be set before calling.
15
+ */
16
+ export function wrapText(ctx, text, maxWidth) {
17
+ if (!maxWidth || !text) return [text || ''];
18
+ const words = text.split(/\s+/);
19
+ const lines = [];
20
+ let line = '';
21
+ for (const word of words) {
22
+ const test = line ? line + ' ' + word : word;
23
+ if (line && ctx.measureText(test).width > maxWidth) {
24
+ lines.push(line);
25
+ line = word;
26
+ } else {
27
+ line = test;
28
+ }
29
+ }
30
+ if (line) lines.push(line);
31
+ return lines.length ? lines : [text];
32
+ }
33
+
34
+ export const PORT_KEYS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
35
+
36
+ const SQRT2_INV = 1 / Math.sqrt(2);
37
+
38
+ // Offset fraction [ox, oy] from node centre, in units of half-width / half-height.
39
+ // Rectangular shapes: corners reach [±1, ±1], mid-sides [0, ±1] or [±1, 0].
40
+ const PORT_OFFSETS_RECT = {
41
+ N: [ 0, -1], NE: [ 1, -1], E: [ 1, 0], SE: [ 1, 1],
42
+ S: [ 0, 1], SW: [-1, 1], W: [-1, 0], NW: [-1, -1],
43
+ };
44
+
45
+ // Circular / elliptical shapes: all points lie on the circumference.
46
+ const PORT_OFFSETS_CIRC = {
47
+ N: [ 0, -1 ], NE: [ SQRT2_INV, -SQRT2_INV ],
48
+ E: [ 1, 0 ], SE: [ SQRT2_INV, SQRT2_INV ],
49
+ S: [ 0, 1 ], SW: [-SQRT2_INV, SQRT2_INV ],
50
+ W: [-1, 0 ], NW: [-SQRT2_INV, -SQRT2_INV ],
51
+ };
52
+
53
+ // Database / cylinder: diagonal ports sit at the cylinder wall (x=±1) at the
54
+ // junction between the side body and the elliptic cap (y ≈ ±0.76, derived from
55
+ // cap ry = H×0.12 → body_top_frac = 1 − 2×0.12 = 0.76).
56
+ const PORT_OFFSETS_DATABASE = {
57
+ N: [ 0, -1 ], NE: [ 1, -0.76 ],
58
+ E: [ 1, 0 ], SE: [ 1, 0.76 ],
59
+ S: [ 0, 1 ], SW: [-1, 0.76 ],
60
+ W: [-1, 0 ], NW: [-1, -0.76 ],
61
+ };
62
+
63
+ // Outward-pointing unit normals used to compute bezier control points.
64
+ const PORT_NORMALS = {
65
+ N: [ 0, -1 ], NE: [ SQRT2_INV, -SQRT2_INV ],
66
+ E: [ 1, 0 ], SE: [ SQRT2_INV, SQRT2_INV ],
67
+ S: [ 0, 1 ], SW: [-SQRT2_INV, SQRT2_INV ],
68
+ W: [-1, 0 ], NW: [-SQRT2_INV, -SQRT2_INV ],
69
+ };
70
+
71
+ // Shapes whose ports follow an elliptical boundary (no sharp corners).
72
+ const CIRCULAR_SHAPES = new Set(['circle', 'ellipse']);
73
+
74
+ // ── Geometry helpers ──────────────────────────────────────────────────────────
75
+
76
+ function nodeGeometry(nodeId) {
77
+ const n = st.nodes && st.nodes.get(nodeId);
78
+ if (!n) return null;
79
+ const pos = st.network && st.network.getPositions([nodeId])[nodeId];
80
+ if (!pos) return null;
81
+ const shapeType = n.shapeType || 'box';
82
+ const defaults = SHAPE_DEFAULTS[shapeType] || [100, 40];
83
+ const W = n.nodeWidth || defaults[0];
84
+ // 'circle' is always square (H = W); 'ellipse' uses its own height.
85
+ const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
86
+ return { cx: pos.x, cy: pos.y, W, H, rotation: n.rotation || 0, shapeType };
87
+ }
88
+
89
+ /** Returns the world-space {x, y} of a port on a node. */
90
+ export function getPortPosition(nodeId, portKey) {
91
+ const geo = nodeGeometry(nodeId);
92
+ if (!geo) return null;
93
+ const { cx, cy, W, H, rotation, shapeType } = geo;
94
+ const offsets = shapeType === 'database' ? PORT_OFFSETS_DATABASE
95
+ : CIRCULAR_SHAPES.has(shapeType) ? PORT_OFFSETS_CIRC
96
+ : PORT_OFFSETS_RECT;
97
+ const [ox, oy] = offsets[portKey] || [0, 0];
98
+ let dx = ox * W / 2;
99
+ let dy = oy * H / 2;
100
+ if (rotation) {
101
+ const cos = Math.cos(rotation), sin = Math.sin(rotation);
102
+ [dx, dy] = [dx * cos - dy * sin, dx * sin + dy * cos];
103
+ }
104
+ return { x: cx + dx, y: cy + dy };
105
+ }
106
+
107
+ /** Returns the port key whose position is closest to `canvasPos` (world coords). */
108
+ export function getNearestPort(nodeId, canvasPos) {
109
+ let minD2 = Infinity, nearest = 'N';
110
+ for (const key of PORT_KEYS) {
111
+ const p = getPortPosition(nodeId, key);
112
+ if (!p) continue;
113
+ const d2 = (canvasPos.x - p.x) ** 2 + (canvasPos.y - p.y) ** 2;
114
+ if (d2 < minD2) { minD2 = d2; nearest = key; }
115
+ }
116
+ return nearest;
117
+ }
118
+
119
+ // ── Port dot visualisation ────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Draws 8 port hint dots for `nodeId` in the vis-network afterDrawing context.
123
+ * `highlightedPort` (if any) is rendered larger and fully orange.
124
+ */
125
+ export function drawPortDots(ctx, nodeId, highlightedPort) {
126
+ for (const key of PORT_KEYS) {
127
+ const p = getPortPosition(nodeId, key);
128
+ if (!p) continue;
129
+ const hl = key === highlightedPort;
130
+ ctx.save();
131
+ ctx.beginPath();
132
+ ctx.arc(p.x, p.y, hl ? 6 : 4, 0, Math.PI * 2);
133
+ ctx.fillStyle = hl ? '#f97316' : 'rgba(249,115,22,0.45)';
134
+ ctx.strokeStyle = '#ffffff';
135
+ ctx.lineWidth = 1.5;
136
+ ctx.fill();
137
+ ctx.stroke();
138
+ ctx.restore();
139
+ }
140
+ }
141
+
142
+ // ── Custom edge rendering ─────────────────────────────────────────────────────
143
+
144
+ function bezierAt(p0, cp1, cp2, p3, t) {
145
+ const mt = 1 - t;
146
+ return {
147
+ x: mt ** 3 * p0.x + 3 * mt ** 2 * t * cp1.x + 3 * mt * t ** 2 * cp2.x + t ** 3 * p3.x,
148
+ y: mt ** 3 * p0.y + 3 * mt ** 2 * t * cp1.y + 3 * mt * t ** 2 * cp2.y + t ** 3 * p3.y,
149
+ };
150
+ }
151
+
152
+ function drawArrowhead(ctx, x, y, angle, size) {
153
+ ctx.save();
154
+ ctx.translate(x, y);
155
+ ctx.rotate(angle);
156
+ ctx.beginPath();
157
+ ctx.moveTo(0, 0);
158
+ ctx.lineTo(-size * 1.6, -size * 0.55);
159
+ ctx.lineTo(-size * 1.6, size * 0.55);
160
+ ctx.closePath();
161
+ ctx.fill();
162
+ ctx.restore();
163
+ }
164
+
165
+ function centerPos(nodeId) {
166
+ const p = st.network && st.network.getPositions([nodeId])[nodeId];
167
+ return p || null;
168
+ }
169
+
170
+ function isAnchorNode(nodeId) {
171
+ const n = st.nodes && st.nodes.get(nodeId);
172
+ return !!(n && n.shapeType === 'anchor');
173
+ }
174
+
175
+ /**
176
+ * Draws a port-anchored edge on the vis-network canvas context (world coordinates).
177
+ * Called from the _drawNodes patch instead of vis-network's native e.draw(ctx).
178
+ *
179
+ * Handles:
180
+ * - Bezier curve (when both ports are set and not in straight mode)
181
+ * - Straight line fallback
182
+ * - Arrowheads (to / both / none)
183
+ * - Dashes
184
+ * - Label (rotated or plain) at the curve midpoint
185
+ */
186
+ export function drawPortEdge(ctx, edgeData) {
187
+ const fromIsAnchor = isAnchorNode(edgeData.from);
188
+ const toIsAnchor = isAnchorNode(edgeData.to);
189
+
190
+ const fromPos = (edgeData.fromPort && !fromIsAnchor)
191
+ ? getPortPosition(edgeData.from, edgeData.fromPort)
192
+ : centerPos(edgeData.from);
193
+ const toPos = (edgeData.toPort && !toIsAnchor)
194
+ ? getPortPosition(edgeData.to, edgeData.toPort)
195
+ : centerPos(edgeData.to);
196
+
197
+ if (!fromPos || !toPos) return;
198
+
199
+ const selected = st.selectedEdgeIds && st.selectedEdgeIds.includes(edgeData.id);
200
+ const baseColor = edgeData.edgeColor || '#a8a29e';
201
+ const color = selected ? '#f97316' : baseColor;
202
+ const lw = selected ? Math.max(2.5, (edgeData.edgeWidth || 1.5) + 1) : (edgeData.edgeWidth || 1.5);
203
+ const arrSz = lw * 5;
204
+
205
+ ctx.save();
206
+ ctx.strokeStyle = color;
207
+ ctx.fillStyle = color;
208
+ ctx.lineWidth = lw;
209
+ if (edgeData.dashes) ctx.setLineDash([6, 4]);
210
+
211
+ // Bezier control points: only when both endpoints are real ports and not in straight mode.
212
+ let cp1, cp2;
213
+ const useBezier =
214
+ !st.edgesStraight &&
215
+ edgeData.fromPort && !fromIsAnchor &&
216
+ edgeData.toPort && !toIsAnchor;
217
+
218
+ if (useBezier) {
219
+ const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
220
+ const tension = Math.max(60, dist * 0.4);
221
+ const fn = PORT_NORMALS[edgeData.fromPort];
222
+ const tn = PORT_NORMALS[edgeData.toPort];
223
+ cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
224
+ cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
225
+ }
226
+
227
+ // ── Path ──────────────────────────────────────────────────────────────────
228
+ ctx.beginPath();
229
+ ctx.moveTo(fromPos.x, fromPos.y);
230
+ if (cp1 && cp2) ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, toPos.x, toPos.y);
231
+ else ctx.lineTo(toPos.x, toPos.y);
232
+ ctx.stroke();
233
+
234
+ // ── Arrowheads ─────────────────────────────────────────────────────────────
235
+ const arrowDir = edgeData.arrowDir ?? 'to';
236
+ const drawTo = arrowDir === 'to' || arrowDir === 'both';
237
+ const drawFrom = arrowDir === 'both';
238
+
239
+ if (drawTo) {
240
+ const a = cp2
241
+ ? Math.atan2(toPos.y - cp2.y, toPos.x - cp2.x)
242
+ : Math.atan2(toPos.y - fromPos.y, toPos.x - fromPos.x);
243
+ drawArrowhead(ctx, toPos.x, toPos.y, a, arrSz);
244
+ }
245
+ if (drawFrom) {
246
+ const a = cp1
247
+ ? Math.atan2(fromPos.y - cp1.y, fromPos.x - cp1.x)
248
+ : Math.atan2(fromPos.y - toPos.y, fromPos.x - toPos.x);
249
+ drawArrowhead(ctx, fromPos.x, fromPos.y, a, arrSz);
250
+ }
251
+
252
+ // ── Label midpoint — always compute so the label editor can open here ────────
253
+ const mid = (cp1 && cp2)
254
+ ? bezierAt(fromPos, cp1, cp2, toPos, 0.5)
255
+ : { x: (fromPos.x + toPos.x) / 2, y: (fromPos.y + toPos.y) / 2 };
256
+
257
+ const ox = edgeData.edgeLabelOffsetX || 0;
258
+ const oy = edgeData.edgeLabelOffsetY || 0;
259
+ const lx = mid.x + ox;
260
+ const ly = mid.y + oy;
261
+
262
+ // Store the DOM position using the canvas transform — same matrix used to
263
+ // draw the label, so it's pixel-perfect for the textarea positioning.
264
+ {
265
+ const m = ctx.getTransform();
266
+ const dpr = window.devicePixelRatio || 1;
267
+ const canvasEl = ctx.canvas;
268
+ const container = document.getElementById('vis-canvas').parentElement;
269
+ const canvasRect = canvasEl.getBoundingClientRect();
270
+ const containerRect = container.getBoundingClientRect();
271
+ st.edgeLabelCanvasPos[edgeData.id] = {
272
+ x: (m.a * lx + m.e) / dpr + (canvasRect.left - containerRect.left),
273
+ y: (m.d * ly + m.f) / dpr + (canvasRect.top - containerRect.top),
274
+ };
275
+ }
276
+
277
+ // ── Label ─────────────────────────────────────────────────────────────────
278
+ if (edgeData.label) {
279
+ const fontSize = edgeData.fontSize || 11;
280
+ const lineHeight = fontSize * 1.5;
281
+ const PAD_X = 6, PAD_Y = 4;
282
+
283
+ ctx.save();
284
+ ctx.translate(lx, ly);
285
+ if (edgeData.labelRotation && Math.abs(edgeData.labelRotation) > 0.001) {
286
+ ctx.rotate(edgeData.labelRotation);
287
+ }
288
+ ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
289
+
290
+ const fixedW = edgeData.edgeLabelWidth || null;
291
+ const innerW = fixedW ? fixedW - PAD_X * 2 : null;
292
+ const lines = fixedW ? wrapText(ctx, edgeData.label, innerW) : [edgeData.label];
293
+ const textW = fixedW ? innerW : ctx.measureText(edgeData.label).width;
294
+ const boxW = textW + PAD_X * 2;
295
+ const boxH = lines.length * lineHeight + PAD_Y * 2;
296
+
297
+ // Always store bbox (needed for resize handles, even when not selected)
298
+ st.edgeLabelBBox[edgeData.id] = {
299
+ cx: lx, cy: ly,
300
+ w: boxW, h: boxH,
301
+ rotation: edgeData.labelRotation || 0,
302
+ };
303
+
304
+ const totalH = lines.length * lineHeight;
305
+ const TEXT_PAD = 3; // tight padding behind text only (not the full resize box)
306
+ const isDark = document.documentElement.classList.contains('dark');
307
+ const bgFill = isDark ? 'rgba(3,7,18,0.82)' : 'rgba(249,250,251,0.82)';
308
+
309
+ // Opaque background tight around the text — makes the arrow appear to pass behind.
310
+ ctx.save();
311
+ ctx.fillStyle = bgFill;
312
+ ctx.fillRect(-textW / 2 - TEXT_PAD, -totalH / 2 - TEXT_PAD,
313
+ textW + TEXT_PAD * 2, totalH + TEXT_PAD * 2);
314
+ ctx.restore();
315
+
316
+ // Dashed border box — only when the edge is selected
317
+ if (st.selectedEdgeIds && st.selectedEdgeIds.includes(edgeData.id)) {
318
+ ctx.save();
319
+ ctx.strokeStyle = '#9ca3af';
320
+ ctx.lineWidth = 0.8;
321
+ ctx.setLineDash([3, 3]);
322
+ ctx.strokeRect(-boxW / 2, -boxH / 2, boxW, boxH);
323
+ ctx.setLineDash([]);
324
+ ctx.restore();
325
+ }
326
+
327
+ ctx.fillStyle = '#6b7280';
328
+ ctx.textAlign = 'center';
329
+ ctx.textBaseline = 'middle';
330
+ lines.forEach((line, i) => {
331
+ const y = -totalH / 2 + i * lineHeight + lineHeight / 2;
332
+ ctx.fillText(line, 0, y);
333
+ });
334
+
335
+ ctx.restore();
336
+ }
337
+
338
+ ctx.setLineDash([]);
339
+ ctx.restore();
340
+ }
341
+
342
+ // ── Hit detection ─────────────────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Returns the minimum distance (world units) from point `p` to the visual path
346
+ * of a port edge — matching the bezier geometry used by drawPortEdge exactly.
347
+ */
348
+ export function distanceToPortEdge(edgeData, p) {
349
+ const fromIsAnch = isAnchorNode(edgeData.from);
350
+ const toIsAnch = isAnchorNode(edgeData.to);
351
+ const fromPos = (edgeData.fromPort && !fromIsAnch)
352
+ ? getPortPosition(edgeData.from, edgeData.fromPort) : centerPos(edgeData.from);
353
+ const toPos = (edgeData.toPort && !toIsAnch)
354
+ ? getPortPosition(edgeData.to, edgeData.toPort) : centerPos(edgeData.to);
355
+ if (!fromPos || !toPos) return Infinity;
356
+
357
+ if (!st.edgesStraight && edgeData.fromPort && !fromIsAnch && edgeData.toPort && !toIsAnch) {
358
+ const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
359
+ const tension = Math.max(60, dist * 0.4);
360
+ const fn = PORT_NORMALS[edgeData.fromPort];
361
+ const tn = PORT_NORMALS[edgeData.toPort];
362
+ const cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
363
+ const cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
364
+ return _distToBezier(p, fromPos, cp1, cp2, toPos);
365
+ }
366
+ return _distToSegment(p, fromPos, toPos);
367
+ }
368
+
369
+ function _distToBezier(p, p0, cp1, cp2, p3, samples = 24) {
370
+ let minD = Infinity;
371
+ for (let i = 0; i <= samples; i++) {
372
+ const pt = bezierAt(p0, cp1, cp2, p3, i / samples);
373
+ const d = Math.hypot(p.x - pt.x, p.y - pt.y);
374
+ if (d < minD) minD = d;
375
+ }
376
+ return minD;
377
+ }
378
+
379
+ function _distToSegment(p, a, b) {
380
+ const dx = b.x - a.x, dy = b.y - a.y;
381
+ const len2 = dx * dx + dy * dy;
382
+ if (len2 === 0) return Math.hypot(p.x - a.x, p.y - a.y);
383
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
384
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
385
+ }
386
+