@imenam/mcp-feature-analyzer 1.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 (111) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +295 -0
  3. package/dist/shared/diagram-geometry.d.ts +45 -0
  4. package/dist/shared/diagram-geometry.js +120 -0
  5. package/dist/shared/diagram-layout.d.ts +98 -0
  6. package/dist/shared/diagram-layout.js +1156 -0
  7. package/dist/shared/diagram-quality.d.ts +30 -0
  8. package/dist/shared/diagram-quality.js +26 -0
  9. package/dist/shared/labels.d.ts +31 -0
  10. package/dist/shared/labels.js +53 -0
  11. package/dist/shared/mindmap-tree.d.ts +28 -0
  12. package/dist/shared/mindmap-tree.js +62 -0
  13. package/dist/shared/prompt.d.ts +17 -0
  14. package/dist/shared/prompt.js +97 -0
  15. package/dist/shared/review-state.d.ts +10 -0
  16. package/dist/shared/review-state.js +17 -0
  17. package/dist/shared/schemas/analysis.schema.d.ts +372 -0
  18. package/dist/shared/schemas/analysis.schema.js +152 -0
  19. package/dist/shared/schemas/api.schema.d.ts +92 -0
  20. package/dist/shared/schemas/api.schema.js +46 -0
  21. package/dist/shared/schemas/diff.schema.d.ts +91 -0
  22. package/dist/shared/schemas/diff.schema.js +37 -0
  23. package/dist/shared/schemas/ipc.schema.d.ts +29 -0
  24. package/dist/shared/schemas/ipc.schema.js +30 -0
  25. package/dist/shared/severity.d.ts +11 -0
  26. package/dist/shared/severity.js +17 -0
  27. package/dist/shared/text.d.ts +12 -0
  28. package/dist/shared/text.js +20 -0
  29. package/dist/src/cli/setup-mcp.d.ts +24 -0
  30. package/dist/src/cli/setup-mcp.js +145 -0
  31. package/dist/src/core/analysis-store.d.ts +95 -0
  32. package/dist/src/core/analysis-store.js +387 -0
  33. package/dist/src/core/data-dir.d.ts +5 -0
  34. package/dist/src/core/data-dir.js +11 -0
  35. package/dist/src/core/diff-parser.d.ts +22 -0
  36. package/dist/src/core/diff-parser.js +225 -0
  37. package/dist/src/core/errors.d.ts +16 -0
  38. package/dist/src/core/errors.js +28 -0
  39. package/dist/src/core/git.d.ts +35 -0
  40. package/dist/src/core/git.js +257 -0
  41. package/dist/src/core/ids.d.ts +16 -0
  42. package/dist/src/core/ids.js +41 -0
  43. package/dist/src/core/invariants.d.ts +24 -0
  44. package/dist/src/core/invariants.js +157 -0
  45. package/dist/src/core/locations.d.ts +30 -0
  46. package/dist/src/core/locations.js +66 -0
  47. package/dist/src/core/log-dir.d.ts +6 -0
  48. package/dist/src/core/log-dir.js +13 -0
  49. package/dist/src/core/migrate.d.ts +24 -0
  50. package/dist/src/core/migrate.js +53 -0
  51. package/dist/src/core/refresh.d.ts +30 -0
  52. package/dist/src/core/refresh.js +113 -0
  53. package/dist/src/core/request-guard.d.ts +32 -0
  54. package/dist/src/core/request-guard.js +72 -0
  55. package/dist/src/core/review-actions.d.ts +27 -0
  56. package/dist/src/core/review-actions.js +99 -0
  57. package/dist/src/gui-worker.d.ts +1 -0
  58. package/dist/src/gui-worker.js +401 -0
  59. package/dist/src/index.d.ts +2 -0
  60. package/dist/src/index.js +658 -0
  61. package/dist/src/tools/add-findings.d.ts +9 -0
  62. package/dist/src/tools/add-findings.js +106 -0
  63. package/dist/src/tools/create-analysis.d.ts +4 -0
  64. package/dist/src/tools/create-analysis.js +36 -0
  65. package/dist/src/tools/delete-analysis.d.ts +4 -0
  66. package/dist/src/tools/delete-analysis.js +11 -0
  67. package/dist/src/tools/delete-diagram.d.ts +4 -0
  68. package/dist/src/tools/delete-diagram.js +22 -0
  69. package/dist/src/tools/delete-findings.d.ts +7 -0
  70. package/dist/src/tools/delete-findings.js +27 -0
  71. package/dist/src/tools/format.d.ts +27 -0
  72. package/dist/src/tools/format.js +92 -0
  73. package/dist/src/tools/get-analysis.d.ts +4 -0
  74. package/dist/src/tools/get-analysis.js +87 -0
  75. package/dist/src/tools/get-diff.d.ts +4 -0
  76. package/dist/src/tools/get-diff.js +67 -0
  77. package/dist/src/tools/get-review-feedback.d.ts +4 -0
  78. package/dist/src/tools/get-review-feedback.js +71 -0
  79. package/dist/src/tools/inputs.d.ts +14 -0
  80. package/dist/src/tools/inputs.js +40 -0
  81. package/dist/src/tools/list-analyses.d.ts +8 -0
  82. package/dist/src/tools/list-analyses.js +71 -0
  83. package/dist/src/tools/refresh-analysis.d.ts +7 -0
  84. package/dist/src/tools/refresh-analysis.js +39 -0
  85. package/dist/src/tools/set-diagram.d.ts +16 -0
  86. package/dist/src/tools/set-diagram.js +207 -0
  87. package/dist/src/tools/types.d.ts +52 -0
  88. package/dist/src/tools/types.js +133 -0
  89. package/dist/src/tools/update-analysis.d.ts +4 -0
  90. package/dist/src/tools/update-analysis.js +121 -0
  91. package/dist/src/tools/update-finding.d.ts +8 -0
  92. package/dist/src/tools/update-finding.js +91 -0
  93. package/dist/src/version.d.ts +1 -0
  94. package/dist/src/version.js +2 -0
  95. package/gui/dist/assets/index-CU9pNYDp.js +72 -0
  96. package/gui/dist/assets/index-Du5eL78F.css +1 -0
  97. package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  98. package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  99. package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  100. package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  101. package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  102. package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  103. package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  104. package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  105. package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  106. package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  107. package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  108. package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  109. package/gui/dist/index.html +13 -0
  110. package/package.json +56 -0
  111. package/scripts/generate-version.js +20 -0
@@ -0,0 +1,1156 @@
1
+ /**
2
+ * Placement des schémas : position et taille de chaque nœud, tracé de chaque
3
+ * lien, pour les trois sortes de schéma (`flow`, `layers`, `mindmap`).
4
+ * Fonctions pures et déterministes, sans dépendance, partagées par la GUI et
5
+ * les tests. Un schéma incohérent lève une erreur explicite.
6
+ */
7
+ import { routeIssues, issueCount } from "./diagram-geometry.js";
8
+ import { mindmapProblems } from "./mindmap-tree.js";
9
+ // --- Mesure du texte ---------------------------------------------------------
10
+ /** Largeur moyenne d'un caractère en police à chasse fixe (JetBrains Mono : 0,6 em exactement). */
11
+ export const MONO_CHAR_WIDTH = 0.6;
12
+ /** Largeur moyenne d'un caractère en Inter, majorée pour couvrir capitales et accents d'un texte courant. */
13
+ export const SANS_CHAR_WIDTH = 0.56;
14
+ /** Estimation déterministe : nombre de caractères × largeur moyenne × taille de police. */
15
+ export function estimateTextWidth(text, font) {
16
+ return [...text].length * font.size * (font.mono ? MONO_CHAR_WIDTH : SANS_CHAR_WIDTH);
17
+ }
18
+ export const LABEL_FONT_MONO = { size: 12, weight: 400, mono: true };
19
+ export const LABEL_FONT_SANS = { size: 12.5, weight: 400, mono: false };
20
+ export const MISSING_LABEL_FONT = { size: 12.5, weight: 500, mono: false };
21
+ export const DECISION_FONT = { size: 11.5, weight: 500, mono: false };
22
+ export const DETAIL_FONT = { size: 11, weight: 400, mono: false };
23
+ export const LINK_LABEL_FONT = { size: 10.5, weight: 400, mono: false };
24
+ export const HEADER_FONT = { size: 10.5, weight: 600, mono: false };
25
+ /** Police du libellé d'un nœud : chasse fixe pour un nœud rattaché à un fichier. */
26
+ export function labelFont(node) {
27
+ if (node.shape === "decision")
28
+ return DECISION_FONT;
29
+ if (node.status === "missing")
30
+ return MISSING_LABEL_FONT;
31
+ return node.location ? LABEL_FONT_MONO : LABEL_FONT_SANS;
32
+ }
33
+ // --- Dimensions des nœuds ----------------------------------------------------
34
+ export const BOX_MIN_WIDTH = 140;
35
+ export const BOX_HEIGHT = 44;
36
+ export const BOX_RADIUS = 10;
37
+ export const PILL_MIN_WIDTH = 80;
38
+ export const PILL_HEIGHT = 40;
39
+ export const DECISION_MIN_SIZE = 84;
40
+ /** Marge ajoutée au libellé d'un losange : le texte tient dans sa diagonale horizontale. */
41
+ export const DECISION_TEXT_MARGIN = 36;
42
+ /** Hauteur d'un nœud « manquant » qui affiche son détail sur une deuxième ligne. */
43
+ export const DETAIL_NODE_HEIGHT = 52;
44
+ export const NODE_PADDING_X = 16;
45
+ export const PILL_PADDING_X = 24;
46
+ /** Place de l'icône de fichier : 14 px d'icône et 8 px d'espace. */
47
+ export const ICON_SLOT = 22;
48
+ /** Place de la pastille de constat à droite du libellé. */
49
+ export const DOT_SLOT = 14;
50
+ /** Largeur minimale d'une colonne de schéma `layers`. */
51
+ export const LAYER_MIN_WIDTH = 180;
52
+ /** Vrai quand le nœud affiche une icône de fichier. */
53
+ export function hasFileIcon(node) {
54
+ return node.location !== null && node.shape !== "decision";
55
+ }
56
+ /** Vrai quand le nœud affiche son détail sur une deuxième ligne (nœud « manquant »). */
57
+ export function showsDetailLine(node) {
58
+ return node.status === "missing" && node.shape !== "decision" && node.detail !== null && node.detail !== "";
59
+ }
60
+ function nodeSize(node, measure) {
61
+ const label = measure(node.label, labelFont(node));
62
+ if (node.shape === "decision") {
63
+ const side = Math.max(DECISION_MIN_SIZE, Math.ceil(label + DECISION_TEXT_MARGIN));
64
+ return { width: side, height: side };
65
+ }
66
+ const detail = showsDetailLine(node) ? measure(node.detail, DETAIL_FONT) : 0;
67
+ const text = Math.max(label, detail);
68
+ const extras = (hasFileIcon(node) ? ICON_SLOT : 0) + (node.location ? DOT_SLOT : 0);
69
+ if (node.shape === "pill") {
70
+ return {
71
+ width: Math.max(PILL_MIN_WIDTH, Math.ceil(text + 2 * PILL_PADDING_X + extras)),
72
+ height: detail > 0 ? DETAIL_NODE_HEIGHT : PILL_HEIGHT,
73
+ };
74
+ }
75
+ return {
76
+ width: Math.max(BOX_MIN_WIDTH, Math.ceil(text + 2 * NODE_PADDING_X + extras)),
77
+ height: detail > 0 ? DETAIL_NODE_HEIGHT : BOX_HEIGHT,
78
+ };
79
+ }
80
+ // --- Constantes de placement -------------------------------------------------
81
+ /** Marge autour du schéma. */
82
+ export const LAYOUT_PADDING = 24;
83
+ /** Écart horizontal minimal entre deux rangs d'un flux : longueur d'une flèche droite. */
84
+ const RANK_GAP = 40;
85
+ /** Écart vertical entre deux nœuds d'un même rang ou d'une même colonne. */
86
+ const NODE_GAP = 36;
87
+ /** Écart vertical autour d'un point de passage d'un lien long. */
88
+ const DUMMY_GAP = 18;
89
+ /** Écart minimal entre deux couloirs verticaux de liens dans un intervalle de rangs. */
90
+ const CHANNEL_SPACING = 12;
91
+ /** Écart vertical entre un nœud et la branche suspendue sous lui. */
92
+ const HANG_GAP = 36;
93
+ /** Distance horizontale minimale entre un nœud suspendu qui déborde de son rang et un nœud d'un autre rang. */
94
+ const HANG_CLEARANCE = 24;
95
+ /** Marge verticale autour d'un lien ou d'un nœud qu'un nœud suspendu ne doit pas recouvrir. */
96
+ const LINK_CLEARANCE = 16;
97
+ /** Décalage du couloir d'un lien de retour par rapport au bord du rang. */
98
+ const BACK_OFFSET = 14;
99
+ /** Distance entre le bas du schéma et le premier couloir des liens de retour. */
100
+ const BACK_CHANNEL_GAP = 30;
101
+ const BACK_CHANNEL_SPACING = 14;
102
+ /** Taille de la boucle d'un lien d'un nœud vers lui-même. */
103
+ const LOOP_SIZE = 14;
104
+ /** Distance entre un lien et son libellé. */
105
+ const LABEL_OFFSET = 8;
106
+ const LABEL_MARGIN = 8;
107
+ /** Écart horizontal entre deux colonnes d'un schéma `layers`. */
108
+ const COLUMN_GAP = 60;
109
+ /** Distance entre l'en-tête d'une colonne et son premier nœud. */
110
+ const HEADER_OFFSET = 40;
111
+ const HEADER_HEIGHT = 14;
112
+ /** Marge verticale d'un nœud qu'un lien droit ne doit pas traverser. */
113
+ const CROSS_MARGIN = 6;
114
+ /** Écart entre un nœud de carte mentale et son parent, le long du rayon. */
115
+ const RING_GAP = 48;
116
+ /** Distance minimale entre deux nœuds d'une carte mentale. */
117
+ const MINDMAP_CLEARANCE = 16;
118
+ /** Pas d'éloignement d'un nœud qui en chevauche un autre. */
119
+ const RING_STEP = 8;
120
+ const MAX_RING_ITERATIONS = 20000;
121
+ /** Passes de réduction des croisements et de placement vertical. */
122
+ const ORDER_SWEEPS = 8;
123
+ /** Passes maximales d'échange de deux voisins d'un rang ou d'une colonne. */
124
+ const TRANSPOSE_PASSES = 6;
125
+ const PLACEMENT_PASSES = 12;
126
+ /** Poids d'un lien entre deux nœuds alignés, pour garder droite la branche principale. */
127
+ const ALIGN_WEIGHT = 1000;
128
+ /** Poids d'un nœud sans voisin dans la direction de la passe : il reste presque où il est. */
129
+ const FREE_WEIGHT = 0.1;
130
+ /** Écart vertical en deçà duquel un élément aligné est calé sur son voisin. */
131
+ const SNAP_DISTANCE = 4;
132
+ /** Écart vertical en deçà duquel un lien entre deux rangs est tracé droit, sans coude. */
133
+ const STRAIGHT_TOLERANCE = 2;
134
+ function rightMid(box) {
135
+ return { x: box.x + box.width, y: box.y + box.height / 2 };
136
+ }
137
+ function leftMid(box) {
138
+ return { x: box.x, y: box.y + box.height / 2 };
139
+ }
140
+ function centerOf(box) {
141
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
142
+ }
143
+ /** Moyenne pondérée ; null sans poids. */
144
+ function weightedMean(values) {
145
+ let sum = 0;
146
+ let total = 0;
147
+ for (const { value, weight } of values) {
148
+ sum += value * weight;
149
+ total += weight;
150
+ }
151
+ return total > 0 ? sum / total : null;
152
+ }
153
+ /**
154
+ * Régression isotone pondérée (fusion des blocs adjacents en désordre) :
155
+ * suite croissante la plus proche des cibles au sens des moindres carrés.
156
+ */
157
+ function isotonic(targets, weights) {
158
+ const blocks = [];
159
+ targets.forEach((target, index) => {
160
+ blocks.push({ value: target, weight: weights[index], count: 1 });
161
+ while (blocks.length > 1 && blocks[blocks.length - 2].value > blocks[blocks.length - 1].value) {
162
+ const last = blocks.pop();
163
+ const previous = blocks[blocks.length - 1];
164
+ const weight = previous.weight + last.weight;
165
+ previous.value = (previous.value * previous.weight + last.value * last.weight) / weight;
166
+ previous.weight = weight;
167
+ previous.count += last.count;
168
+ }
169
+ });
170
+ return blocks.flatMap((block) => new Array(block.count).fill(block.value));
171
+ }
172
+ /**
173
+ * Place une pile de nœuds ordonnée au plus près des centres souhaités, sans
174
+ * chevauchement : `gaps[i]` sépare l'élément `i` du suivant. Renvoie les centres.
175
+ */
176
+ function placeStack(heights, gaps, desired, weights) {
177
+ const offsets = [];
178
+ let offset = 0;
179
+ heights.forEach((height, index) => {
180
+ offsets.push(offset);
181
+ offset += height + (gaps[index] ?? 0);
182
+ });
183
+ const targets = desired.map((center, index) => center - heights[index] / 2 - offsets[index]);
184
+ return isotonic(targets, weights).map((value, index) => value + offsets[index] + heights[index] / 2);
185
+ }
186
+ /** Centres d'une pile posée à partir de 0. */
187
+ function stackCenters(heights, gaps) {
188
+ const centers = [];
189
+ let top = 0;
190
+ heights.forEach((height, index) => {
191
+ centers.push(top + height / 2);
192
+ top += height + (gaps[index] ?? 0);
193
+ });
194
+ return centers;
195
+ }
196
+ /** Tri stable d'une liste selon une clé numérique, départagé par la position d'origine. */
197
+ function stableSortBy(items, key) {
198
+ return items
199
+ .map((item, index) => ({ item, index, key: key(item) }))
200
+ .sort((a, b) => a.key - b.key || a.index - b.index)
201
+ .map(({ item }) => item);
202
+ }
203
+ /** Boucle d'un nœud vers lui-même : sort à droite, entre par le haut. */
204
+ function selfLoop(box, id) {
205
+ const start = rightMid(box);
206
+ const loopX = start.x + LOOP_SIZE;
207
+ const loopY = box.y - LOOP_SIZE;
208
+ const centerX = box.x + box.width / 2;
209
+ return {
210
+ from: id,
211
+ to: id,
212
+ points: [start, { x: loopX, y: start.y }, { x: loopX, y: loopY }, { x: centerX, y: loopY }, { x: centerX, y: box.y }],
213
+ labelPosition: { x: (centerX + loopX) / 2, y: loopY - LABEL_OFFSET },
214
+ labelAnchor: "middle",
215
+ back: true,
216
+ };
217
+ }
218
+ /** Supprime les points intermédiaires alignés avec leurs voisins. */
219
+ function simplify(points) {
220
+ const result = [];
221
+ for (const point of points) {
222
+ const last = result[result.length - 1];
223
+ if (last && Math.abs(last.x - point.x) < 1e-6 && Math.abs(last.y - point.y) < 1e-6)
224
+ continue;
225
+ if (result.length >= 2) {
226
+ const before = result[result.length - 2];
227
+ const cross = (last.x - before.x) * (point.y - before.y) - (last.y - before.y) * (point.x - before.x);
228
+ if (Math.abs(cross) < 1e-6)
229
+ result.pop();
230
+ }
231
+ result.push(point);
232
+ }
233
+ return result;
234
+ }
235
+ /** Point milieu du segment central d'une ligne brisée. */
236
+ function middleOf(points) {
237
+ const index = Math.max(0, Math.floor((points.length - 1) / 2));
238
+ const a = points[index];
239
+ const b = points[Math.min(index + 1, points.length - 1)];
240
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
241
+ }
242
+ /** Vérifie les nœuds et les liens, puis indexe les liens. */
243
+ function indexLinks(diagram) {
244
+ if (diagram.nodes.length === 0)
245
+ throw new Error(`Le schéma « ${diagram.title} » ne contient aucun nœud.`);
246
+ const index = new Map();
247
+ diagram.nodes.forEach((node, position) => {
248
+ if (index.has(node.id))
249
+ throw new Error(`Le nœud « ${node.id} » apparaît deux fois dans le schéma « ${diagram.title} ».`);
250
+ index.set(node.id, position);
251
+ });
252
+ const links = diagram.links.map((link) => {
253
+ const u = index.get(link.from);
254
+ const v = index.get(link.to);
255
+ if (u === undefined || v === undefined) {
256
+ const missing = u === undefined ? link.from : link.to;
257
+ throw new Error(`Le lien ${link.from} → ${link.to} référence le nœud « ${missing} », absent du schéma « ${diagram.title} ».`);
258
+ }
259
+ return { u, v, label: link.label };
260
+ });
261
+ return { index, links };
262
+ }
263
+ function scoreLayout(raw, links) {
264
+ const issues = issueCount(routeIssues(raw.boxes, raw.links.map((link, k) => ({ u: links[k].u, v: links[k].v, points: link.points }))));
265
+ let stretch = 0;
266
+ for (const link of raw.links) {
267
+ for (let i = 1; i < link.points.length; i++)
268
+ stretch += Math.abs(link.points[i].y - link.points[i - 1].y);
269
+ }
270
+ return { issues, stretch };
271
+ }
272
+ function better(a, b) {
273
+ return a.issues < b.issues || (a.issues === b.issues && a.stretch < b.stretch - 1e-6);
274
+ }
275
+ /**
276
+ * Ordre des éléments de chaque rang ou colonne : le candidat dont le tracé
277
+ * final a le moins de problèmes (à égalité, le moins étiré, puis le premier),
278
+ * puis, tant qu'il reste des problèmes, échanges de deux voisins d'un même rang
279
+ * conservés quand ils en retirent. `score` trace le schéma pour un ordre donné.
280
+ */
281
+ function chooseOrder(candidates, score) {
282
+ let best = candidates[0];
283
+ let bestScore = score(best);
284
+ for (const candidate of candidates.slice(1)) {
285
+ const current = score(candidate);
286
+ if (better(current, bestScore)) {
287
+ best = candidate;
288
+ bestScore = current;
289
+ }
290
+ }
291
+ const order = best.map((layer) => [...layer]);
292
+ for (let pass = 0; pass < TRANSPOSE_PASSES && bestScore.issues > 0; pass++) {
293
+ let improved = false;
294
+ for (const layer of order) {
295
+ for (let i = 0; i + 1 < layer.length && bestScore.issues > 0; i++) {
296
+ [layer[i], layer[i + 1]] = [layer[i + 1], layer[i]];
297
+ const current = score(order);
298
+ if (current.issues < bestScore.issues) {
299
+ bestScore = current;
300
+ improved = true;
301
+ }
302
+ else {
303
+ [layer[i], layer[i + 1]] = [layer[i + 1], layer[i]];
304
+ }
305
+ }
306
+ }
307
+ if (!improved)
308
+ break;
309
+ }
310
+ return order;
311
+ }
312
+ function layoutFlow(diagram, sizes, links, measure) {
313
+ const count = diagram.nodes.length;
314
+ // Liens de retour : repérés par un parcours en profondeur qui part des sources.
315
+ const outgoing = Array.from({ length: count }, () => []);
316
+ const incoming = new Array(count).fill(0);
317
+ links.forEach((link, k) => {
318
+ if (link.u === link.v)
319
+ return;
320
+ outgoing[link.u].push(k);
321
+ incoming[link.v]++;
322
+ });
323
+ const state = new Array(count).fill(0);
324
+ const visitOrder = new Array(count).fill(-1);
325
+ const back = new Array(links.length).fill(false);
326
+ let visited = 0;
327
+ const starts = [...diagram.nodes.keys()].filter((node) => incoming[node] === 0).concat([...diagram.nodes.keys()]);
328
+ for (const start of starts) {
329
+ if (state[start] !== 0)
330
+ continue;
331
+ state[start] = 1;
332
+ visitOrder[start] = visited++;
333
+ const stack = [{ node: start, next: 0 }];
334
+ while (stack.length > 0) {
335
+ const top = stack[stack.length - 1];
336
+ if (top.next < outgoing[top.node].length) {
337
+ const k = outgoing[top.node][top.next++];
338
+ const target = links[k].v;
339
+ if (state[target] === 1)
340
+ back[k] = true;
341
+ else if (state[target] === 0) {
342
+ state[target] = 1;
343
+ visitOrder[target] = visited++;
344
+ stack.push({ node: target, next: 0 });
345
+ }
346
+ }
347
+ else {
348
+ state[top.node] = 2;
349
+ stack.pop();
350
+ }
351
+ }
352
+ }
353
+ // Branches suspendues : une branche secondaire sans jonction, ou le nœud terminal d'une chaîne,
354
+ // descend verticalement sous le nœud qui la précède au lieu d'occuper un rang de plus.
355
+ const fwdSuccs = Array.from({ length: count }, () => []);
356
+ const fwdPreds = Array.from({ length: count }, () => []);
357
+ const looped = new Array(count).fill(false);
358
+ links.forEach((link, k) => {
359
+ if (link.u === link.v || back[k]) {
360
+ looped[link.u] = true;
361
+ looped[link.v] = true;
362
+ return;
363
+ }
364
+ if (!fwdSuccs[link.u].includes(link.v))
365
+ fwdSuccs[link.u].push(link.v);
366
+ if (!fwdPreds[link.v].includes(link.u))
367
+ fwdPreds[link.v].push(link.u);
368
+ });
369
+ /** Chaîne linéaire issue de `start` (un prédécesseur, au plus un successeur, sans cycle), ou null. */
370
+ const chainFrom = (start) => {
371
+ const chain = [];
372
+ for (let node = start;; node = fwdSuccs[node][0]) {
373
+ if (fwdPreds[node].length !== 1 || looped[node] || fwdSuccs[node].length > 1)
374
+ return null;
375
+ chain.push(node);
376
+ if (fwdSuccs[node].length === 0)
377
+ return chain;
378
+ }
379
+ };
380
+ const hanging = Array.from({ length: count }, () => []);
381
+ const hung = new Array(count).fill(false);
382
+ for (let node = 0; node < count; node++) {
383
+ const succs = fwdSuccs[node];
384
+ let chain = null;
385
+ for (let i = 1; i < succs.length && chain === null; i++)
386
+ chain = chainFrom(succs[i]);
387
+ if (chain === null && succs.length === 1 && fwdPreds[node].length > 0 && fwdSuccs[succs[0]].length === 0) {
388
+ chain = chainFrom(succs[0]);
389
+ }
390
+ if (chain === null)
391
+ continue;
392
+ hanging[node] = chain;
393
+ for (const member of chain)
394
+ hung[member] = true;
395
+ }
396
+ for (let node = 0; node < count; node++)
397
+ if (hung[node])
398
+ hanging[node] = [];
399
+ const spine = [...diagram.nodes.keys()].filter((node) => !hung[node]);
400
+ // Rangs : plus long chemin depuis les sources, sur le graphe sans liens de retour ni branches suspendues.
401
+ const forward = links.map((link, k) => ({ ...link, k })).filter((link) => link.u !== link.v && !back[link.k] && !hung[link.v]);
402
+ const preds = Array.from({ length: count }, () => []);
403
+ const succs = Array.from({ length: count }, () => []);
404
+ for (const link of forward) {
405
+ succs[link.u].push(link.v);
406
+ preds[link.v].push(link.u);
407
+ }
408
+ const rank = new Array(count).fill(0);
409
+ const remaining = preds.map((list) => list.length);
410
+ const ready = spine.filter((node) => remaining[node] === 0);
411
+ while (ready.length > 0) {
412
+ ready.sort((a, b) => visitOrder[a] - visitOrder[b]);
413
+ const node = ready.shift();
414
+ for (const next of succs[node]) {
415
+ rank[next] = Math.max(rank[next], rank[node] + 1);
416
+ if (--remaining[next] === 0)
417
+ ready.push(next);
418
+ }
419
+ }
420
+ // Une source se place juste avant son premier successeur.
421
+ for (const node of spine) {
422
+ if (preds[node].length === 0 && succs[node].length > 0) {
423
+ rank[node] = Math.min(...succs[node].map((next) => rank[next])) - 1;
424
+ }
425
+ }
426
+ const rankCount = Math.max(...spine.map((node) => rank[node])) + 1;
427
+ hanging.forEach((chain, node) => chain.forEach((member) => (rank[member] = rank[node])));
428
+ // Éléments des rangs : un nœud porte sa branche suspendue ; ses liens horizontaux partent à `above` de son bord haut.
429
+ const stackHeight = (node) => hanging[node].reduce((total, member) => total + HANG_GAP + sizes[member].height, sizes[node].height);
430
+ const items = diagram.nodes.map((_, node) => ({
431
+ node,
432
+ rank: rank[node],
433
+ width: sizes[node].width,
434
+ height: stackHeight(node),
435
+ above: sizes[node].height / 2,
436
+ }));
437
+ const chains = new Map();
438
+ for (const link of forward) {
439
+ const chain = [link.u];
440
+ for (let r = rank[link.u] + 1; r < rank[link.v]; r++) {
441
+ items.push({ node: null, rank: r, width: 0, height: 0, above: 0 });
442
+ chain.push(items.length - 1);
443
+ }
444
+ chain.push(link.v);
445
+ chains.set(link.k, chain);
446
+ }
447
+ const itemPreds = items.map(() => []);
448
+ const itemSuccs = items.map(() => []);
449
+ for (const chain of chains.values()) {
450
+ for (let i = 1; i < chain.length; i++) {
451
+ itemSuccs[chain[i - 1]].push(chain[i]);
452
+ itemPreds[chain[i]].push(chain[i - 1]);
453
+ }
454
+ }
455
+ // Ordres candidats dans chaque rang : ordre de déclaration (ordre de visite), puis l'ordre obtenu
456
+ // après chaque balayage barycentrique, alternativement vers l'aval et vers l'amont.
457
+ let layers = Array.from({ length: rankCount }, () => []);
458
+ stableSortBy(spine, (node) => visitOrder[node]).forEach((node) => layers[rank[node]].push(node));
459
+ for (let item = count; item < items.length; item++)
460
+ layers[items[item].rank].push(item);
461
+ const position = new Array(items.length).fill(0);
462
+ const refresh = () => layers.forEach((layer) => layer.forEach((item, index) => (position[item] = index)));
463
+ refresh();
464
+ const reorder = (r, neighbours) => {
465
+ layers[r] = stableSortBy(layers[r], (item) => {
466
+ const list = neighbours[item];
467
+ return list.length > 0 ? list.reduce((sum, other) => sum + position[other], 0) / list.length : position[item];
468
+ });
469
+ layers[r].forEach((item, index) => (position[item] = index));
470
+ };
471
+ const candidates = [layers.map((layer) => [...layer])];
472
+ for (let sweep = 0; sweep < ORDER_SWEEPS; sweep++) {
473
+ if (sweep % 2 === 0)
474
+ for (let r = 1; r < rankCount; r++)
475
+ reorder(r, itemPreds);
476
+ else
477
+ for (let r = rankCount - 2; r >= 0; r--)
478
+ reorder(r, itemSuccs);
479
+ candidates.push(layers.map((layer) => [...layer]));
480
+ }
481
+ /** Trace le flux pour un ordre donné des éléments de chaque rang. */
482
+ const build = (order) => {
483
+ layers = order.map((layer) => [...layer]);
484
+ refresh();
485
+ // Alignement : chaque élément s'aligne sur son voisin médian du rang précédent, si celui-ci est libre.
486
+ const aligned = new Set();
487
+ const taken = new Set();
488
+ for (let r = 1; r < rankCount; r++) {
489
+ let lastPosition = -1;
490
+ for (const item of layers[r]) {
491
+ const candidates = [...new Set(itemPreds[item])].sort((a, b) => position[a] - position[b]);
492
+ if (candidates.length === 0)
493
+ continue;
494
+ const median = candidates[Math.floor((candidates.length - 1) / 2)];
495
+ if (!taken.has(median) && position[median] > lastPosition) {
496
+ taken.add(median);
497
+ aligned.add(`${median}>${item}`);
498
+ lastPosition = position[median];
499
+ }
500
+ }
501
+ }
502
+ const hopWeight = (a, b) => (aligned.has(`${a}>${b}`) || aligned.has(`${b}>${a}`) ? ALIGN_WEIGHT : 1);
503
+ // Placement vertical : passes alternées vers l'aval et vers l'amont. `port[item]` est l'ordonnée
504
+ // de ses liens horizontaux ; `shift` passe du port au centre de l'élément empilé.
505
+ const above = (item) => items[item].above;
506
+ const below = (item) => items[item].height - items[item].above;
507
+ const shift = (item) => items[item].height / 2 - items[item].above;
508
+ const gapsOf = (layer) => layer.slice(0, -1).map((item, index) => (items[item].node === null || items[layer[index + 1]].node === null ? DUMMY_GAP : NODE_GAP));
509
+ const port = new Array(items.length).fill(0);
510
+ for (const layer of layers) {
511
+ stackCenters(layer.map((item) => items[item].height), gapsOf(layer)).forEach((value, index) => (port[layer[index]] = value - shift(layer[index])));
512
+ }
513
+ for (let pass = 0; pass < PLACEMENT_PASSES; pass++) {
514
+ const mode = pass === PLACEMENT_PASSES - 1 ? "both" : pass % 2 === 0 ? "down" : "up";
515
+ const order = mode === "up" ? [...layers.keys()].reverse() : [...layers.keys()];
516
+ for (const r of order) {
517
+ const layer = layers[r];
518
+ const desired = [];
519
+ const weights = [];
520
+ for (const item of layer) {
521
+ const neighbours = mode === "down" ? itemPreds[item] : mode === "up" ? itemSuccs[item] : [...itemPreds[item], ...itemSuccs[item]];
522
+ const values = neighbours.map((other) => ({ value: port[other], weight: hopWeight(item, other) }));
523
+ const mean = weightedMean(values);
524
+ desired.push((mean ?? port[item]) + shift(item));
525
+ weights.push(mean === null ? FREE_WEIGHT : values.reduce((sum, entry) => sum + entry.weight, 0));
526
+ }
527
+ placeStack(layer.map((item) => items[item].height), gapsOf(layer), desired, weights).forEach((value, index) => (port[layer[index]] = value - shift(layer[index])));
528
+ }
529
+ }
530
+ // Redressement : un élément aligné presque à hauteur de son voisin s'y cale exactement ; ses voisins de rang s'écartent d'autant.
531
+ for (let r = 1; r < rankCount; r++) {
532
+ const layer = layers[r];
533
+ const gaps = gapsOf(layer);
534
+ layer.forEach((item, index) => {
535
+ const partner = itemPreds[item].find((other) => aligned.has(`${other}>${item}`));
536
+ if (partner === undefined || Math.abs(port[partner] - port[item]) > SNAP_DISTANCE)
537
+ return;
538
+ port[item] = port[partner];
539
+ for (let j = index + 1; j < layer.length; j++) {
540
+ const minimum = port[layer[j - 1]] + below(layer[j - 1]) + gaps[j - 1] + above(layer[j]);
541
+ if (port[layer[j]] < minimum)
542
+ port[layer[j]] = minimum;
543
+ }
544
+ for (let j = index - 1; j >= 0; j--) {
545
+ const maximum = port[layer[j + 1]] - above(layer[j + 1]) - gaps[j] - below(layer[j]);
546
+ if (port[layer[j]] > maximum)
547
+ port[layer[j]] = maximum;
548
+ }
549
+ });
550
+ }
551
+ // Ordonnées des boîtes : un nœud du flux principal centré sur son port, sa branche suspendue empilée dessous.
552
+ const boxY = new Array(count).fill(0);
553
+ for (const node of spine) {
554
+ boxY[node] = port[node] - sizes[node].height / 2;
555
+ let bottom = boxY[node] + sizes[node].height;
556
+ for (const member of hanging[node]) {
557
+ boxY[member] = bottom + HANG_GAP;
558
+ bottom = boxY[member] + sizes[member].height;
559
+ }
560
+ }
561
+ // Couloirs verticaux des liens coudés, par intervalle entre deux rangs : un couloir par élément
562
+ // de départ, qui descend ou monte vers chacune de ses cibles coudées.
563
+ const isElbow = (a, b) => Math.abs(port[a] - port[b]) >= STRAIGHT_TOLERANCE;
564
+ const channels = Array.from({ length: rankCount }, () => []);
565
+ const elbowTargets = new Map();
566
+ for (const chain of chains.values()) {
567
+ for (let i = 1; i < chain.length; i++) {
568
+ const [a, b] = [chain[i - 1], chain[i]];
569
+ if (!isElbow(a, b))
570
+ continue;
571
+ if (!channels[items[a].rank].includes(a))
572
+ channels[items[a].rank].push(a);
573
+ const targets = elbowTargets.get(a) ?? [];
574
+ if (!targets.includes(b))
575
+ targets.push(b);
576
+ elbowTargets.set(a, targets);
577
+ }
578
+ }
579
+ /**
580
+ * Croisements dans l'intervalle quand le couloir de `left` est à gauche de celui de `right` :
581
+ * les liens sortants de `left` qui coupent le couloir de `right` (hors cible commune), plus
582
+ * le lien entrant de `right` quand il coupe le couloir de `left`.
583
+ */
584
+ const spanOf = (a) => {
585
+ const ys = [port[a], ...elbowTargets.get(a).map((b) => port[b])];
586
+ return [Math.min(...ys), Math.max(...ys)];
587
+ };
588
+ const inside = (y, [low, high]) => y > low + STRAIGHT_TOLERANCE && y < high - STRAIGHT_TOLERANCE;
589
+ const channelCost = (left, right) => {
590
+ const rightTargets = elbowTargets.get(right);
591
+ const exits = elbowTargets.get(left).filter((b) => !rightTargets.includes(b) && inside(port[b], spanOf(right))).length;
592
+ return exits + (inside(port[right], spanOf(left)) ? 1 : 0);
593
+ };
594
+ // Insertion un à un, par ordonnée de départ, à la place qui coûte le moins de croisements ;
595
+ // à égalité, la place la plus à droite, ce qui garde l'ordre des ordonnées.
596
+ channels.forEach((list, r) => {
597
+ const ordered = [];
598
+ for (const item of stableSortBy(list, (entry) => port[entry])) {
599
+ let bestPosition = ordered.length;
600
+ let bestCost = Infinity;
601
+ for (let position = ordered.length; position >= 0; position--) {
602
+ const cost = ordered.reduce((sum, other, k) => sum + (k < position ? channelCost(other, item) : channelCost(item, other)), 0);
603
+ if (cost < bestCost) {
604
+ bestCost = cost;
605
+ bestPosition = position;
606
+ }
607
+ }
608
+ ordered.splice(bestPosition, 0, item);
609
+ }
610
+ channels[r] = ordered;
611
+ });
612
+ const channelFraction = (r, item) => (channels[r].indexOf(item) + 1) / (channels[r].length + 1);
613
+ // Largeur des intervalles : couloirs et libellés doivent y tenir.
614
+ const gapWidth = channels.map((list) => Math.max(RANK_GAP, (list.length + 1) * CHANNEL_SPACING));
615
+ for (const link of forward) {
616
+ if (!link.label)
617
+ continue;
618
+ const chain = chains.get(link.k);
619
+ const a = chain[chain.length - 2];
620
+ const r = items[a].rank;
621
+ const width = measure(link.label, LINK_LABEL_FONT);
622
+ const needed = isElbow(a, link.v)
623
+ ? (width + LABEL_OFFSET + LABEL_MARGIN) / (1 - channelFraction(r, a))
624
+ : width + 2 * LABEL_MARGIN;
625
+ gapWidth[r] = Math.max(gapWidth[r], needed);
626
+ }
627
+ // Tranches verticales occupées par des liens de part et d'autre de chaque rang :
628
+ // `occupied[b]` pour la frontière `b`, entre les rangs `b - 1` et `b`.
629
+ const occupied = Array.from({ length: rankCount + 1 }, () => []);
630
+ for (const chain of chains.values()) {
631
+ for (let i = 1; i < chain.length; i++) {
632
+ const [a, b] = [chain[i - 1], chain[i]];
633
+ occupied[items[a].rank + 1].push([Math.min(port[a], port[b]), Math.max(port[a], port[b])]);
634
+ }
635
+ }
636
+ links.forEach((link, k) => {
637
+ if (link.u === link.v)
638
+ occupied[rank[link.u] + 1].push([boxY[link.u] - LOOP_SIZE, port[link.u]]);
639
+ else if (back[k]) {
640
+ occupied[rank[link.u] + 1].push([port[link.u], Infinity]);
641
+ occupied[rank[link.v]].push([port[link.v], Infinity]);
642
+ }
643
+ });
644
+ const crossesLinks = (boundary, top, bottom) => occupied[boundary].some(([low, high]) => low - LINK_CLEARANCE < bottom && high + LINK_CLEARANCE > top);
645
+ const extents = diagram.nodes.map((_, node) => {
646
+ const half = sizes[node].width / 2;
647
+ if (!hung[node])
648
+ return { rank: rank[node], top: boxY[node], bottom: boxY[node] + sizes[node].height, left: half, right: half, hung: false };
649
+ const labels = links.filter((link) => link.v === node && link.label).map((link) => LABEL_OFFSET + measure(link.label, LINK_LABEL_FONT) + LABEL_MARGIN);
650
+ return {
651
+ rank: rank[node],
652
+ top: boxY[node] - HANG_GAP,
653
+ bottom: boxY[node] + sizes[node].height,
654
+ left: half,
655
+ right: Math.max(half, ...labels),
656
+ hung: true,
657
+ };
658
+ });
659
+ // Demi-largeurs des rangs : celle des nœuds du flux principal, élargie là où un nœud suspendu
660
+ // plus large croiserait un lien ; ailleurs, il déborde sur l'intervalle voisin.
661
+ const halfLeft = layers.map((layer) => Math.max(0, ...layer.map((item) => items[item].width / 2)));
662
+ const halfRight = [...halfLeft];
663
+ for (const extent of extents) {
664
+ if (!extent.hung)
665
+ continue;
666
+ if (crossesLinks(extent.rank, extent.top, extent.bottom))
667
+ halfLeft[extent.rank] = Math.max(halfLeft[extent.rank], extent.left);
668
+ if (crossesLinks(extent.rank + 1, extent.top, extent.bottom))
669
+ halfRight[extent.rank] = Math.max(halfRight[extent.rank], extent.right);
670
+ }
671
+ // Axe de chaque rang : après l'intervalle qui le sépare du précédent, et assez loin de tout nœud
672
+ // suspendu qui déborde à la même hauteur.
673
+ const byRank = Array.from({ length: rankCount }, () => []);
674
+ extents.forEach((extent) => byRank[extent.rank].push(extent));
675
+ const axis = [];
676
+ for (let r = 0; r < rankCount; r++) {
677
+ let x = r === 0 ? halfLeft[0] : axis[r - 1] + halfRight[r - 1] + gapWidth[r - 1] + halfLeft[r];
678
+ for (const current of byRank[r]) {
679
+ for (let earlier = 0; earlier < r; earlier++) {
680
+ for (const other of byRank[earlier]) {
681
+ if (!current.hung && !other.hung)
682
+ continue;
683
+ if (current.top >= other.bottom + LINK_CLEARANCE || other.top >= current.bottom + LINK_CLEARANCE)
684
+ continue;
685
+ x = Math.max(x, axis[earlier] + other.right + HANG_CLEARANCE + current.left);
686
+ }
687
+ }
688
+ }
689
+ axis.push(x);
690
+ }
691
+ const rankLeft = (r) => axis[r] - halfLeft[r];
692
+ const rankRight = (r) => axis[r] + halfRight[r];
693
+ const boxes = diagram.nodes.map((_, node) => ({
694
+ x: axis[rank[node]] - sizes[node].width / 2,
695
+ y: boxY[node],
696
+ width: sizes[node].width,
697
+ height: sizes[node].height,
698
+ }));
699
+ const bottom = Math.max(...boxes.map((box) => box.y + box.height));
700
+ let backIndex = 0;
701
+ const laidOut = links.map((link, k) => {
702
+ const from = diagram.nodes[link.u].id;
703
+ const to = diagram.nodes[link.v].id;
704
+ if (link.u === link.v)
705
+ return selfLoop(boxes[link.u], from);
706
+ if (back[k]) {
707
+ const start = rightMid(boxes[link.u]);
708
+ const end = leftMid(boxes[link.v]);
709
+ const outX = rankRight(rank[link.u]) + BACK_OFFSET;
710
+ const inX = rankLeft(rank[link.v]) - BACK_OFFSET;
711
+ const channelY = bottom + BACK_CHANNEL_GAP + BACK_CHANNEL_SPACING * backIndex++;
712
+ return {
713
+ from,
714
+ to,
715
+ points: [start, { x: outX, y: start.y }, { x: outX, y: channelY }, { x: inX, y: channelY }, { x: inX, y: end.y }, end],
716
+ labelPosition: { x: (outX + inX) / 2, y: channelY - LABEL_OFFSET },
717
+ labelAnchor: "middle",
718
+ back: true,
719
+ };
720
+ }
721
+ if (hung[link.v]) {
722
+ const x = axis[rank[link.v]];
723
+ const top = boxes[link.u].y + boxes[link.u].height;
724
+ const end = boxes[link.v].y;
725
+ return {
726
+ from,
727
+ to,
728
+ points: [
729
+ { x, y: top },
730
+ { x, y: end },
731
+ ],
732
+ labelPosition: { x: x + LABEL_OFFSET, y: (top + end) / 2 },
733
+ labelAnchor: "start",
734
+ back: false,
735
+ };
736
+ }
737
+ const chain = chains.get(k);
738
+ const points = [rightMid(boxes[link.u])];
739
+ let labelPosition = points[0];
740
+ let labelAnchor = "middle";
741
+ for (let i = 1; i < chain.length; i++) {
742
+ const a = chain[i - 1];
743
+ const b = chain[i];
744
+ const exit = points[points.length - 1];
745
+ const real = items[b].node !== null;
746
+ const entry = real ? leftMid(boxes[b]) : { x: rankLeft(items[b].rank), y: port[b] };
747
+ if (isElbow(a, b)) {
748
+ const r = items[a].rank;
749
+ const channelX = rankRight(r) + gapWidth[r] * channelFraction(r, a);
750
+ points.push({ x: channelX, y: exit.y }, { x: channelX, y: entry.y });
751
+ labelPosition = { x: channelX + LABEL_OFFSET, y: (exit.y + entry.y) / 2 };
752
+ labelAnchor = "start";
753
+ }
754
+ else {
755
+ labelPosition = { x: (exit.x + entry.x) / 2, y: entry.y - LABEL_OFFSET };
756
+ labelAnchor = "middle";
757
+ }
758
+ points.push(entry);
759
+ if (!real)
760
+ points.push({ x: rankRight(items[b].rank), y: port[b] });
761
+ }
762
+ return { from, to, points: simplify(points), labelPosition, labelAnchor, back: false };
763
+ });
764
+ return { boxes, links: laidOut, headers: [], center: null };
765
+ };
766
+ return build(chooseOrder(candidates, (order) => scoreLayout(build(order), links)));
767
+ }
768
+ // --- Couches (layers) --------------------------------------------------------
769
+ function layoutLayers(diagram, sizes, links, measure) {
770
+ if (diagram.layers.length === 0)
771
+ throw new Error(`Le schéma en couches « ${diagram.title} » ne déclare aucune couche.`);
772
+ const column = diagram.nodes.map((node) => {
773
+ if (node.layer === null) {
774
+ throw new Error(`Le nœud « ${node.id} » n'a pas de couche alors que le schéma « ${diagram.title} » est en couches.`);
775
+ }
776
+ const index = diagram.layers.indexOf(node.layer);
777
+ if (index === -1) {
778
+ throw new Error(`Le nœud « ${node.id} » est dans la couche « ${node.layer} », absente des couches déclarées (${diagram.layers.join(", ")}).`);
779
+ }
780
+ return index;
781
+ });
782
+ const columnCount = diagram.layers.length;
783
+ const columnWidth = diagram.layers.map((layer, c) => Math.max(LAYER_MIN_WIDTH, Math.ceil(measure(layer.toUpperCase(), HEADER_FONT)), ...sizes.filter((_, node) => column[node] === c).map((size) => size.width)));
784
+ // Les boîtes occupent toute la largeur de leur colonne ; pastilles et losanges gardent la leur.
785
+ const width = diagram.nodes.map((node, index) => (node.shape === "box" ? columnWidth[column[index]] : sizes[index].width));
786
+ const height = sizes.map((size) => size.height);
787
+ // Voisins : centre souhaité = centre du voisin, décalé d'un pas quand les deux nœuds partagent la colonne.
788
+ const neighbours = diagram.nodes.map(() => []);
789
+ for (const link of links) {
790
+ if (link.u === link.v)
791
+ continue;
792
+ const delta = column[link.u] === column[link.v] ? (height[link.u] + height[link.v]) / 2 + NODE_GAP : 0;
793
+ neighbours[link.u].push({ other: link.v, delta: -delta });
794
+ neighbours[link.v].push({ other: link.u, delta });
795
+ }
796
+ let columns = diagram.layers.map((_, c) => [...diagram.nodes.keys()].filter((node) => column[node] === c));
797
+ const gaps = (list) => list.slice(0, -1).map(() => NODE_GAP);
798
+ const center = new Array(diagram.nodes.length).fill(0);
799
+ const restack = (c) => stackCenters(columns[c].map((node) => height[node]), gaps(columns[c])).forEach((value, index) => (center[columns[c][index]] = value));
800
+ columns.forEach((_, c) => restack(c));
801
+ const desiredOf = (node) => {
802
+ const values = neighbours[node].map(({ other, delta }) => ({ value: center[other] + delta, weight: 1 }));
803
+ return { mean: weightedMean(values), weight: values.length };
804
+ };
805
+ // Ordres candidats dans chaque colonne : ordre de déclaration, puis l'ordre obtenu après chaque
806
+ // balayage barycentrique, alternativement de gauche à droite et de droite à gauche.
807
+ const candidates = [columns.map((list) => [...list])];
808
+ for (let sweep = 0; sweep < ORDER_SWEEPS; sweep++) {
809
+ const order = sweep % 2 === 0 ? [...columns.keys()] : [...columns.keys()].reverse();
810
+ for (const c of order) {
811
+ columns[c] = stableSortBy(columns[c], (node) => desiredOf(node).mean ?? center[node]);
812
+ restack(c);
813
+ }
814
+ candidates.push(columns.map((list) => [...list]));
815
+ }
816
+ /** Trace le schéma pour un ordre donné des nœuds de chaque colonne. */
817
+ const build = (orderOfColumns) => {
818
+ columns = orderOfColumns.map((list) => [...list]);
819
+ columns.forEach((_, c) => restack(c));
820
+ // Placement vertical au plus près des voisins, sans chevauchement.
821
+ for (let pass = 0; pass < PLACEMENT_PASSES; pass++) {
822
+ const order = pass % 2 === 0 ? [...columns.keys()] : [...columns.keys()].reverse();
823
+ for (const c of order) {
824
+ const list = columns[c];
825
+ const targets = list.map(desiredOf);
826
+ placeStack(list.map((node) => height[node]), gaps(list), targets.map((target, index) => target.mean ?? center[list[index]]), targets.map((target) => (target.mean === null ? FREE_WEIGHT : target.weight))).forEach((value, index) => (center[list[index]] = value));
827
+ }
828
+ }
829
+ const columnLeft = [];
830
+ let left = 0;
831
+ for (let c = 0; c < columnCount; c++) {
832
+ columnLeft.push(left);
833
+ left += columnWidth[c] + COLUMN_GAP;
834
+ }
835
+ const boxes = diagram.nodes.map((_, node) => ({
836
+ x: columnLeft[column[node]] + (columnWidth[column[node]] - width[node]) / 2,
837
+ y: center[node] - height[node] / 2,
838
+ width: width[node],
839
+ height: height[node],
840
+ }));
841
+ const top = Math.min(...boxes.map((box) => box.y));
842
+ const headers = diagram.layers.map((label, c) => ({ label, x: columnLeft[c], y: top - HEADER_OFFSET, width: columnWidth[c] }));
843
+ /** Ordonnée libre la plus proche de `y` dans la colonne `c`. */
844
+ const freeSlot = (c, y) => {
845
+ const list = columns[c].map((node) => boxes[node]);
846
+ const slots = [list[0].y - NODE_GAP / 2, ...list.slice(1).map((box, i) => (list[i].y + list[i].height + box.y) / 2)];
847
+ slots.push(list[list.length - 1].y + list[list.length - 1].height + NODE_GAP / 2);
848
+ return stableSortBy(slots, (slot) => Math.abs(slot - y))[0];
849
+ };
850
+ const laidOut = links.map((link) => {
851
+ const from = diagram.nodes[link.u].id;
852
+ const to = diagram.nodes[link.v].id;
853
+ if (link.u === link.v)
854
+ return selfLoop(boxes[link.u], from);
855
+ const a = boxes[link.u];
856
+ const b = boxes[link.v];
857
+ let points;
858
+ if (column[link.u] === column[link.v]) {
859
+ const c = column[link.u];
860
+ const [upper, lower] = a.y <= b.y ? [link.u, link.v] : [link.v, link.u];
861
+ const between = columns[c].indexOf(lower) - columns[c].indexOf(upper) > 1;
862
+ const up = boxes[upper];
863
+ const low = boxes[lower];
864
+ if (between) {
865
+ const sideX = columnLeft[c] + columnWidth[c] + BACK_OFFSET;
866
+ points = [rightMid(up), { x: sideX, y: up.y + up.height / 2 }, { x: sideX, y: low.y + low.height / 2 }, rightMid(low)];
867
+ }
868
+ else {
869
+ points = [
870
+ { x: up.x + up.width / 2, y: up.y + up.height },
871
+ { x: low.x + low.width / 2, y: low.y },
872
+ ];
873
+ }
874
+ if (upper !== link.u)
875
+ points.reverse();
876
+ }
877
+ else {
878
+ const [leftNode, rightNode] = column[link.u] < column[link.v] ? [link.u, link.v] : [link.v, link.u];
879
+ const end = leftMid(boxes[rightNode]);
880
+ points = [rightMid(boxes[leftNode])];
881
+ for (let c = column[leftNode] + 1; c < column[rightNode]; c++) {
882
+ const start = points[points.length - 1];
883
+ const x0 = columnLeft[c];
884
+ const x1 = columnLeft[c] + columnWidth[c];
885
+ const yAt = (x) => start.y + ((end.y - start.y) * (x - start.x)) / (end.x - start.x);
886
+ const low = Math.min(yAt(x0), yAt(x1));
887
+ const high = Math.max(yAt(x0), yAt(x1));
888
+ const blocked = columns[c].some((node) => boxes[node].y - CROSS_MARGIN < high && boxes[node].y + boxes[node].height + CROSS_MARGIN > low);
889
+ if (blocked) {
890
+ const slot = freeSlot(c, yAt((x0 + x1) / 2));
891
+ points.push({ x: x0, y: slot }, { x: x1, y: slot });
892
+ }
893
+ }
894
+ points.push(end);
895
+ if (leftNode !== link.u)
896
+ points.reverse();
897
+ }
898
+ const middle = middleOf(points);
899
+ return {
900
+ from,
901
+ to,
902
+ points,
903
+ labelPosition: { x: middle.x, y: middle.y - LABEL_OFFSET },
904
+ labelAnchor: "middle",
905
+ back: false,
906
+ };
907
+ });
908
+ return { boxes, links: laidOut, headers, center: null };
909
+ };
910
+ return build(chooseOrder(candidates, (order) => scoreLayout(build(order), links)));
911
+ }
912
+ // --- Carte mentale (mindmap) --------------------------------------------------
913
+ /** Point du bord d'un nœud sur la demi-droite qui part de son centre vers `target`. */
914
+ function clipToBorder(box, shape, target) {
915
+ const c = centerOf(box);
916
+ const dx = target.x - c.x;
917
+ const dy = target.y - c.y;
918
+ if (dx === 0 && dy === 0)
919
+ return c;
920
+ const halfW = box.width / 2;
921
+ const halfH = box.height / 2;
922
+ let t;
923
+ if (shape === "decision") {
924
+ t = 1 / (Math.abs(dx) / halfW + Math.abs(dy) / halfH);
925
+ }
926
+ else {
927
+ t = Math.min(dx === 0 ? Infinity : halfW / Math.abs(dx), dy === 0 ? Infinity : halfH / Math.abs(dy));
928
+ if (shape === "pill") {
929
+ // Extrémités arrondies : intersection avec le demi-cercle quand le point tombe au-delà de la partie droite.
930
+ const radius = halfH;
931
+ const straight = halfW - radius;
932
+ if (Math.abs(t * dx) > straight) {
933
+ const length = Math.hypot(dx, dy);
934
+ const ux = dx / length;
935
+ const cx = Math.sign(dx) * straight;
936
+ const projection = cx * ux;
937
+ const s = projection + Math.sqrt(Math.max(0, projection * projection - cx * cx + radius * radius));
938
+ t = s / length;
939
+ }
940
+ }
941
+ }
942
+ return { x: c.x + dx * t, y: c.y + dy * t };
943
+ }
944
+ function boxesOverlap(a, b, clearance) {
945
+ return (a.x < b.x + b.width + clearance &&
946
+ b.x < a.x + a.width + clearance &&
947
+ a.y < b.y + b.height + clearance &&
948
+ b.y < a.y + a.height + clearance);
949
+ }
950
+ /** Description française d'un problème d'arborescence. */
951
+ function mindmapProblemText(problem) {
952
+ const list = (ids) => ids.map((id) => `« ${id} »`).join(", ");
953
+ switch (problem.kind) {
954
+ case "self_loop":
955
+ return `le nœud « ${problem.node} » est relié à lui-même`;
956
+ case "no_root":
957
+ return "aucun nœud n'est sans lien entrant, il n'y a pas de racine";
958
+ case "several_roots":
959
+ return `plusieurs nœuds sans lien entrant (${list(problem.roots)}) au lieu d'une seule racine`;
960
+ case "several_parents":
961
+ return `le nœud « ${problem.node} » a plusieurs parents (${list(problem.parents)})`;
962
+ case "cycle":
963
+ return `cycle ${problem.nodes.join(" → ")}`;
964
+ case "unreachable":
965
+ return `${list(problem.nodes)} inaccessible(s) depuis la racine « ${problem.root} »`;
966
+ }
967
+ }
968
+ function layoutMindmap(diagram, sizes, links) {
969
+ const problems = mindmapProblems(diagram);
970
+ if (problems.length > 0) {
971
+ throw new Error(`La carte mentale « ${diagram.title} » n'est pas un arbre : ${problems.map(mindmapProblemText).join(" ; ")}.`);
972
+ }
973
+ const count = diagram.nodes.length;
974
+ const hasIncoming = new Array(count).fill(false);
975
+ const outgoing = Array.from({ length: count }, () => []);
976
+ for (const link of links) {
977
+ hasIncoming[link.v] = true;
978
+ outgoing[link.u].push(link.v);
979
+ }
980
+ const root = hasIncoming.indexOf(false);
981
+ // Arbre parcouru en largeur depuis la racine, enfants dans l'ordre des liens.
982
+ const children = outgoing;
983
+ const depth = new Array(count).fill(0);
984
+ const bfsOrder = [];
985
+ const queue = [root];
986
+ while (queue.length > 0) {
987
+ const node = queue.shift();
988
+ bfsOrder.push(node);
989
+ for (const next of children[node]) {
990
+ depth[next] = depth[node] + 1;
991
+ queue.push(next);
992
+ }
993
+ }
994
+ const subtree = new Array(count).fill(1);
995
+ for (const node of [...bfsOrder].reverse())
996
+ for (const child of children[node])
997
+ subtree[node] += subtree[child];
998
+ // Secteurs angulaires proportionnels à la taille des sous-arbres ; la première branche pointe vers la droite.
999
+ const angle = new Array(count).fill(0);
1000
+ const assign = (node, from, to) => {
1001
+ const total = children[node].reduce((sum, child) => sum + subtree[child], 0);
1002
+ let start = from;
1003
+ for (const child of children[node]) {
1004
+ const span = ((to - from) * subtree[child]) / total;
1005
+ angle[child] = start + span / 2;
1006
+ assign(child, start, start + span);
1007
+ start += span;
1008
+ }
1009
+ };
1010
+ if (children[root].length > 0) {
1011
+ const firstSpan = (2 * Math.PI * subtree[children[root][0]]) / (subtree[root] - 1);
1012
+ assign(root, -firstSpan / 2, 2 * Math.PI - firstSpan / 2);
1013
+ }
1014
+ // Distance au centre : celle du parent, plus la demi-traversée des deux nœuds le long du rayon et un écart ;
1015
+ // un nœud qui en chevauche un autre s'éloigne ensuite avec son sous-arbre, pas à pas.
1016
+ const exit = (node, theta) => Math.min(Math.abs(Math.cos(theta)) < 1e-9 ? Infinity : sizes[node].width / 2 / Math.abs(Math.cos(theta)), Math.abs(Math.sin(theta)) < 1e-9 ? Infinity : sizes[node].height / 2 / Math.abs(Math.sin(theta)));
1017
+ const distance = new Array(count).fill(0);
1018
+ for (const node of bfsOrder) {
1019
+ for (const child of children[node]) {
1020
+ distance[child] = distance[node] + exit(node, angle[child]) + exit(child, angle[child]) + RING_GAP;
1021
+ }
1022
+ }
1023
+ const bfsRank = new Array(count).fill(0);
1024
+ bfsOrder.forEach((node, index) => (bfsRank[node] = index));
1025
+ const pushOut = (node) => {
1026
+ distance[node] += RING_STEP;
1027
+ for (const child of children[node])
1028
+ pushOut(child);
1029
+ };
1030
+ const place = () => diagram.nodes.map((_, node) => {
1031
+ const cx = distance[node] * Math.cos(angle[node]);
1032
+ const cy = distance[node] * Math.sin(angle[node]);
1033
+ return { x: cx - sizes[node].width / 2, y: cy - sizes[node].height / 2, width: sizes[node].width, height: sizes[node].height };
1034
+ });
1035
+ let boxes = place();
1036
+ for (let iteration = 0;; iteration++) {
1037
+ let moved = null;
1038
+ for (let a = 0; a < count && moved === null; a++) {
1039
+ for (let b = a + 1; b < count; b++) {
1040
+ if (!boxesOverlap(boxes[a], boxes[b], MINDMAP_CLEARANCE))
1041
+ continue;
1042
+ moved = depth[a] > depth[b] || (depth[a] === depth[b] && bfsRank[a] > bfsRank[b]) ? a : b;
1043
+ break;
1044
+ }
1045
+ }
1046
+ if (moved === null)
1047
+ break;
1048
+ if (iteration >= MAX_RING_ITERATIONS) {
1049
+ throw new Error(`La carte mentale « ${diagram.title} » n'a pas pu être placée sans chevauchement.`);
1050
+ }
1051
+ pushOut(moved);
1052
+ boxes = place();
1053
+ }
1054
+ const laidOut = links.map((link) => {
1055
+ const from = diagram.nodes[link.u].id;
1056
+ const to = diagram.nodes[link.v].id;
1057
+ if (link.u === link.v)
1058
+ return selfLoop(boxes[link.u], from);
1059
+ const start = clipToBorder(boxes[link.u], diagram.nodes[link.u].shape, centerOf(boxes[link.v]));
1060
+ const end = clipToBorder(boxes[link.v], diagram.nodes[link.v].shape, centerOf(boxes[link.u]));
1061
+ return {
1062
+ from,
1063
+ to,
1064
+ points: [start, end],
1065
+ labelPosition: { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 - LABEL_OFFSET },
1066
+ labelAnchor: "middle",
1067
+ back: false,
1068
+ };
1069
+ });
1070
+ return { boxes, links: laidOut, headers: [], center: centerOf(boxes[root]) };
1071
+ }
1072
+ // --- Assemblage ----------------------------------------------------------------
1073
+ /** Libellés des liens parallèles décalés pour ne pas se superposer. */
1074
+ function separateParallelLabels(diagram, links) {
1075
+ const seen = new Map();
1076
+ links.forEach((link, index) => {
1077
+ if (!diagram.links[index].label)
1078
+ return;
1079
+ const key = `${link.from}\u0000${link.to}`;
1080
+ const rank = seen.get(key) ?? 0;
1081
+ seen.set(key, rank + 1);
1082
+ link.labelPosition = { x: link.labelPosition.x, y: link.labelPosition.y - rank * (LINK_LABEL_FONT.size + 4) };
1083
+ });
1084
+ }
1085
+ /** Translate le schéma pour que tout soit en coordonnées positives avec une marge, et calcule ses dimensions. */
1086
+ function finish(diagram, raw, measure) {
1087
+ separateParallelLabels(diagram, raw.links);
1088
+ const xs = [];
1089
+ const ys = [];
1090
+ for (const box of raw.boxes) {
1091
+ xs.push(box.x, box.x + box.width);
1092
+ ys.push(box.y, box.y + box.height);
1093
+ }
1094
+ raw.links.forEach((link, index) => {
1095
+ for (const point of link.points) {
1096
+ xs.push(point.x);
1097
+ ys.push(point.y);
1098
+ }
1099
+ const label = diagram.links[index].label;
1100
+ if (label) {
1101
+ const width = measure(label, LINK_LABEL_FONT);
1102
+ const half = LINK_LABEL_FONT.size * 0.7;
1103
+ const start = link.labelAnchor === "start" ? link.labelPosition.x : link.labelPosition.x - width / 2;
1104
+ xs.push(start, start + width);
1105
+ ys.push(link.labelPosition.y - half, link.labelPosition.y + half);
1106
+ }
1107
+ });
1108
+ for (const header of raw.headers) {
1109
+ xs.push(header.x, header.x + header.width);
1110
+ ys.push(header.y, header.y + HEADER_HEIGHT);
1111
+ }
1112
+ let minX = Math.min(...xs);
1113
+ let maxX = Math.max(...xs);
1114
+ let minY = Math.min(...ys);
1115
+ let maxY = Math.max(...ys);
1116
+ if (raw.center) {
1117
+ const halfW = Math.max(raw.center.x - minX, maxX - raw.center.x);
1118
+ const halfH = Math.max(raw.center.y - minY, maxY - raw.center.y);
1119
+ minX = raw.center.x - halfW;
1120
+ maxX = raw.center.x + halfW;
1121
+ minY = raw.center.y - halfH;
1122
+ maxY = raw.center.y + halfH;
1123
+ }
1124
+ const dx = LAYOUT_PADDING - minX;
1125
+ const dy = LAYOUT_PADDING - minY;
1126
+ const move = (point) => ({ x: point.x + dx, y: point.y + dy });
1127
+ return {
1128
+ nodes: raw.boxes.map((box, index) => ({
1129
+ id: diagram.nodes[index].id,
1130
+ x: box.x + dx,
1131
+ y: box.y + dy,
1132
+ width: box.width,
1133
+ height: box.height,
1134
+ })),
1135
+ links: raw.links.map((link) => ({ ...link, points: link.points.map(move), labelPosition: move(link.labelPosition) })),
1136
+ headers: raw.headers.map((header) => ({ ...header, x: header.x + dx, y: header.y + dy })),
1137
+ width: Math.ceil(maxX - minX + 2 * LAYOUT_PADDING),
1138
+ height: Math.ceil(maxY - minY + 2 * LAYOUT_PADDING),
1139
+ };
1140
+ }
1141
+ /**
1142
+ * Calcule le placement d'un schéma. `measure` donne la largeur d'un texte ;
1143
+ * par défaut, l'estimation déterministe `estimateTextWidth`.
1144
+ */
1145
+ export function layoutDiagram(diagram, measure = estimateTextWidth) {
1146
+ const { links } = indexLinks(diagram);
1147
+ const sizes = diagram.nodes.map((node) => nodeSize(node, measure));
1148
+ switch (diagram.kind) {
1149
+ case "flow":
1150
+ return finish(diagram, layoutFlow(diagram, sizes, links, measure), measure);
1151
+ case "layers":
1152
+ return finish(diagram, layoutLayers(diagram, sizes, links, measure), measure);
1153
+ case "mindmap":
1154
+ return finish(diagram, layoutMindmap(diagram, sizes, links), measure);
1155
+ }
1156
+ }