astro-archify 0.3.4

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 (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
@@ -0,0 +1,1334 @@
1
+ // Geometry helpers shared by all typed renderers. Every function here is
2
+ // pure; renderers own their layout tables and pass measured rects
3
+ // ({x, y, width, height, cx, cy}) in.
4
+
5
+ import { recordDiagnostic } from './diagnostics.mjs';
6
+
7
+ // In degraded mode (no ajv) a type-wrong top-level field reaches the renderer.
8
+ // Coerce non-arrays to [] so the module-level Maps build without throwing and
9
+ // the friendly validator checks (which run later) report the real problem.
10
+ export function asArray(value) {
11
+ return Array.isArray(value) ? value : [];
12
+ }
13
+
14
+ // A computed coordinate must be a finite number; NaN/undefined would silently
15
+ // write `<rect x="NaN">` into the output. Used by the validators as a backstop.
16
+ export function isFinitePoint(...coords) {
17
+ return coords.every((c) => Number.isFinite(c));
18
+ }
19
+
20
+ export function rectsOverlap(a, b, gap = 0) {
21
+ // Non-finite geometry means "unknown", not "overlapping". Every comparison
22
+ // below is false for NaN, so without this guard the negation reports a
23
+ // collision for every pair. Callers surface non-finite pos/size through their
24
+ // own diagnostic; reporting it again as an overlap buries that message under
25
+ // one bogus separation hint per pair.
26
+ if (!isFinitePoint(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height)) {
27
+ return false;
28
+ }
29
+ return !(
30
+ a.x + a.width + gap <= b.x ||
31
+ b.x + b.width + gap <= a.x ||
32
+ a.y + a.height + gap <= b.y ||
33
+ b.y + b.height + gap <= a.y
34
+ );
35
+ }
36
+
37
+ export function segmentIntersectsRect(segment, rect, gap = 0) {
38
+ const box = {
39
+ x1: rect.x - gap,
40
+ y1: rect.y - gap,
41
+ x2: rect.x + rect.width + gap,
42
+ y2: rect.y + rect.height + gap
43
+ };
44
+ const [a, b] = [segment.start, segment.end];
45
+ if (pointInBox(a, box) || pointInBox(b, box)) return true;
46
+ return (
47
+ segmentsIntersect(a, b, [box.x1, box.y1], [box.x2, box.y1]) ||
48
+ segmentsIntersect(a, b, [box.x2, box.y1], [box.x2, box.y2]) ||
49
+ segmentsIntersect(a, b, [box.x2, box.y2], [box.x1, box.y2]) ||
50
+ segmentsIntersect(a, b, [box.x1, box.y2], [box.x1, box.y1])
51
+ );
52
+ }
53
+
54
+ export function segmentRectClearance(segment, rect) {
55
+ if (!segment || !rect) return null;
56
+ const { start, end } = segment;
57
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
58
+ if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
59
+ if (rect.width < 0 || rect.height < 0) return null;
60
+ if (segmentIntersectsRect(segment, rect)) return 0;
61
+
62
+ const corners = [
63
+ [rect.x, rect.y],
64
+ [rect.x + rect.width, rect.y],
65
+ [rect.x + rect.width, rect.y + rect.height],
66
+ [rect.x, rect.y + rect.height],
67
+ ];
68
+ return Math.min(
69
+ pointRectDistance(start, rect),
70
+ pointRectDistance(end, rect),
71
+ ...corners.map((corner) => pointSegmentDistance(corner, start, end)),
72
+ );
73
+ }
74
+
75
+ export function segmentRectIntersectionLength(segment, rect) {
76
+ if (!segment || !rect) return null;
77
+ const { start, end } = segment;
78
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
79
+ if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
80
+ if (rect.width < 0 || rect.height < 0) return null;
81
+
82
+ const dx = end[0] - start[0];
83
+ const dy = end[1] - start[1];
84
+ const length = Math.hypot(dx, dy);
85
+ if (length <= 0.0000001) return 0;
86
+ const bounds = [
87
+ [-dx, start[0] - rect.x],
88
+ [dx, rect.x + rect.width - start[0]],
89
+ [-dy, start[1] - rect.y],
90
+ [dy, rect.y + rect.height - start[1]],
91
+ ];
92
+ let enter = 0;
93
+ let leave = 1;
94
+ for (const [direction, distance] of bounds) {
95
+ if (Math.abs(direction) <= 0.0000001) {
96
+ if (distance < -0.0000001) return 0;
97
+ continue;
98
+ }
99
+ const ratio = distance / direction;
100
+ if (direction < 0) enter = Math.max(enter, ratio);
101
+ else leave = Math.min(leave, ratio);
102
+ if (enter > leave + 0.0000001) return 0;
103
+ }
104
+ return length * Math.max(0, leave - enter);
105
+ }
106
+
107
+ export function collectLabelRouteClearance({ labels, routedRelations, threshold }) {
108
+ if (!Number.isFinite(threshold) || threshold < 0) return [];
109
+ const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => {
110
+ const relation = entry?.relation || entry;
111
+ const points = normalizeRoutePoints(entry?.points || relation?.routePoints);
112
+ if (!relation || points.length < 2) return null;
113
+ return {
114
+ relation,
115
+ relationIndex: Number.isInteger(entry?.relationIndex) ? entry.relationIndex : fallbackIndex,
116
+ points,
117
+ };
118
+ }).filter(Boolean);
119
+ const seenRoutes = new Set();
120
+ const routes = routeCandidates.filter((route) => {
121
+ const identity = relationshipIdentity(route.relation, route.relationIndex);
122
+ if (seenRoutes.has(identity)) return false;
123
+ seenRoutes.add(identity);
124
+ return true;
125
+ });
126
+ const hits = [];
127
+ const seenLabels = new Set();
128
+
129
+ for (const [fallbackIndex, label] of asArray(labels).entries()) {
130
+ const rect = label?.rect || label;
131
+ if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height) || rect.width < 0 || rect.height < 0) continue;
132
+ const relationIndex = Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex;
133
+ const labelIdentity = relationshipIdentity(label?.relation, relationIndex);
134
+ if (seenLabels.has(labelIdentity)) continue;
135
+ seenLabels.add(labelIdentity);
136
+ for (const route of routes) {
137
+ if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue;
138
+ let nearest = null;
139
+ for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) {
140
+ const start = route.points[segmentIndex];
141
+ const end = route.points[segmentIndex + 1];
142
+ const clearance = segmentRectClearance({ start, end }, rect);
143
+ if (clearance == null) continue;
144
+ if (!nearest || clearance < nearest.clearance) {
145
+ nearest = {
146
+ clearance,
147
+ intersectionLength: segmentRectIntersectionLength({ start, end }, rect),
148
+ segmentIndex,
149
+ start,
150
+ end,
151
+ };
152
+ }
153
+ }
154
+ if (!nearest || nearest.clearance + 0.0001 >= threshold) continue;
155
+ hits.push({
156
+ label,
157
+ labelRelation: label?.relation,
158
+ labelRelationIndex: relationIndex,
159
+ otherRelation: route.relation,
160
+ otherRelationIndex: route.relationIndex,
161
+ rect,
162
+ ...nearest,
163
+ threshold,
164
+ });
165
+ }
166
+ }
167
+ return hits;
168
+ }
169
+
170
+ function relationshipIdentity(relation, relationIndex) {
171
+ if (relation?.key !== undefined) return `key:${relation.key}`;
172
+ if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`;
173
+ return `index:${relationIndex}`;
174
+ }
175
+
176
+ function sameRelationship(left, right) {
177
+ if (!left || !right) return false;
178
+ if (left === right) return true;
179
+ if (left.key !== undefined && right.key !== undefined) return left.key === right.key;
180
+ return Boolean(left.id && right.id && left.id === right.id && left.from === right.from && left.to === right.to);
181
+ }
182
+
183
+ function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
184
+ return {
185
+ diagramType,
186
+ collection: relationCollection,
187
+ index: relationIndex,
188
+ ...(relation?.id ? { id: relation.id } : {}),
189
+ ...(relation?.from ? { from: relation.from } : {}),
190
+ ...(relation?.to ? { to: relation.to } : {}),
191
+ };
192
+ }
193
+
194
+ const ENDPOINT_SIDE_RULES = {
195
+ left: {
196
+ axis: 'horizontal',
197
+ sourceSign: -1,
198
+ targetSign: 1,
199
+ sourceDirection: 'leftward',
200
+ targetDirection: 'rightward from the left',
201
+ },
202
+ right: {
203
+ axis: 'horizontal',
204
+ sourceSign: 1,
205
+ targetSign: -1,
206
+ sourceDirection: 'rightward',
207
+ targetDirection: 'leftward from the right',
208
+ },
209
+ top: {
210
+ axis: 'vertical',
211
+ sourceSign: -1,
212
+ targetSign: 1,
213
+ sourceDirection: 'upward',
214
+ targetDirection: 'downward from above',
215
+ },
216
+ bottom: {
217
+ axis: 'vertical',
218
+ sourceSign: 1,
219
+ targetSign: -1,
220
+ sourceDirection: 'downward',
221
+ targetDirection: 'upward from below',
222
+ },
223
+ };
224
+
225
+ function endpointSideIssue(points, endpoint, side) {
226
+ const rule = ENDPOINT_SIDE_RULES[side];
227
+ if (!rule) return null;
228
+ const normalized = normalizeRoutePoints(points);
229
+ if (normalized.length < 2) return null;
230
+ const segmentIndex = endpoint === 'source' ? 0 : normalized.length - 2;
231
+ const start = normalized[segmentIndex];
232
+ const end = normalized[segmentIndex + 1];
233
+ const dx = end[0] - start[0];
234
+ const dy = end[1] - start[1];
235
+ const along = rule.axis === 'horizontal' ? dx : dy;
236
+ const across = rule.axis === 'horizontal' ? dy : dx;
237
+ const expectedSign = endpoint === 'source' ? rule.sourceSign : rule.targetSign;
238
+ if (Math.abs(across) <= 0.0001 && along * expectedSign > 0.0001) return null;
239
+ return {
240
+ endpoint,
241
+ side,
242
+ segmentIndex,
243
+ start,
244
+ end,
245
+ expectedAxis: rule.axis,
246
+ expectedDirection: endpoint === 'source' ? rule.sourceDirection : rule.targetDirection,
247
+ };
248
+ }
249
+
250
+ // A side is a direction contract, not just a point on a box border. This pure
251
+ // predicate lets automatic routers prefer a dogleg whose first and final
252
+ // segments leave/enter the chosen sides perpendicularly.
253
+ export function routeHonorsEndpointSides(points, fromSide, toSide) {
254
+ return !endpointSideIssue(points, 'source', fromSide)
255
+ && !endpointSideIssue(points, 'target', toSide);
256
+ }
257
+
258
+ // Explicit fromSide/toSide are authored geometry, so a tangent or backwards
259
+ // endpoint segment changes their meaning. Fail this universally instead of
260
+ // leaving a malformed arrow for visual review to discover. Named routes and
261
+ // authored via points already carry their own geometry semantics: when they
262
+ // omit endpoint sides, do not invent a relative-position side and then reject
263
+ // the route for disagreeing with that invention. Pure automatic routes may
264
+ // still be checked against renderer-inferred sides.
265
+ export function cleanEndpointSideProblems({
266
+ relations,
267
+ endpointIds,
268
+ pathFor,
269
+ diagramType,
270
+ relationCollection,
271
+ fromSideFor,
272
+ toSideFor,
273
+ shouldCheckRelation = () => true,
274
+ routeHint = 'align the first/final via segment with fromSide/toSide, change the side, or remove explicit routing so auto can choose a perpendicular approach',
275
+ }) {
276
+ const problems = [];
277
+ for (const [relationIndex, relation] of asArray(relations).entries()) {
278
+ if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
279
+ if (!shouldCheckRelation(relation, relationIndex)) continue;
280
+ const points = pathFor(relation)?.points;
281
+ if (!Array.isArray(points) || points.length < 2) continue;
282
+ const authoredFromSide = relation.fromSide && relation.fromSide !== 'auto' ? relation.fromSide : null;
283
+ const authoredToSide = relation.toSide && relation.toSide !== 'auto' ? relation.toSide : null;
284
+ const hasAuthoredRouteGeometry = Boolean(
285
+ (relation.route && relation.route !== 'auto') || Array.isArray(relation.via),
286
+ );
287
+ const inferredFromSide = !hasAuthoredRouteGeometry && typeof fromSideFor === 'function'
288
+ ? fromSideFor(relation)
289
+ : null;
290
+ const inferredToSide = !hasAuthoredRouteGeometry && typeof toSideFor === 'function'
291
+ ? toSideFor(relation)
292
+ : null;
293
+ const fromSide = authoredFromSide ?? inferredFromSide;
294
+ const toSide = authoredToSide ?? inferredToSide;
295
+ const checks = [
296
+ fromSide
297
+ ? { ...endpointSideIssue(points, 'source', fromSide), sideOrigin: authoredFromSide ? 'authored' : 'inferred' }
298
+ : null,
299
+ toSide
300
+ ? { ...endpointSideIssue(points, 'target', toSide), sideOrigin: authoredToSide ? 'authored' : 'inferred' }
301
+ : null,
302
+ ].filter((issue) => issue?.endpoint);
303
+ for (const issue of checks) {
304
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
305
+ const authoredField = issue.endpoint === 'source' ? 'fromSide' : 'toSide';
306
+ const sideField = issue.sideOrigin === 'inferred' ? `inferred ${authoredField}` : authoredField;
307
+ const segmentRole = issue.endpoint === 'source' ? 'first' : 'final';
308
+ const from = issue.start.map((value) => Math.round(value * 10) / 10).join(', ');
309
+ const to = issue.end.map((value) => Math.round(value * 10) / 10).join(', ');
310
+ const message = `[clean-flow/endpoint-side-direction] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" ${segmentRole} segment ${issue.segmentIndex} [${from}] -> [${to}] does not honor ${sideField} "${issue.side}" — it must run ${issue.expectedAxis} ${issue.expectedDirection}; ${routeHint}.`;
311
+ recordDiagnostic({
312
+ code: 'clean-flow/endpoint-side-direction',
313
+ severity: 'error',
314
+ message,
315
+ subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
316
+ evidence: {
317
+ endpoint: issue.endpoint,
318
+ authoredField,
319
+ sideOrigin: issue.sideOrigin,
320
+ side: issue.side,
321
+ segmentIndex: issue.segmentIndex,
322
+ from: issue.start,
323
+ to: issue.end,
324
+ expectedAxis: issue.expectedAxis,
325
+ expectedDirection: issue.expectedDirection,
326
+ },
327
+ supportedFixes: [routeHint],
328
+ });
329
+ problems.push(message);
330
+ }
331
+ }
332
+ return problems;
333
+ }
334
+
335
+ // One mechanical quality gate for every renderer-owned relationship path.
336
+ // A renderer supplies its semantic obstacle set; source/target boxes are
337
+ // always exempt because paths are expected to terminate on their boundaries.
338
+ // Containers, lifelines, and other intentionally pass-through geometry should
339
+ // simply not be supplied as obstacles.
340
+ export function cleanFlowProblems({
341
+ relations,
342
+ obstacles,
343
+ pathFor,
344
+ diagramType,
345
+ relationCollection,
346
+ obstacleKind,
347
+ profile,
348
+ clearance = 2,
349
+ routeHint = 'adjust fromSide/toSide, set route/via or channel coordinates, or move the obstacle'
350
+ }) {
351
+ // A relationship hidden behind an unrelated opaque node changes the
352
+ // diagram's meaning, so this is a correctness invariant rather than an
353
+ // opt-in composition preference. Keep it active even when the author omits
354
+ // quality_profile; standard/showcase still control stricter visual budgets.
355
+ const problems = [];
356
+ const obstacleList = [...obstacles];
357
+ const obstacleIds = new Set(obstacleList.map((obstacle) => obstacle?.id));
358
+ for (const [relationIndex, relation] of asArray(relations).entries()) {
359
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') continue;
360
+ if (!obstacleIds.has(relation.from) || !obstacleIds.has(relation.to)) continue;
361
+ const points = pathFor(relation)?.points;
362
+ if (!Array.isArray(points) || points.length < 2) continue;
363
+ if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) continue;
364
+
365
+ const endpointIds = new Set([relation.from, relation.to]);
366
+ for (const obstacle of obstacleList) {
367
+ if (!obstacle || endpointIds.has(obstacle.id)) continue;
368
+ if (!isFinitePoint(obstacle.x, obstacle.y, obstacle.width, obstacle.height)) continue;
369
+ let hitSegment = -1;
370
+ for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
371
+ if (segmentIntersectsRect({ start: points[segmentIndex], end: points[segmentIndex + 1] }, obstacle, clearance)) {
372
+ hitSegment = segmentIndex;
373
+ break;
374
+ }
375
+ }
376
+ if (hitSegment === -1) continue;
377
+ const from = points[hitSegment].map(Math.round).join(', ');
378
+ const to = points[hitSegment + 1].map(Math.round).join(', ');
379
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
380
+ const message = `[clean-flow/edge-through-node] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" crosses ${obstacleKind} "${obstacle.id}" (unrelated to this relationship) on segment ${hitSegment} [${from}] -> [${to}] (${clearance}px clearance) — ${routeHint}.`;
381
+ recordDiagnostic({
382
+ code: 'clean-flow/edge-through-node',
383
+ severity: 'error',
384
+ message,
385
+ subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
386
+ evidence: {
387
+ obstacleKind,
388
+ obstacleId: obstacle.id,
389
+ segmentIndex: hitSegment,
390
+ from: points[hitSegment],
391
+ to: points[hitSegment + 1],
392
+ clearancePx: clearance,
393
+ },
394
+ supportedFixes: [routeHint],
395
+ });
396
+ problems.push(message);
397
+ }
398
+ }
399
+ return problems;
400
+ }
401
+
402
+ // Reject only a proper interior X between relationships that share no semantic
403
+ // endpoint. Endpoint touches, branch/merge ports, and collinear shared
404
+ // corridors are intentionally outside this contract because geometry alone
405
+ // cannot tell whether those are authored junctions.
406
+ export function cleanCrossingProblems({
407
+ relations,
408
+ endpointIds,
409
+ pathFor,
410
+ diagramType,
411
+ relationCollection,
412
+ profile = 'standard',
413
+ routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
414
+ }) {
415
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
416
+ const activeProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
417
+ if (activeProfile !== 'showcase') return [];
418
+ const routed = asArray(relations).map((relation, index) => {
419
+ if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
420
+ const points = pathFor(relation)?.points;
421
+ if (!Array.isArray(points) || points.length < 2) return null;
422
+ if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) return null;
423
+ return { relation, index, points };
424
+ }).filter(Boolean);
425
+ const problems = [];
426
+
427
+ for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
428
+ const left = routed[leftIndex];
429
+ for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
430
+ const right = routed[rightIndex];
431
+ if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
432
+
433
+ let hit = null;
434
+ for (let leftSegment = 0; leftSegment < left.points.length - 1 && !hit; leftSegment += 1) {
435
+ for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
436
+ const point = properSegmentIntersection(
437
+ left.points[leftSegment],
438
+ left.points[leftSegment + 1],
439
+ right.points[rightSegment],
440
+ right.points[rightSegment + 1]
441
+ );
442
+ if (point) {
443
+ hit = { point, leftSegment, rightSegment };
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ if (!hit) continue;
449
+
450
+ const describe = ({ relation, index }) => {
451
+ const id = relation.id ? ` id "${relation.id}"` : '';
452
+ return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`;
453
+ };
454
+ const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', ');
455
+ const message = `[composition/proper-crossing] showcase ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${routeHint}.`;
456
+ recordDiagnostic({
457
+ code: 'composition/proper-crossing',
458
+ severity: 'error',
459
+ message,
460
+ subject: relationshipSubject(diagramType, relationCollection, left.index, left.relation),
461
+ evidence: {
462
+ otherRelationship: relationshipSubject(diagramType, relationCollection, right.index, right.relation),
463
+ point: hit.point,
464
+ segmentIndex: hit.leftSegment,
465
+ otherSegmentIndex: hit.rightSegment,
466
+ },
467
+ supportedFixes: [routeHint],
468
+ });
469
+ problems.push(message);
470
+ }
471
+ }
472
+ return problems;
473
+ }
474
+
475
+ // Two unrelated relationships that occupy the same visible corridor can read
476
+ // as one authored branch or merge even when neither relationship crosses a
477
+ // node or forms a proper X. Keep shared semantic endpoints exempt: their
478
+ // initial/final fan-out is real topology. Tiny overlaps below the route rhythm
479
+ // floor are ignored to avoid turning sub-pixel rounding into a quality debt.
480
+ export function collectAmbiguousCorridors({
481
+ routedRelations,
482
+ minOverlapPx = 8,
483
+ }) {
484
+ const routed = asArray(routedRelations).map((entry, fallbackIndex) => {
485
+ const relation = entry?.relation;
486
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
487
+ const points = normalizeRoutePoints(entry?.points);
488
+ if (points.length < 2) return null;
489
+ return {
490
+ relation,
491
+ relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex,
492
+ points,
493
+ };
494
+ }).filter(Boolean);
495
+ const hits = [];
496
+
497
+ for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
498
+ const left = routed[leftIndex];
499
+ for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
500
+ const right = routed[rightIndex];
501
+ if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
502
+
503
+ let longest = null;
504
+ for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) {
505
+ for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
506
+ const overlap = collinearAxisOverlap(
507
+ left.points[leftSegment],
508
+ left.points[leftSegment + 1],
509
+ right.points[rightSegment],
510
+ right.points[rightSegment + 1],
511
+ );
512
+ if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue;
513
+ if (!longest || overlap.length > longest.overlapLength + 0.0001) {
514
+ longest = {
515
+ left,
516
+ right,
517
+ leftSegment,
518
+ rightSegment,
519
+ overlapLength: overlap.length,
520
+ overlapStart: overlap.start,
521
+ overlapEnd: overlap.end,
522
+ };
523
+ }
524
+ }
525
+ }
526
+ if (longest) hits.push(longest);
527
+ }
528
+ }
529
+ return hits;
530
+ }
531
+
532
+ export function cleanAmbiguousCorridorProblems({
533
+ relations,
534
+ endpointIds,
535
+ pathFor,
536
+ diagramType,
537
+ relationCollection,
538
+ profile = 'standard',
539
+ routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
540
+ minOverlapPx = 8,
541
+ }) {
542
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
543
+ if (requestedProfile !== 'showcase') return [];
544
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
545
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
546
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
547
+ return { relation, relationIndex, points: pathFor(relation)?.points };
548
+ }).filter(Boolean);
549
+
550
+ return collectAmbiguousCorridors({ routedRelations, minOverlapPx }).map((hit) => {
551
+ const describe = ({ relation, relationIndex }) => {
552
+ const id = relation.id ? ` id "${relation.id}"` : '';
553
+ return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`;
554
+ };
555
+ const length = Math.round(hit.overlapLength * 10) / 10;
556
+ const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
557
+ const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
558
+ const message = `[composition/ambiguous-corridor] showcase ${diagramType} ${describe(hit.left)} shares a ${length}px corridor with ${describe(hit.right)} at [${from}] -> [${to}] (segments ${hit.leftSegment} and ${hit.rightSegment}; minimum ${minOverlapPx}px) — ${routeHint}.`;
559
+ recordDiagnostic({
560
+ code: 'composition/ambiguous-corridor',
561
+ severity: 'error',
562
+ message,
563
+ subject: relationshipSubject(diagramType, relationCollection, hit.left.relationIndex, hit.left.relation),
564
+ evidence: {
565
+ otherRelationship: relationshipSubject(diagramType, relationCollection, hit.right.relationIndex, hit.right.relation),
566
+ overlapLengthPx: length,
567
+ minimumPx: minOverlapPx,
568
+ from: hit.overlapStart,
569
+ to: hit.overlapEnd,
570
+ segmentIndex: hit.leftSegment,
571
+ otherSegmentIndex: hit.rightSegment,
572
+ },
573
+ supportedFixes: [routeHint],
574
+ });
575
+ return message;
576
+ });
577
+ }
578
+
579
+ // Relationship paths may cross a structural frame, but they must not borrow a
580
+ // frame side as a routing corridor. Rounded rectangle corners are trimmed from
581
+ // the modeled straight sides so a short corner touch is not mistaken for a
582
+ // border run. Any positive straight overlap beyond the numeric epsilon is a
583
+ // hard failure in every quality profile; 16px belongs only to the separate,
584
+ // neutral short-segment metric and is not a corridor exemption.
585
+ export function collectBorderRuns({ routedRelations, frames }) {
586
+ const hits = [];
587
+ for (const routed of asArray(routedRelations)) {
588
+ const routeSegments = Array.isArray(routed?.segments)
589
+ ? routed.segments
590
+ : asArray(routed?.points).slice(0, -1).map((start, index) => ({ start, end: routed.points[index + 1] }));
591
+ if (!routeSegments.length) continue;
592
+ if (!routeSegments.every((segment) => (
593
+ Array.isArray(segment?.start) && segment.start.length === 2 && isFinitePoint(...segment.start)
594
+ && Array.isArray(segment?.end) && segment.end.length === 2 && isFinitePoint(...segment.end)
595
+ ))) continue;
596
+ for (const [frameIndex, frame] of asArray(frames).entries()) {
597
+ for (const border of frameBorderSegments(frame)) {
598
+ const overlaps = [];
599
+ for (let segmentIndex = 0; segmentIndex < routeSegments.length; segmentIndex += 1) {
600
+ const segment = routeSegments[segmentIndex];
601
+ const overlap = collinearAxisOverlap(
602
+ segment.start,
603
+ segment.end,
604
+ border.start,
605
+ border.end,
606
+ );
607
+ if (!overlap || overlap.length <= 0.0001) continue;
608
+ overlaps.push({ ...overlap, segmentIndex });
609
+ }
610
+ if (!overlaps.length) continue;
611
+ const merged = mergeBorderOverlaps(overlaps, border);
612
+ const longest = [...merged].sort((left, right) => right.length - left.length || left.low - right.low)[0];
613
+ hits.push({
614
+ ...routed,
615
+ frame,
616
+ frameIndex,
617
+ side: border.side,
618
+ segmentIndex: Math.min(...overlaps.map((overlap) => overlap.segmentIndex)),
619
+ overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0),
620
+ overlapStart: longest.start,
621
+ overlapEnd: longest.end,
622
+ });
623
+ }
624
+ }
625
+ }
626
+ return hits;
627
+ }
628
+
629
+ export function cleanBorderRunProblems({
630
+ relations,
631
+ endpointIds,
632
+ frames,
633
+ pathFor,
634
+ diagramType,
635
+ relationCollection,
636
+ profile,
637
+ routeHint = 'adjust route/via or channel coordinates so the relationship crosses the frame perpendicularly through a clear opening'
638
+ }) {
639
+ if (!process.env.ARCHIFY_QUALITY_PROFILE && !profile) return [];
640
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
641
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
642
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
643
+ return { relation, relationIndex, points: pathFor(relation)?.points };
644
+ }).filter(Boolean);
645
+ return collectBorderRuns({ routedRelations, frames }).map((hit) => {
646
+ const relation = hit.relation || {};
647
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
648
+ const frameKind = hit.frame?.kind || hit.frame?.shape || 'frame';
649
+ const frameIdentity = hit.frame?.label || hit.frame?.id || hit.frameIndex;
650
+ const length = Math.round(hit.overlapLength * 10) / 10;
651
+ const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
652
+ const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
653
+ const message = `[composition/container-border-run] ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" follows ${frameKind} "${frameIdentity}" ${hit.side} border for ${length}px on segment ${hit.segmentIndex} [${from}] -> [${to}] — ${routeHint}.`;
654
+ recordDiagnostic({
655
+ code: 'composition/container-border-run',
656
+ severity: 'error',
657
+ message,
658
+ subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
659
+ evidence: {
660
+ frameKind,
661
+ frameId: hit.frame?.id,
662
+ frameLabel: hit.frame?.label,
663
+ side: hit.side,
664
+ segmentIndex: hit.segmentIndex,
665
+ overlapLengthPx: length,
666
+ from: hit.overlapStart,
667
+ to: hit.overlapEnd,
668
+ },
669
+ supportedFixes: [routeHint],
670
+ });
671
+ return message;
672
+ });
673
+ }
674
+
675
+ export function routeBudgetMetrics({
676
+ routedRelations,
677
+ bendsPerRelationship = 2,
678
+ stretch = 1.35,
679
+ segmentPx = 16,
680
+ microSegmentPx = 8,
681
+ }) {
682
+ let maxBends = 0;
683
+ let routesOverSuggestedBends = 0;
684
+ let maxStretch = null;
685
+ let routesOverSuggestedStretch = 0;
686
+ let minSegmentPx = null;
687
+ let minInteriorSegmentPx = null;
688
+ let shortSegmentCount = 0;
689
+ let shortEndpointSegmentCount = 0;
690
+ let shortInteriorSegmentCount = 0;
691
+ let microSegmentCount = 0;
692
+
693
+ for (const routed of asArray(routedRelations)) {
694
+ const points = normalizeRoutePoints(routed?.points);
695
+ if (points.length < 2) continue;
696
+ const bends = Math.max(0, points.length - 2);
697
+ maxBends = Math.max(maxBends, bends);
698
+ if (bends > bendsPerRelationship) routesOverSuggestedBends += 1;
699
+
700
+ let routeLength = 0;
701
+ for (let index = 0; index < points.length - 1; index += 1) {
702
+ const length = Math.abs(points[index + 1][0] - points[index][0]) + Math.abs(points[index + 1][1] - points[index][1]);
703
+ if (length <= 0.0001) continue;
704
+ const position = segmentPosition(index, points.length - 1);
705
+ routeLength += length;
706
+ minSegmentPx = minSegmentPx == null ? length : Math.min(minSegmentPx, length);
707
+ if (position === 'interior') {
708
+ minInteriorSegmentPx = minInteriorSegmentPx == null ? length : Math.min(minInteriorSegmentPx, length);
709
+ }
710
+ if (length < segmentPx) {
711
+ shortSegmentCount += 1;
712
+ if (position === 'interior') shortInteriorSegmentCount += 1;
713
+ else shortEndpointSegmentCount += 1;
714
+ }
715
+ if (length < microSegmentPx) microSegmentCount += 1;
716
+ }
717
+ const direct = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]);
718
+ if (direct > 0.0001) {
719
+ const routeStretch = routeLength / direct;
720
+ maxStretch = maxStretch == null ? routeStretch : Math.max(maxStretch, routeStretch);
721
+ if (routeStretch > stretch + 0.0001) routesOverSuggestedStretch += 1;
722
+ }
723
+ }
724
+
725
+ return {
726
+ maxBends,
727
+ routesOverSuggestedBends,
728
+ maxStretch,
729
+ routesOverSuggestedStretch,
730
+ minSegmentPx,
731
+ minInteriorSegmentPx,
732
+ shortSegmentCount,
733
+ shortEndpointSegmentCount,
734
+ shortInteriorSegmentCount,
735
+ microSegmentCount,
736
+ };
737
+ }
738
+
739
+ export function collectRouteRhythmIssues({
740
+ routedRelations,
741
+ interiorSegmentPx = 16,
742
+ microSegmentPx = 8,
743
+ }) {
744
+ const issues = [];
745
+ for (const [fallbackIndex, routed] of asArray(routedRelations).entries()) {
746
+ const points = normalizeRoutePoints(routed?.points);
747
+ if (points.length < 2) continue;
748
+ for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
749
+ const start = points[segmentIndex];
750
+ const end = points[segmentIndex + 1];
751
+ const length = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
752
+ if (length <= 0.0001) continue;
753
+ const position = segmentPosition(segmentIndex, points.length - 1);
754
+ const code = length < microSegmentPx - 0.0001
755
+ ? 'composition/micro-segment'
756
+ : position === 'interior' && length < interiorSegmentPx - 0.0001
757
+ ? 'composition/short-interior-segment'
758
+ : null;
759
+ if (!code) continue;
760
+ issues.push({
761
+ code,
762
+ relation: routed.relation,
763
+ relationIndex: Number.isInteger(routed.relationIndex) ? routed.relationIndex : fallbackIndex,
764
+ segmentIndex,
765
+ position,
766
+ length,
767
+ start,
768
+ end,
769
+ });
770
+ }
771
+ }
772
+ return issues;
773
+ }
774
+
775
+ export function cleanRouteRhythmProblems({
776
+ relations,
777
+ endpointIds,
778
+ pathFor,
779
+ diagramType,
780
+ relationCollection,
781
+ profile,
782
+ routeHint = 'move the channel/via point to remove the cramped turn or give the route more corridor space',
783
+ interiorSegmentPx = 16,
784
+ microSegmentPx = 8,
785
+ }) {
786
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
787
+ if (requestedProfile !== 'showcase') return [];
788
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
789
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
790
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
791
+ return { relation, relationIndex, points: pathFor(relation)?.points };
792
+ }).filter(Boolean);
793
+ return collectRouteRhythmIssues({ routedRelations, interiorSegmentPx, microSegmentPx }).map((hit) => {
794
+ const relation = hit.relation || {};
795
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
796
+ const length = Math.round(hit.length * 10) / 10;
797
+ const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
798
+ const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
799
+ const rule = hit.code === 'composition/micro-segment'
800
+ ? `is below the ${microSegmentPx}px micro-segment floor`
801
+ : `is below the ${interiorSegmentPx}px interior-segment floor`;
802
+ const message = `[${hit.code}] showcase ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" has a ${length}px ${hit.position} segment ${hit.segmentIndex} [${from}] -> [${to}] that ${rule} — ${routeHint}.`;
803
+ recordDiagnostic({
804
+ code: hit.code,
805
+ severity: 'error',
806
+ message,
807
+ subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
808
+ evidence: {
809
+ segmentIndex: hit.segmentIndex,
810
+ position: hit.position,
811
+ lengthPx: length,
812
+ minimumPx: hit.code === 'composition/micro-segment' ? microSegmentPx : interiorSegmentPx,
813
+ from: hit.start,
814
+ to: hit.end,
815
+ },
816
+ supportedFixes: [routeHint],
817
+ });
818
+ return message;
819
+ });
820
+ }
821
+
822
+ export function cleanLabelRouteClearanceProblems({
823
+ relations,
824
+ labels,
825
+ endpointIds,
826
+ pathFor,
827
+ diagramType,
828
+ relationCollection,
829
+ profile,
830
+ threshold = 4,
831
+ routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other relationship route/via/channel',
832
+ }) {
833
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
834
+ if (requestedProfile !== 'showcase') return [];
835
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
836
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
837
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
838
+ return { relation, relationIndex, points: pathFor(relation)?.points };
839
+ }).filter(Boolean);
840
+ return collectLabelRouteClearance({ labels, routedRelations, threshold }).map((hit) => {
841
+ const describe = (relation, relationIndex) => {
842
+ const relationId = relation?.id ? ` id "${relation.id}"` : '';
843
+ const relationLabel = relation?.label ? ` label "${relation.label}"` : '';
844
+ return `${relationCollection}[${relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}"${relationLabel}`;
845
+ };
846
+ const clearance = Math.round(hit.clearance * 10) / 10;
847
+ const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
848
+ const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
849
+ const message = `[composition/label-route-clearance] showcase ${diagramType} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${describe(hit.labelRelation, hit.labelRelationIndex)} is ${clearance}px from ${describe(hit.otherRelation, hit.otherRelationIndex)} segment ${hit.segmentIndex} [${from}] -> [${to}] (label rect ${formatRect(hit.rect)}; minimum ${threshold}px) — ${routeHint}.`;
850
+ recordDiagnostic({
851
+ code: 'composition/label-route-clearance',
852
+ severity: 'error',
853
+ message,
854
+ subject: relationshipSubject(diagramType, relationCollection, hit.labelRelationIndex, hit.labelRelation),
855
+ evidence: {
856
+ label: hit.label?.label || hit.labelRelation?.label || '',
857
+ otherRelationship: relationshipSubject(diagramType, relationCollection, hit.otherRelationIndex, hit.otherRelation),
858
+ segmentIndex: hit.segmentIndex,
859
+ clearancePx: clearance,
860
+ minimumPx: threshold,
861
+ labelRect: hit.rect,
862
+ from: hit.start,
863
+ to: hit.end,
864
+ },
865
+ supportedFixes: [routeHint],
866
+ });
867
+ return message;
868
+ });
869
+ }
870
+
871
+ function segmentPosition(index, segmentCount) {
872
+ if (index === 0) return 'source-stub';
873
+ if (index === segmentCount - 1) return 'target-stub';
874
+ return 'interior';
875
+ }
876
+
877
+ export function normalizeRoutePoints(points) {
878
+ const finite = asArray(points).filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point));
879
+ const deduped = [];
880
+ for (const point of finite) {
881
+ const previous = deduped.at(-1);
882
+ if (!previous || Math.abs(point[0] - previous[0]) > 0.0001 || Math.abs(point[1] - previous[1]) > 0.0001) deduped.push(point);
883
+ }
884
+ const normalized = [];
885
+ for (const point of deduped) {
886
+ while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
887
+ normalized.push(point);
888
+ }
889
+ return normalized;
890
+ }
891
+
892
+ function pointRectDistance(point, rect) {
893
+ const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
894
+ const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
895
+ return Math.hypot(dx, dy);
896
+ }
897
+
898
+ function pointSegmentDistance(point, start, end) {
899
+ const dx = end[0] - start[0];
900
+ const dy = end[1] - start[1];
901
+ const lengthSquared = dx * dx + dy * dy;
902
+ if (lengthSquared <= 0.0000001) return Math.hypot(point[0] - start[0], point[1] - start[1]);
903
+ const projection = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared));
904
+ return Math.hypot(point[0] - (start[0] + projection * dx), point[1] - (start[1] + projection * dy));
905
+ }
906
+
907
+ function collinearForward(a, b, c) {
908
+ if (Math.abs(crossProduct(a, b, c)) > 0.0001) return false;
909
+ return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001;
910
+ }
911
+
912
+ function frameBorderSegments(frame) {
913
+ if (!frame || typeof frame !== 'object') return [];
914
+ if (frame.shape === 'line') {
915
+ const start = frame.start || [frame.x1, frame.y1];
916
+ const end = frame.end || [frame.x2, frame.y2];
917
+ return isFinitePoint(...start, ...end) ? [{ side: 'line', start, end }] : [];
918
+ }
919
+ if (!isFinitePoint(frame.x, frame.y, frame.width, frame.height) || frame.width <= 0 || frame.height <= 0) return [];
920
+ const radius = Math.max(0, Math.min(Number(frame.radius) || 0, frame.width / 2, frame.height / 2));
921
+ const left = frame.x;
922
+ const right = frame.x + frame.width;
923
+ const top = frame.y;
924
+ const bottom = frame.y + frame.height;
925
+ return [
926
+ { side: 'top', start: [left + radius, top], end: [right - radius, top] },
927
+ { side: 'right', start: [right, top + radius], end: [right, bottom - radius] },
928
+ { side: 'bottom', start: [right - radius, bottom], end: [left + radius, bottom] },
929
+ { side: 'left', start: [left, bottom - radius], end: [left, top + radius] },
930
+ ].filter(({ start, end }) => Math.hypot(end[0] - start[0], end[1] - start[1]) > 0.0001);
931
+ }
932
+
933
+ function mergeBorderOverlaps(overlaps, border) {
934
+ const horizontal = Math.abs(border.start[1] - border.end[1]) <= 0.0001;
935
+ const axis = horizontal ? 0 : 1;
936
+ const fixed = horizontal ? border.start[1] : border.start[0];
937
+ const sorted = overlaps.map((overlap) => ({
938
+ low: Math.min(overlap.start[axis], overlap.end[axis]),
939
+ high: Math.max(overlap.start[axis], overlap.end[axis]),
940
+ })).sort((left, right) => left.low - right.low || left.high - right.high);
941
+ const merged = [];
942
+ for (const interval of sorted) {
943
+ const previous = merged.at(-1);
944
+ if (previous && interval.low <= previous.high + 0.0001) previous.high = Math.max(previous.high, interval.high);
945
+ else merged.push({ ...interval });
946
+ }
947
+ return merged.map((interval) => ({
948
+ ...interval,
949
+ length: interval.high - interval.low,
950
+ start: horizontal ? [interval.low, fixed] : [fixed, interval.low],
951
+ end: horizontal ? [interval.high, fixed] : [fixed, interval.high],
952
+ }));
953
+ }
954
+
955
+ function collinearAxisOverlap(a, b, c, d) {
956
+ const epsilon = 0.0001;
957
+ const horizontal = Math.abs(a[1] - b[1]) <= epsilon
958
+ && Math.abs(c[1] - d[1]) <= epsilon
959
+ && Math.abs(a[1] - c[1]) <= epsilon;
960
+ const vertical = Math.abs(a[0] - b[0]) <= epsilon
961
+ && Math.abs(c[0] - d[0]) <= epsilon
962
+ && Math.abs(a[0] - c[0]) <= epsilon;
963
+ if (!horizontal && !vertical) return null;
964
+ const axis = horizontal ? 0 : 1;
965
+ const low = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
966
+ const high = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]));
967
+ if (high - low <= epsilon) return null;
968
+ const fixed = horizontal ? a[1] : a[0];
969
+ return {
970
+ length: high - low,
971
+ start: horizontal ? [low, fixed] : [fixed, low],
972
+ end: horizontal ? [high, fixed] : [fixed, high],
973
+ };
974
+ }
975
+
976
+ function properSegmentIntersection(a, b, c, d) {
977
+ const abC = crossProduct(a, b, c);
978
+ const abD = crossProduct(a, b, d);
979
+ const cdA = crossProduct(c, d, a);
980
+ const cdB = crossProduct(c, d, b);
981
+ const epsilon = 0.0001;
982
+ const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
983
+ if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
984
+
985
+ const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
986
+ if (Math.abs(denominator) < epsilon) return null;
987
+ const ab = a[0] * b[1] - a[1] * b[0];
988
+ const cd = c[0] * d[1] - c[1] * d[0];
989
+ return [
990
+ (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
991
+ (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator
992
+ ];
993
+ }
994
+
995
+ function crossProduct(a, b, c) {
996
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
997
+ }
998
+
999
+ function pointInBox(point, box) {
1000
+ return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
1001
+ }
1002
+
1003
+ function segmentsIntersect(a, b, c, d) {
1004
+ const o1 = orientation(a, b, c);
1005
+ const o2 = orientation(a, b, d);
1006
+ const o3 = orientation(c, d, a);
1007
+ const o4 = orientation(c, d, b);
1008
+
1009
+ if (o1 === 0 && onSegment(a, c, b)) return true;
1010
+ if (o2 === 0 && onSegment(a, d, b)) return true;
1011
+ if (o3 === 0 && onSegment(c, a, d)) return true;
1012
+ if (o4 === 0 && onSegment(c, b, d)) return true;
1013
+
1014
+ return o1 !== o2 && o3 !== o4;
1015
+ }
1016
+
1017
+ function orientation(a, b, c) {
1018
+ const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
1019
+ if (Math.abs(value) < 0.0001) return 0;
1020
+ return value > 0 ? 1 : 2;
1021
+ }
1022
+
1023
+ function onSegment(a, b, c) {
1024
+ return (
1025
+ b[0] <= Math.max(a[0], c[0]) &&
1026
+ b[0] >= Math.min(a[0], c[0]) &&
1027
+ b[1] <= Math.max(a[1], c[1]) &&
1028
+ b[1] >= Math.min(a[1], c[1])
1029
+ );
1030
+ }
1031
+
1032
+ export function anchor(rect, side) {
1033
+ switch (side) {
1034
+ case 'left': return [rect.x, rect.cy];
1035
+ case 'right': return [rect.x + rect.width, rect.cy];
1036
+ case 'top': return [rect.cx, rect.y];
1037
+ case 'bottom': return [rect.cx, rect.y + rect.height];
1038
+ default:
1039
+ return [rect.x + rect.width, rect.cy];
1040
+ }
1041
+ }
1042
+
1043
+ const PORT_OUTWARD_VECTOR = {
1044
+ left: [-1, 0],
1045
+ right: [1, 0],
1046
+ top: [0, -1],
1047
+ bottom: [0, 1],
1048
+ };
1049
+
1050
+ // Automatic port spreading can put otherwise parallel anchors only a few
1051
+ // pixels apart. A conventional midpoint dogleg then violates the renderer's
1052
+ // own 8px/16px route-rhythm floors. Return a full outside-channel route when
1053
+ // that happens, or null when the normal automatic route remains appropriate.
1054
+ export function automaticPortRhythmBridge(
1055
+ start,
1056
+ end,
1057
+ fromSide,
1058
+ toSide,
1059
+ { endpointStubPx = 24, interiorSegmentPx = 16, accept } = {},
1060
+ ) {
1061
+ if (!Array.isArray(start) || !Array.isArray(end)
1062
+ || start.length !== 2 || end.length !== 2
1063
+ || !isFinitePoint(...start, ...end)) return null;
1064
+ const fromVector = PORT_OUTWARD_VECTOR[fromSide];
1065
+ const toVector = PORT_OUTWARD_VECTOR[toSide];
1066
+ if (!fromVector || !toVector) return null;
1067
+
1068
+ const startStub = [
1069
+ start[0] + fromVector[0] * endpointStubPx,
1070
+ start[1] + fromVector[1] * endpointStubPx,
1071
+ ];
1072
+ const endStub = [
1073
+ end[0] + toVector[0] * endpointStubPx,
1074
+ end[1] + toVector[1] * endpointStubPx,
1075
+ ];
1076
+ const candidates = [];
1077
+ const verticalSides = new Set(['top', 'bottom']);
1078
+ const horizontalSides = new Set(['left', 'right']);
1079
+
1080
+ if (verticalSides.has(fromSide) && verticalSides.has(toSide)
1081
+ && Math.abs(start[0] - end[0]) < interiorSegmentPx) {
1082
+ for (const channelX of [
1083
+ Math.max(start[0], end[0]) + interiorSegmentPx,
1084
+ Math.min(start[0], end[0]) - interiorSegmentPx,
1085
+ ]) {
1086
+ candidates.push([
1087
+ start,
1088
+ startStub,
1089
+ [channelX, startStub[1]],
1090
+ [channelX, endStub[1]],
1091
+ endStub,
1092
+ end,
1093
+ ]);
1094
+ }
1095
+ }
1096
+ if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
1097
+ && Math.abs(start[1] - end[1]) < interiorSegmentPx) {
1098
+ for (const channelY of [
1099
+ Math.max(start[1], end[1]) + interiorSegmentPx,
1100
+ Math.min(start[1], end[1]) - interiorSegmentPx,
1101
+ ]) {
1102
+ candidates.push([
1103
+ start,
1104
+ startStub,
1105
+ [startStub[0], channelY],
1106
+ [endStub[0], channelY],
1107
+ endStub,
1108
+ end,
1109
+ ]);
1110
+ }
1111
+ }
1112
+
1113
+ return candidates
1114
+ .map((points) => normalizeRoutePoints(points))
1115
+ .find((points) => (
1116
+ routeHonorsEndpointSides(points, fromSide, toSide)
1117
+ && collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx }).length === 0
1118
+ && (typeof accept !== 'function' || accept(points))
1119
+ )) || null;
1120
+ }
1121
+
1122
+ // Keep conservative auto-routed fan-out/fan-in relationships visually
1123
+ // distinct without changing authored route controls. The returned map only
1124
+ // contains endpoints that belong to a shared automatic midpoint anchor.
1125
+ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor } = {}) {
1126
+ const groups = new Map();
1127
+ const spread = new Map();
1128
+
1129
+ const add = (relation, endpoint, rect, side, counterpart) => {
1130
+ const key = `${rect.id}\u0000${side}`;
1131
+ const items = groups.get(key) || [];
1132
+ items.push({ relation, endpoint, rect, side, counterpart });
1133
+ groups.set(key, items);
1134
+ };
1135
+
1136
+ for (const relation of asArray(relations)) {
1137
+ if (!relation || (relation.route && relation.route !== 'auto')) continue;
1138
+ if (relation.via || relation.channelX !== undefined || relation.channelY !== undefined || relation.labelAt) continue;
1139
+ const from = boxes.get(relation.from);
1140
+ const to = boxes.get(relation.to);
1141
+ if (!from || !to) continue;
1142
+ const fromSide = chosenSide(
1143
+ relation.fromSide,
1144
+ sideFor?.(relation, 'source') || defaultFromSide(from, to),
1145
+ );
1146
+ const toSide = chosenSide(
1147
+ relation.toSide,
1148
+ sideFor?.(relation, 'target') || defaultToSide(from, to),
1149
+ );
1150
+ add(relation, 'from', from, fromSide, to);
1151
+ add(relation, 'to', to, toSide, from);
1152
+ }
1153
+
1154
+ for (const items of groups.values()) {
1155
+ if (items.length < 2) continue;
1156
+ const verticalSide = items[0].side === 'left' || items[0].side === 'right';
1157
+ items.sort((a, b) => {
1158
+ const aCoordinate = verticalSide ? a.counterpart.cy : a.counterpart.cx;
1159
+ const bCoordinate = verticalSide ? b.counterpart.cy : b.counterpart.cx;
1160
+ if (aCoordinate !== bCoordinate) return aCoordinate - bCoordinate;
1161
+ const aKey = `${a.relation.id || ''}\u0000${a.relation.from}\u0000${a.relation.to}\u0000${a.relation.label || ''}`;
1162
+ const bKey = `${b.relation.id || ''}\u0000${b.relation.from}\u0000${b.relation.to}\u0000${b.relation.label || ''}`;
1163
+ return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
1164
+ });
1165
+
1166
+ const extent = verticalSide ? items[0].rect.height : items[0].rect.width;
1167
+ const usable = Math.max(0, extent - gutter * 2);
1168
+ const spacing = Math.min(maxSpacing, usable / (items.length - 1));
1169
+ if (!(spacing > 0)) continue;
1170
+
1171
+ for (const [index, item] of items.entries()) {
1172
+ const offset = (index - (items.length - 1) / 2) * spacing;
1173
+ const point = anchor(item.rect, item.side);
1174
+ if (verticalSide) point[1] += offset;
1175
+ else point[0] += offset;
1176
+ const endpoints = spread.get(item.relation) || {};
1177
+ endpoints[item.endpoint] = point;
1178
+ spread.set(item.relation, endpoints);
1179
+ }
1180
+ }
1181
+
1182
+ return spread;
1183
+ }
1184
+
1185
+ export function defaultFromSide(from, to) {
1186
+ if (to.cx < from.cx) return 'left';
1187
+ if (to.cx > from.cx) return 'right';
1188
+ if (to.cy > from.cy) return 'bottom';
1189
+ return 'top';
1190
+ }
1191
+
1192
+ export function defaultToSide(from, to) {
1193
+ if (to.cx < from.cx) return 'right';
1194
+ if (to.cx > from.cx) return 'left';
1195
+ if (to.cy > from.cy) return 'top';
1196
+ return 'bottom';
1197
+ }
1198
+
1199
+ export function chosenSide(side, fallback) {
1200
+ return side && side !== 'auto' ? side : fallback;
1201
+ }
1202
+
1203
+ export function polylinePath(points) {
1204
+ return points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
1205
+ }
1206
+
1207
+ export function routePointsValue(points) {
1208
+ return asArray(points)
1209
+ .filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))
1210
+ .map(([x, y]) => `${x},${y}`)
1211
+ .join(';');
1212
+ }
1213
+
1214
+ export function roundedPath(points, radius) {
1215
+ if (points.length < 3 || radius <= 0) {
1216
+ return polylinePath(points);
1217
+ }
1218
+
1219
+ const commands = [`M ${points[0][0]} ${points[0][1]}`];
1220
+ for (let i = 1; i < points.length - 1; i += 1) {
1221
+ const [px, py] = points[i - 1];
1222
+ const [cx, cy] = points[i];
1223
+ const [nx, ny] = points[i + 1];
1224
+ const prevLen = Math.hypot(cx - px, cy - py);
1225
+ const nextLen = Math.hypot(nx - cx, ny - cy);
1226
+ const r = Math.min(radius, prevLen / 2, nextLen / 2);
1227
+ if (r < 1) {
1228
+ commands.push(`L ${cx} ${cy}`);
1229
+ continue;
1230
+ }
1231
+ const before = [cx - ((cx - px) / prevLen) * r, cy - ((cy - py) / prevLen) * r];
1232
+ const after = [cx + ((nx - cx) / nextLen) * r, cy + ((ny - cy) / nextLen) * r];
1233
+ commands.push(`L ${before[0]} ${before[1]}`);
1234
+ commands.push(`Q ${cx} ${cy} ${after[0]} ${after[1]}`);
1235
+ }
1236
+ const [endX, endY] = points[points.length - 1];
1237
+ commands.push(`L ${endX} ${endY}`);
1238
+ return commands.join(' ');
1239
+ }
1240
+
1241
+ // Shared by edges/flows/transitions: all carry the same optional
1242
+ // labelAt/labelDx/labelDy/labelSegment knobs.
1243
+ export function labelPoint(item, points) {
1244
+ if (item.labelAt) return item.labelAt;
1245
+ if (points.length === 2) {
1246
+ return [
1247
+ (points[0][0] + points[1][0]) / 2 + (item.labelDx || 0),
1248
+ points[0][1] - 10 + (item.labelDy || 0)
1249
+ ];
1250
+ }
1251
+ const segmentIndex = Math.min(points.length - 2, Math.max(0, item.labelSegment ?? 1));
1252
+ const a = points[segmentIndex];
1253
+ const b = points[segmentIndex + 1];
1254
+ return [(a[0] + b[0]) / 2 + (item.labelDx || 0), (a[1] + b[1]) / 2 - 10 + (item.labelDy || 0)];
1255
+ }
1256
+
1257
+ export const componentFill = {
1258
+ frontend: 'c-frontend',
1259
+ backend: 'c-backend',
1260
+ database: 'c-database',
1261
+ cloud: 'c-cloud',
1262
+ security: 'c-security',
1263
+ messagebus: 'c-messagebus',
1264
+ external: 'c-external'
1265
+ };
1266
+
1267
+ export const componentText = {
1268
+ frontend: 't-frontend',
1269
+ backend: 't-backend',
1270
+ database: 't-database',
1271
+ cloud: 't-cloud',
1272
+ security: 't-security',
1273
+ messagebus: 't-messagebus',
1274
+ external: 't-external'
1275
+ };
1276
+
1277
+ export const arrowClassMap = {
1278
+ default: ['a-default', 'arrowhead'],
1279
+ emphasis: ['a-emphasis', 'arrowhead-emphasis'],
1280
+ security: ['a-security', 'arrowhead-security'],
1281
+ dashed: ['a-dashed', 'arrowhead-dashed']
1282
+ };
1283
+
1284
+ // Label accent per edge variant. Workflow colors dashed (async trace) labels
1285
+ // like the trace store it points at; the other renderers use the bus color.
1286
+ export function variantAccent(variant, { dashed = 't-messagebus' } = {}) {
1287
+ return variant === 'security'
1288
+ ? 't-security'
1289
+ : variant === 'emphasis'
1290
+ ? 't-backend'
1291
+ : variant === 'dashed'
1292
+ ? dashed
1293
+ : 't-muted';
1294
+ }
1295
+
1296
+ export function formatRect(r) {
1297
+ return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`;
1298
+ }
1299
+
1300
+ function formatDelta(n) {
1301
+ const v = Math.round(n);
1302
+ return v >= 0 ? `+${v}` : String(v);
1303
+ }
1304
+
1305
+ /** Actionable hint when an edge label rect hits a node/component box (#7). */
1306
+ export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component') {
1307
+ const lxR = Math.round(lx);
1308
+ const lyR = Math.round(ly);
1309
+ const belowY = Math.round(obstacle.y + obstacle.height + 14);
1310
+ const aboveY = Math.round(obstacle.y - 4);
1311
+ return [
1312
+ ` label rect: ${formatRect(labelRect)}`,
1313
+ ` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`,
1314
+ ` Suggested fix: labelAt [${lxR}, ${belowY}] or labelDy ${formatDelta(belowY - lyR)} (below); or labelAt [${lxR}, ${aboveY}] or labelDy ${formatDelta(aboveY - lyR)} (above)`,
1315
+ ].join('\n');
1316
+ }
1317
+
1318
+ /** Hint when two edge labels collide. */
1319
+ export function suggestLabelPairFix(a, b) {
1320
+ return [
1321
+ ` "${a.label}" ${formatRect(a)}; "${b.label}" ${formatRect(b)}`,
1322
+ ' Suggested fix: add labelDy +24 on one edge, adjust labelDx, or remove one label',
1323
+ ].join('\n');
1324
+ }
1325
+
1326
+ /** Hint when two components/nodes are too close. */
1327
+ export function suggestComponentSeparation(a, b, minGap = 8) {
1328
+ const rightX = Math.round(a.x + a.width + minGap);
1329
+ const belowY = Math.round(a.y + a.height + minGap);
1330
+ return [
1331
+ ` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`,
1332
+ ` Suggested fix: move "${b.id}" pos to [${rightX}, ${Math.round(b.y)}] (right of "${a.id}") or [${Math.round(b.x)}, ${belowY}] (below)`,
1333
+ ].join('\n');
1334
+ }