@stacksjs/defaults 0.75.361 → 0.75.366
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.
- package/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +5 -5
- package/ai/skills/stacks-payments/SKILL.md +10 -0
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ai/skills/stacks-testing/SKILL.md +8 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -17,6 +17,14 @@ export function isFinitePoint(...coords) {
|
|
|
17
17
|
return coords.every((c) => Number.isFinite(c));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// Separation the layout solver guarantees and separation this check re-derives
|
|
21
|
+
// travel through different arithmetic, so a pair the solver placed at exactly
|
|
22
|
+
// its minimum can land a few ulps short of this comparison. In #583 the CLI's
|
|
23
|
+
// own receipt has the pair `793.6 + 160 + 8` read as 2**-43 past a5's left edge
|
|
24
|
+
// at 961.5999999999999 — one ulp at that magnitude. 0.0001 is the numeric
|
|
25
|
+
// tolerance the route and layout geometry in this file already compares with.
|
|
26
|
+
const NUMERIC_CLEARANCE_PX = 0.0001;
|
|
27
|
+
|
|
20
28
|
export function rectsOverlap(a, b, gap = 0) {
|
|
21
29
|
// Non-finite geometry means "unknown", not "overlapping". Every comparison
|
|
22
30
|
// below is false for NaN, so without this guard the negation reports a
|
|
@@ -27,10 +35,10 @@ export function rectsOverlap(a, b, gap = 0) {
|
|
|
27
35
|
return false;
|
|
28
36
|
}
|
|
29
37
|
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
|
|
38
|
+
a.x + a.width + gap <= b.x + NUMERIC_CLEARANCE_PX ||
|
|
39
|
+
b.x + b.width + gap <= a.x + NUMERIC_CLEARANCE_PX ||
|
|
40
|
+
a.y + a.height + gap <= b.y + NUMERIC_CLEARANCE_PX ||
|
|
41
|
+
b.y + b.height + gap <= a.y + NUMERIC_CLEARANCE_PX
|
|
34
42
|
);
|
|
35
43
|
}
|
|
36
44
|
|
|
@@ -55,21 +63,39 @@ export function segmentRectClearance(segment, rect) {
|
|
|
55
63
|
if (!segment || !rect) return null;
|
|
56
64
|
const { start, end } = segment;
|
|
57
65
|
if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
|
|
58
|
-
if (!isFinitePoint(
|
|
66
|
+
if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
|
|
59
67
|
if (rect.width < 0 || rect.height < 0) return null;
|
|
60
68
|
if (segmentIntersectsRect(segment, rect)) return 0;
|
|
61
69
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const right = rect.x + rect.width;
|
|
71
|
+
const bottom = rect.y + rect.height;
|
|
72
|
+
let clearance = Math.min(pointRectDistance(start, rect), pointRectDistance(end, rect));
|
|
73
|
+
const corners = [rect.x, rect.y, right, rect.y, right, bottom, rect.x, bottom];
|
|
74
|
+
for (let index = 0; index < 8; index += 2) {
|
|
75
|
+
const distance = pointSegmentDistanceXY(corners[index], corners[index + 1], start, end);
|
|
76
|
+
if (distance < clearance) clearance = distance;
|
|
77
|
+
}
|
|
78
|
+
return clearance;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The same clearance, but the caller already knows the threshold it compares
|
|
82
|
+
// against: when an axis gap alone proves the distance cannot be smaller, the
|
|
83
|
+
// exact distance is never computed and Infinity is returned.
|
|
84
|
+
export function segmentRectClearanceWithin(segment, rect, limit) {
|
|
85
|
+
if (!segment || !rect) return segmentRectClearance(segment, rect);
|
|
86
|
+
const { start, end } = segment;
|
|
87
|
+
if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
|
|
88
|
+
if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
|
|
89
|
+
if (rect.width < 0 || rect.height < 0) return null;
|
|
90
|
+
if (segmentIntersectsRect(segment, rect)) return 0;
|
|
91
|
+
const minX = Math.min(start[0], end[0]);
|
|
92
|
+
const maxX = Math.max(start[0], end[0]);
|
|
93
|
+
const minY = Math.min(start[1], end[1]);
|
|
94
|
+
const maxY = Math.max(start[1], end[1]);
|
|
95
|
+
const gapX = Math.max(rect.x - maxX, minX - (rect.x + rect.width));
|
|
96
|
+
const gapY = Math.max(rect.y - maxY, minY - (rect.y + rect.height));
|
|
97
|
+
if (Math.max(gapX, gapY) > limit) return Number.POSITIVE_INFINITY;
|
|
98
|
+
return segmentRectClearance(segment, rect);
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
export function segmentRectIntersectionLength(segment, rect) {
|
|
@@ -104,8 +130,82 @@ export function segmentRectIntersectionLength(segment, rect) {
|
|
|
104
130
|
return length * Math.max(0, leave - enter);
|
|
105
131
|
}
|
|
106
132
|
|
|
133
|
+
const ROUTE_INDEX_CELL_SIZE = 128;
|
|
134
|
+
const ROUTE_INDEX_MAX_CELLS_PER_ROUTE = 256;
|
|
135
|
+
|
|
136
|
+
function routeBounds(route) {
|
|
137
|
+
let x1 = Infinity;
|
|
138
|
+
let y1 = Infinity;
|
|
139
|
+
let x2 = -Infinity;
|
|
140
|
+
let y2 = -Infinity;
|
|
141
|
+
for (const [x, y] of route.points) {
|
|
142
|
+
x1 = Math.min(x1, x);
|
|
143
|
+
y1 = Math.min(y1, y);
|
|
144
|
+
x2 = Math.max(x2, x);
|
|
145
|
+
y2 = Math.max(y2, y);
|
|
146
|
+
}
|
|
147
|
+
return { x1, y1, x2, y2 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createRouteCandidateIndex(routes) {
|
|
151
|
+
const cells = new Map();
|
|
152
|
+
const globalRouteIndexes = [];
|
|
153
|
+
|
|
154
|
+
routes.forEach((route, routeIndex) => {
|
|
155
|
+
const bounds = routeBounds(route);
|
|
156
|
+
const x1 = Math.floor(bounds.x1 / ROUTE_INDEX_CELL_SIZE);
|
|
157
|
+
const y1 = Math.floor(bounds.y1 / ROUTE_INDEX_CELL_SIZE);
|
|
158
|
+
const x2 = Math.floor(bounds.x2 / ROUTE_INDEX_CELL_SIZE);
|
|
159
|
+
const y2 = Math.floor(bounds.y2 / ROUTE_INDEX_CELL_SIZE);
|
|
160
|
+
if (![x1, y1, x2, y2].every(Number.isSafeInteger)) {
|
|
161
|
+
globalRouteIndexes.push(routeIndex);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
|
|
165
|
+
|
|
166
|
+
// Very long routes are cheaper to test once per label than to duplicate
|
|
167
|
+
// across a large part of the index.
|
|
168
|
+
if (!Number.isSafeInteger(cellCount) || cellCount > ROUTE_INDEX_MAX_CELLS_PER_ROUTE) {
|
|
169
|
+
globalRouteIndexes.push(routeIndex);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (let y = y1; y <= y2; y += 1) {
|
|
173
|
+
for (let x = x1; x <= x2; x += 1) {
|
|
174
|
+
const key = `${x}:${y}`;
|
|
175
|
+
const bucket = cells.get(key);
|
|
176
|
+
if (bucket) bucket.push(routeIndex);
|
|
177
|
+
else cells.set(key, [routeIndex]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return function candidates(rect, threshold) {
|
|
183
|
+
const x1 = Math.floor((rect.x - threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
184
|
+
const y1 = Math.floor((rect.y - threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
185
|
+
const x2 = Math.floor((rect.x + rect.width + threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
186
|
+
const y2 = Math.floor((rect.y + rect.height + threshold) / ROUTE_INDEX_CELL_SIZE);
|
|
187
|
+
const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
|
|
188
|
+
|
|
189
|
+
// A huge threshold offers no selectivity and can overflow cell iteration.
|
|
190
|
+
if (![x1, y1, x2, y2].every(Number.isSafeInteger)
|
|
191
|
+
|| !Number.isSafeInteger(cellCount)
|
|
192
|
+
|| cellCount > Math.max(64, cells.size * 4)) return routes;
|
|
193
|
+
|
|
194
|
+
const routeIndexes = new Set(globalRouteIndexes);
|
|
195
|
+
for (let y = y1; y <= y2; y += 1) {
|
|
196
|
+
for (let x = x1; x <= x2; x += 1) {
|
|
197
|
+
for (const routeIndex of cells.get(`${x}:${y}`) || []) routeIndexes.add(routeIndex);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return [...routeIndexes]
|
|
201
|
+
.sort((left, right) => left - right)
|
|
202
|
+
.map((routeIndex) => routes[routeIndex]);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
107
206
|
export function collectLabelRouteClearance({ labels, routedRelations, threshold }) {
|
|
108
207
|
if (!Number.isFinite(threshold) || threshold < 0) return [];
|
|
208
|
+
if (threshold === 0) return [];
|
|
109
209
|
const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => {
|
|
110
210
|
const relation = entry?.relation || entry;
|
|
111
211
|
const points = normalizeRoutePoints(entry?.points || relation?.routePoints);
|
|
@@ -125,6 +225,7 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold
|
|
|
125
225
|
});
|
|
126
226
|
const hits = [];
|
|
127
227
|
const seenLabels = new Set();
|
|
228
|
+
const candidateRoutes = createRouteCandidateIndex(routes);
|
|
128
229
|
|
|
129
230
|
for (const [fallbackIndex, label] of asArray(labels).entries()) {
|
|
130
231
|
const rect = label?.rect || label;
|
|
@@ -133,7 +234,7 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold
|
|
|
133
234
|
const labelIdentity = relationshipIdentity(label?.relation, relationIndex);
|
|
134
235
|
if (seenLabels.has(labelIdentity)) continue;
|
|
135
236
|
seenLabels.add(labelIdentity);
|
|
136
|
-
for (const route of
|
|
237
|
+
for (const route of candidateRoutes(rect, threshold)) {
|
|
137
238
|
if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue;
|
|
138
239
|
let nearest = null;
|
|
139
240
|
for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) {
|
|
@@ -167,6 +268,15 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold
|
|
|
167
268
|
return hits;
|
|
168
269
|
}
|
|
169
270
|
|
|
271
|
+
export function minimumLabelRouteClearance(measurements) {
|
|
272
|
+
if (!asArray(measurements).length) return null;
|
|
273
|
+
const minimum = measurements.reduce(
|
|
274
|
+
(value, hit) => Math.min(value, hit.clearance),
|
|
275
|
+
Infinity,
|
|
276
|
+
);
|
|
277
|
+
return Math.round(minimum * 10) / 10;
|
|
278
|
+
}
|
|
279
|
+
|
|
170
280
|
function relationshipIdentity(relation, relationIndex) {
|
|
171
281
|
if (relation?.key !== undefined) return `key:${relation.key}`;
|
|
172
282
|
if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`;
|
|
@@ -191,6 +301,20 @@ function relationshipSubject(diagramType, relationCollection, relationIndex, rel
|
|
|
191
301
|
};
|
|
192
302
|
}
|
|
193
303
|
|
|
304
|
+
const AUTHORED_PLAN_KEYS = ['route', 'via', 'channelX', 'channelY', 'fromSide', 'toSide', 'bias', 'labelAt', 'labelDx', 'labelDy', 'labelSegment'];
|
|
305
|
+
|
|
306
|
+
// Re-planning advice only applies when the failing relation actually carries
|
|
307
|
+
// authored controls; telling an author to remove controls that do not exist
|
|
308
|
+
// just sends them hunting for a field that was never written.
|
|
309
|
+
function rePlanHint(relations, fallback) {
|
|
310
|
+
const controls = new Set();
|
|
311
|
+
for (const relation of asArray(relations)) {
|
|
312
|
+
for (const key of AUTHORED_PLAN_KEYS) if (relation?.[key] !== undefined) controls.add(key);
|
|
313
|
+
}
|
|
314
|
+
if (!controls.size) return fallback;
|
|
315
|
+
return `if the authored ${[...controls].join('/')} are not required by the user, remove them so the renderer can re-plan; otherwise preserve that intent and ${fallback}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
194
318
|
const ENDPOINT_SIDE_RULES = {
|
|
195
319
|
left: {
|
|
196
320
|
axis: 'horizontal',
|
|
@@ -254,7 +378,6 @@ export function routeHonorsEndpointSides(points, fromSide, toSide) {
|
|
|
254
378
|
return !endpointSideIssue(points, 'source', fromSide)
|
|
255
379
|
&& !endpointSideIssue(points, 'target', toSide);
|
|
256
380
|
}
|
|
257
|
-
|
|
258
381
|
// Explicit fromSide/toSide are authored geometry, so a tangent or backwards
|
|
259
382
|
// endpoint segment changes their meaning. Fail this universally instead of
|
|
260
383
|
// leaving a malformed arrow for visual review to discover. Named routes and
|
|
@@ -307,7 +430,7 @@ export function cleanEndpointSideProblems({
|
|
|
307
430
|
const segmentRole = issue.endpoint === 'source' ? 'first' : 'final';
|
|
308
431
|
const from = issue.start.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
309
432
|
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}"
|
|
433
|
+
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
434
|
recordDiagnostic({
|
|
312
435
|
code: 'clean-flow/endpoint-side-direction',
|
|
313
436
|
severity: 'error',
|
|
@@ -376,7 +499,8 @@ export function cleanFlowProblems({
|
|
|
376
499
|
const from = points[hitSegment].map(Math.round).join(', ');
|
|
377
500
|
const to = points[hitSegment + 1].map(Math.round).join(', ');
|
|
378
501
|
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
|
379
|
-
const
|
|
502
|
+
const hint = rePlanHint([relation], routeHint);
|
|
503
|
+
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) — ${hint}.`;
|
|
380
504
|
recordDiagnostic({
|
|
381
505
|
code: 'clean-flow/edge-through-node',
|
|
382
506
|
severity: 'error',
|
|
@@ -390,7 +514,7 @@ export function cleanFlowProblems({
|
|
|
390
514
|
to: points[hitSegment + 1],
|
|
391
515
|
clearancePx: clearance,
|
|
392
516
|
},
|
|
393
|
-
supportedFixes: [
|
|
517
|
+
supportedFixes: [hint],
|
|
394
518
|
});
|
|
395
519
|
problems.push(message);
|
|
396
520
|
}
|
|
@@ -477,10 +601,29 @@ function pointLiesOnSegment(point, start, end) {
|
|
|
477
601
|
&& point[1] <= Math.max(start[1], end[1]) + epsilon;
|
|
478
602
|
}
|
|
479
603
|
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
604
|
+
// A bend meeting another route's interior can look like a branch even when
|
|
605
|
+
// no pair of segment interiors forms a proper X. Keep actual route endpoints
|
|
606
|
+
// out of this geometric query; callers decide which relationships are allowed
|
|
607
|
+
// to share junctions. Collinear corridors remain a separate quality measure.
|
|
608
|
+
export function polylineBendContacts(left, right) {
|
|
609
|
+
const contacts = [];
|
|
610
|
+
const near = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= 0.0001;
|
|
611
|
+
for (const [points, other] of [[left, right], [right, left]]) {
|
|
612
|
+
if (other.length < 2) continue;
|
|
613
|
+
for (let index = 1; index < points.length - 1; index += 1) {
|
|
614
|
+
const point = points[index];
|
|
615
|
+
if (Math.abs(crossProduct(points[index - 1], point, points[index + 1])) <= 0.0001
|
|
616
|
+
|| near(point, other[0]) || near(point, other.at(-1))) continue;
|
|
617
|
+
if (other.slice(1).some((end, segment) => pointLiesOnSegment(point, other[segment], end))
|
|
618
|
+
&& !contacts.some(otherPoint => near(point, otherPoint))) contacts.push(point);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return contacts;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Authored shared endpoints retain their junction interpretation by default.
|
|
625
|
+
// Renderers can opt their automatic routes into proper interior X checks;
|
|
626
|
+
// endpoint touches and collinear trunks still are not proper crossings.
|
|
484
627
|
export function cleanCrossingProblems({
|
|
485
628
|
relations,
|
|
486
629
|
endpointIds,
|
|
@@ -490,9 +633,14 @@ export function cleanCrossingProblems({
|
|
|
490
633
|
profile = 'standard',
|
|
491
634
|
profileIsAuthoritative = false,
|
|
492
635
|
mergeForwardCollinearWaypoints = false,
|
|
636
|
+
includeSharedEndpoints = () => false,
|
|
637
|
+
crossingResolved = () => false,
|
|
638
|
+
warnInStandard = false,
|
|
639
|
+
onDiagnostic = recordDiagnostic,
|
|
493
640
|
routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
|
|
494
641
|
}) {
|
|
495
|
-
|
|
642
|
+
const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
|
|
643
|
+
if (severity === 'warning' && !warnInStandard) return [];
|
|
496
644
|
const routed = asArray(relations).map((relation, index) => {
|
|
497
645
|
if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
|
|
498
646
|
const points = pathFor(relation)?.points;
|
|
@@ -513,7 +661,8 @@ export function cleanCrossingProblems({
|
|
|
513
661
|
const left = routed[leftIndex];
|
|
514
662
|
for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
|
|
515
663
|
const right = routed[rightIndex];
|
|
516
|
-
if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)
|
|
664
|
+
if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)
|
|
665
|
+
&& !includeSharedEndpoints(left.relation, right.relation)) continue;
|
|
517
666
|
|
|
518
667
|
let hit = null;
|
|
519
668
|
for (const leftSegment of left.analysisSegments) {
|
|
@@ -536,16 +685,18 @@ export function cleanCrossingProblems({
|
|
|
536
685
|
}
|
|
537
686
|
}
|
|
538
687
|
if (!hit) continue;
|
|
688
|
+
if (crossingResolved(left.relation, right.relation, hit)) continue;
|
|
539
689
|
|
|
540
690
|
const describe = ({ relation, index }) => {
|
|
541
691
|
const id = relation.id ? ` id "${relation.id}"` : '';
|
|
542
692
|
return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`;
|
|
543
693
|
};
|
|
544
694
|
const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
545
|
-
const
|
|
546
|
-
|
|
695
|
+
const hint = rePlanHint([left.relation, right.relation], routeHint);
|
|
696
|
+
const message = `[composition/proper-crossing] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${hint}.`;
|
|
697
|
+
onDiagnostic({
|
|
547
698
|
code: 'composition/proper-crossing',
|
|
548
|
-
severity
|
|
699
|
+
severity,
|
|
549
700
|
message,
|
|
550
701
|
subject: relationshipSubject(diagramType, relationCollection, left.index, left.relation),
|
|
551
702
|
evidence: {
|
|
@@ -554,9 +705,9 @@ export function cleanCrossingProblems({
|
|
|
554
705
|
segmentIndex: hit.leftSegment,
|
|
555
706
|
otherSegmentIndex: hit.rightSegment,
|
|
556
707
|
},
|
|
557
|
-
supportedFixes: [
|
|
708
|
+
supportedFixes: [hint],
|
|
558
709
|
});
|
|
559
|
-
problems.push(message);
|
|
710
|
+
if (severity === 'error') problems.push(message);
|
|
560
711
|
}
|
|
561
712
|
}
|
|
562
713
|
return problems;
|
|
@@ -564,12 +715,24 @@ export function cleanCrossingProblems({
|
|
|
564
715
|
|
|
565
716
|
// Two unrelated relationships that occupy the same visible corridor can read
|
|
566
717
|
// as one authored branch or merge even when neither relationship crosses a
|
|
567
|
-
// node or forms a proper X. Keep shared
|
|
568
|
-
//
|
|
718
|
+
// node or forms a proper X. Keep authored shared endpoints exempt by default;
|
|
719
|
+
// automatic architecture routes opt in because they promise separate ports.
|
|
720
|
+
// Workflow v2 opts into shared-endpoint checks with a bounded terminal-trunk
|
|
721
|
+
// exception. Other callers keep their existing authored-junction contract.
|
|
722
|
+
// The counterflow-only opt-in serves older workflow exports without a root
|
|
723
|
+
// readable-v2 contract; full shared-endpoint checking takes precedence.
|
|
724
|
+
// Relationships a renderer declares on one junction (a Lifecycle exit bracket)
|
|
725
|
+
// may share that junction's horizontal bus, the first tick from a common
|
|
726
|
+
// source port and, when they end at the same state, the merged drop into it;
|
|
727
|
+
// other overlaps between them count.
|
|
728
|
+
// Tiny overlaps below the route rhythm
|
|
569
729
|
// floor are ignored to avoid turning sub-pixel rounding into a quality debt.
|
|
570
730
|
export function collectAmbiguousCorridors({
|
|
571
731
|
routedRelations,
|
|
572
732
|
minOverlapPx = 8,
|
|
733
|
+
includeSharedEndpoints = () => false,
|
|
734
|
+
includeSharedEndpointCounterflow = () => false,
|
|
735
|
+
allowShortWorkflowTrunks = false,
|
|
573
736
|
}) {
|
|
574
737
|
const routed = asArray(routedRelations).map((entry, fallbackIndex) => {
|
|
575
738
|
const relation = entry?.relation;
|
|
@@ -580,6 +743,8 @@ export function collectAmbiguousCorridors({
|
|
|
580
743
|
relation,
|
|
581
744
|
relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex,
|
|
582
745
|
points,
|
|
746
|
+
...(entry.sourceEndpoint === false ? { sourceEndpoint: false } : {}),
|
|
747
|
+
...(entry.targetEndpoint === false ? { targetEndpoint: false } : {}),
|
|
583
748
|
};
|
|
584
749
|
}).filter(Boolean);
|
|
585
750
|
const hits = [];
|
|
@@ -588,7 +753,10 @@ export function collectAmbiguousCorridors({
|
|
|
588
753
|
const left = routed[leftIndex];
|
|
589
754
|
for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
|
|
590
755
|
const right = routed[rightIndex];
|
|
591
|
-
|
|
756
|
+
const sharedBus = Boolean(left.relation.junction) && left.relation.junction === right.relation.junction;
|
|
757
|
+
const sharedEndpoint = [left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to);
|
|
758
|
+
const counterflowOnly = sharedEndpoint && !includeSharedEndpoints(left.relation, right.relation);
|
|
759
|
+
if (counterflowOnly && !includeSharedEndpointCounterflow(left.relation, right.relation)) continue;
|
|
592
760
|
|
|
593
761
|
let longest = null;
|
|
594
762
|
for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) {
|
|
@@ -600,6 +768,21 @@ export function collectAmbiguousCorridors({
|
|
|
600
768
|
right.points[rightSegment + 1],
|
|
601
769
|
);
|
|
602
770
|
if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue;
|
|
771
|
+
if (allowShortWorkflowTrunks && shortWorkflowTrunk(left, right, leftSegment, rightSegment, overlap.length)) continue;
|
|
772
|
+
// A multi-target bracket repeats its source tick for each target.
|
|
773
|
+
// Limit that exemption to the first segment at the same source port:
|
|
774
|
+
// a common source identity cannot waive an interior vertical overlap.
|
|
775
|
+
const sharedSourceTick = left.sourceEndpoint !== false && right.sourceEndpoint !== false
|
|
776
|
+
&& left.relation.from === right.relation.from && leftSegment === 0 && rightSegment === 0
|
|
777
|
+
&& Math.abs(left.points[0][0] - right.points[0][0]) < 0.0001
|
|
778
|
+
&& Math.abs(left.points[0][1] - right.points[0][1]) < 0.0001;
|
|
779
|
+
if (sharedBus && (Math.abs(overlap.start[1] - overlap.end[1]) < 0.0001
|
|
780
|
+
|| left.relation.to === right.relation.to || sharedSourceTick)) continue;
|
|
781
|
+
if (counterflowOnly) {
|
|
782
|
+
const leftDelta = left.points[leftSegment + 1].map((value, axis) => value - left.points[leftSegment][axis]);
|
|
783
|
+
const rightDelta = right.points[rightSegment + 1].map((value, axis) => value - right.points[rightSegment][axis]);
|
|
784
|
+
if (leftDelta[0] * rightDelta[0] + leftDelta[1] * rightDelta[1] >= 0) continue;
|
|
785
|
+
}
|
|
603
786
|
if (!longest || overlap.length > longest.overlapLength + 0.0001) {
|
|
604
787
|
longest = {
|
|
605
788
|
left,
|
|
@@ -619,6 +802,64 @@ export function collectAmbiguousCorridors({
|
|
|
619
802
|
return hits;
|
|
620
803
|
}
|
|
621
804
|
|
|
805
|
+
function shortWorkflowTrunk(left, right, leftSegment, rightSegment, length) {
|
|
806
|
+
if (length > 24 + 0.0001) return false;
|
|
807
|
+
const a = left.relation, b = right.relation;
|
|
808
|
+
const variant = (edge) => edge.variant || 'default';
|
|
809
|
+
const width = (edge) => edge.width || (variant(edge) === 'emphasis' ? 1.8 : 1.4);
|
|
810
|
+
if (variant(a) !== variant(b) || width(a) !== width(b) || (a.role || '') !== (b.role || '')) return false;
|
|
811
|
+
const same = (p, q) => Math.abs(p[0] - q[0]) < 0.0001 && Math.abs(p[1] - q[1]) < 0.0001;
|
|
812
|
+
const source = left.sourceEndpoint !== false && right.sourceEndpoint !== false
|
|
813
|
+
&& a.from === b.from && leftSegment === 0 && rightSegment === 0
|
|
814
|
+
&& same(left.points[0], right.points[0]);
|
|
815
|
+
const target = left.targetEndpoint !== false && right.targetEndpoint !== false
|
|
816
|
+
&& a.to === b.to && leftSegment === left.points.length - 2 && rightSegment === right.points.length - 2
|
|
817
|
+
&& same(left.points.at(-1), right.points.at(-1));
|
|
818
|
+
if (!source && !target) return false;
|
|
819
|
+
const p = left.points[leftSegment], q = left.points[leftSegment + 1];
|
|
820
|
+
const r = right.points[rightSegment], s = right.points[rightSegment + 1];
|
|
821
|
+
return (q[0] - p[0]) * (s[0] - r[0]) + (q[1] - p[1]) * (s[1] - r[1]) > 0;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Bundled arrow markers are 7 stroke-widths across the direction of travel.
|
|
825
|
+
// Callers select the automatic routes they own; explicit junctions are preserved,
|
|
826
|
+
// and relations on one junction ending at the same state share a merged drop.
|
|
827
|
+
export function collectArrowheadCollisions({ routedRelations, allowShortWorkflowTrunks = false }) {
|
|
828
|
+
const incoming = new Map();
|
|
829
|
+
const hits = [];
|
|
830
|
+
for (const entry of asArray(routedRelations)) {
|
|
831
|
+
const points = normalizeRoutePoints(entry.points);
|
|
832
|
+
if (points.length < 2 || !entry.relation?.to) continue;
|
|
833
|
+
const tip = points.at(-1);
|
|
834
|
+
const previous = points.at(-2);
|
|
835
|
+
const vertical = Math.abs(tip[0] - previous[0]) < 0.0001;
|
|
836
|
+
const axis = vertical ? 0 : 1;
|
|
837
|
+
const direction = Math.sign(tip[1 - axis] - previous[1 - axis]);
|
|
838
|
+
const halfWidth = 3.5 * (entry.relation.width || (entry.relation.variant === 'emphasis' ? 1.8 : 1.5));
|
|
839
|
+
const key = `${entry.relation.to}\u0000${axis}\u0000${direction}`;
|
|
840
|
+
const siblings = incoming.get(key) || [];
|
|
841
|
+
const current = { ...entry, tip, halfWidth };
|
|
842
|
+
for (const sibling of siblings) {
|
|
843
|
+
// One junction's merged drop into a state carries a single arrowhead.
|
|
844
|
+
if (sibling.relation.junction && sibling.relation.junction === entry.relation.junction
|
|
845
|
+
&& sibling.relation.to === entry.relation.to) continue;
|
|
846
|
+
if (Math.abs(tip[1 - axis] - sibling.tip[1 - axis]) > 0.0001) continue;
|
|
847
|
+
const distance = Math.abs(tip[axis] - sibling.tip[axis]);
|
|
848
|
+
const minimum = halfWidth + sibling.halfWidth;
|
|
849
|
+
if (allowShortWorkflowTrunks) {
|
|
850
|
+
const left = { ...sibling, points: normalizeRoutePoints(sibling.points) };
|
|
851
|
+
const right = { ...entry, points };
|
|
852
|
+
const overlap = collinearAxisOverlap(left.points.at(-2), left.points.at(-1), points.at(-2), tip);
|
|
853
|
+
if (overlap && shortWorkflowTrunk(left, right, left.points.length - 2, points.length - 2, overlap.length)) continue;
|
|
854
|
+
}
|
|
855
|
+
if (distance < minimum - 0.0001) hits.push({ left: sibling, right: current, distance, minimum });
|
|
856
|
+
}
|
|
857
|
+
siblings.push(current);
|
|
858
|
+
incoming.set(key, siblings);
|
|
859
|
+
}
|
|
860
|
+
return hits;
|
|
861
|
+
}
|
|
862
|
+
|
|
622
863
|
export function cleanAmbiguousCorridorProblems({
|
|
623
864
|
relations,
|
|
624
865
|
endpointIds,
|
|
@@ -629,11 +870,16 @@ export function cleanAmbiguousCorridorProblems({
|
|
|
629
870
|
profileIsAuthoritative = false,
|
|
630
871
|
routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
|
|
631
872
|
minOverlapPx = 8,
|
|
873
|
+
includeSharedEndpoints = () => false,
|
|
874
|
+
includeSharedEndpointCounterflow = () => false,
|
|
875
|
+
allowShortWorkflowTrunks = false,
|
|
876
|
+
onDiagnostic = recordDiagnostic,
|
|
632
877
|
}) {
|
|
633
|
-
|
|
878
|
+
const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
|
|
879
|
+
if (severity === 'warning' && !allowShortWorkflowTrunks) return [];
|
|
634
880
|
const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
|
|
635
881
|
|
|
636
|
-
return collectAmbiguousCorridors({ routedRelations, minOverlapPx }).map((hit) => {
|
|
882
|
+
return collectAmbiguousCorridors({ routedRelations, minOverlapPx, includeSharedEndpoints, includeSharedEndpointCounterflow, allowShortWorkflowTrunks }).map((hit) => {
|
|
637
883
|
const describe = ({ relation, relationIndex }) => {
|
|
638
884
|
const id = relation.id ? ` id "${relation.id}"` : '';
|
|
639
885
|
return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`;
|
|
@@ -641,10 +887,11 @@ export function cleanAmbiguousCorridorProblems({
|
|
|
641
887
|
const length = Math.round(hit.overlapLength * 10) / 10;
|
|
642
888
|
const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
643
889
|
const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
644
|
-
const
|
|
645
|
-
|
|
890
|
+
const hint = rePlanHint([hit.left.relation, hit.right.relation], routeHint);
|
|
891
|
+
const message = `[composition/ambiguous-corridor] ${severity === 'error' ? 'showcase' : 'standard'} ${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) — ${hint}.`;
|
|
892
|
+
onDiagnostic({
|
|
646
893
|
code: 'composition/ambiguous-corridor',
|
|
647
|
-
severity
|
|
894
|
+
severity,
|
|
648
895
|
message,
|
|
649
896
|
subject: relationshipSubject(diagramType, relationCollection, hit.left.relationIndex, hit.left.relation),
|
|
650
897
|
evidence: {
|
|
@@ -656,10 +903,10 @@ export function cleanAmbiguousCorridorProblems({
|
|
|
656
903
|
segmentIndex: hit.leftSegment,
|
|
657
904
|
otherSegmentIndex: hit.rightSegment,
|
|
658
905
|
},
|
|
659
|
-
supportedFixes: [
|
|
906
|
+
supportedFixes: [hint],
|
|
660
907
|
});
|
|
661
|
-
return message;
|
|
662
|
-
});
|
|
908
|
+
return severity === 'error' ? message : null;
|
|
909
|
+
}).filter(Boolean);
|
|
663
910
|
}
|
|
664
911
|
|
|
665
912
|
// Relationship paths may cross a structural frame, but they must not borrow a
|
|
@@ -701,7 +948,7 @@ export function collectBorderRuns({ routedRelations, frames }) {
|
|
|
701
948
|
frame,
|
|
702
949
|
frameIndex,
|
|
703
950
|
side: border.side,
|
|
704
|
-
segmentIndex:
|
|
951
|
+
segmentIndex: overlaps.reduce((min, overlap) => Math.min(min, overlap.segmentIndex), Infinity),
|
|
705
952
|
overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0),
|
|
706
953
|
overlapStart: longest.start,
|
|
707
954
|
overlapEnd: longest.end,
|
|
@@ -733,7 +980,8 @@ export function cleanBorderRunProblems({
|
|
|
733
980
|
const length = Math.round(hit.overlapLength * 10) / 10;
|
|
734
981
|
const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
735
982
|
const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
736
|
-
const
|
|
983
|
+
const hint = rePlanHint([relation], routeHint);
|
|
984
|
+
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}] — ${hint}.`;
|
|
737
985
|
recordDiagnostic({
|
|
738
986
|
code: 'composition/container-border-run',
|
|
739
987
|
severity: 'error',
|
|
@@ -749,7 +997,7 @@ export function cleanBorderRunProblems({
|
|
|
749
997
|
from: hit.overlapStart,
|
|
750
998
|
to: hit.overlapEnd,
|
|
751
999
|
},
|
|
752
|
-
supportedFixes: [
|
|
1000
|
+
supportedFixes: [hint],
|
|
753
1001
|
});
|
|
754
1002
|
return message;
|
|
755
1003
|
});
|
|
@@ -878,7 +1126,8 @@ export function cleanRouteRhythmProblems({
|
|
|
878
1126
|
const rule = hit.code === 'composition/micro-segment'
|
|
879
1127
|
? `is below the ${microSegmentPx}px micro-segment floor`
|
|
880
1128
|
: `is below the ${interiorSegmentPx}px interior-segment floor`;
|
|
881
|
-
const
|
|
1129
|
+
const hint = rePlanHint([relation], routeHint);
|
|
1130
|
+
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} — ${hint}.`;
|
|
882
1131
|
recordDiagnostic({
|
|
883
1132
|
code: hit.code,
|
|
884
1133
|
severity: 'error',
|
|
@@ -892,7 +1141,7 @@ export function cleanRouteRhythmProblems({
|
|
|
892
1141
|
from: hit.start,
|
|
893
1142
|
to: hit.end,
|
|
894
1143
|
},
|
|
895
|
-
supportedFixes: [
|
|
1144
|
+
supportedFixes: [hint],
|
|
896
1145
|
});
|
|
897
1146
|
return message;
|
|
898
1147
|
});
|
|
@@ -921,7 +1170,8 @@ export function cleanLabelRouteClearanceProblems({
|
|
|
921
1170
|
const clearance = Math.round(hit.clearance * 10) / 10;
|
|
922
1171
|
const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
923
1172
|
const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
924
|
-
const
|
|
1173
|
+
const hint = rePlanHint([hit.labelRelation, hit.otherRelation], routeHint);
|
|
1174
|
+
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) — ${hint}.`;
|
|
925
1175
|
recordDiagnostic({
|
|
926
1176
|
code: 'composition/label-route-clearance',
|
|
927
1177
|
severity: 'error',
|
|
@@ -937,7 +1187,113 @@ export function cleanLabelRouteClearanceProblems({
|
|
|
937
1187
|
from: hit.start,
|
|
938
1188
|
to: hit.end,
|
|
939
1189
|
},
|
|
940
|
-
supportedFixes: [
|
|
1190
|
+
supportedFixes: [hint],
|
|
1191
|
+
});
|
|
1192
|
+
return message;
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// How far [start, start + size) leaves [0, extent), per end of one axis. This
|
|
1197
|
+
// is the single definition of "inside the canvas" that both the containment
|
|
1198
|
+
// rule and the repair hints measure against; an unknown extent bounds nothing.
|
|
1199
|
+
function axisCanvasOverflow(start, size, extent, origin = 0) {
|
|
1200
|
+
if (!Number.isFinite(extent)) return { start: 0, end: 0 };
|
|
1201
|
+
return { start: origin - start, end: start + size - (origin + extent) };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// THE CANVAS CONTAINMENT RATIONALE (referenced from the other call sites).
|
|
1205
|
+
//
|
|
1206
|
+
// The SVG canvas clips whatever leaves the viewBox, so an edge label that
|
|
1207
|
+
// overhangs an edge ships as truncated text while every post-render check on
|
|
1208
|
+
// the emitted markup still passes: clipped text is still well-formed markup.
|
|
1209
|
+
// Renderers with a fixed canvas already bound their nodes, lanes, and legends
|
|
1210
|
+
// against it; edge label rects were the exception.
|
|
1211
|
+
//
|
|
1212
|
+
// Not applied to readable-v2 workflows: that compiler grows its canvas around
|
|
1213
|
+
// pinned label rects and rejects an authored viewBox that cannot hold the
|
|
1214
|
+
// result, so the only gap growth cannot close is the origin its own rule
|
|
1215
|
+
// already guards. Architecture's auto canvas grows the same way, which leaves
|
|
1216
|
+
// this rule reporting authored viewBoxes and the origin side.
|
|
1217
|
+
//
|
|
1218
|
+
// Showcase only: a standard document authored before the rule exists may
|
|
1219
|
+
// overhang by a few pixels, and failing it there would break compatibility
|
|
1220
|
+
// instead of repairing a diagram.
|
|
1221
|
+
export function collectLabelCanvasOverflow({ labels, viewBox, tolerance = 0.5 }) {
|
|
1222
|
+
// Renderers author origin-zero canvases (meta.viewBox is [width, height] by
|
|
1223
|
+
// schema), but `check` re-measures foreign artifacts whose SVG viewBox may
|
|
1224
|
+
// carry a legal non-zero min-x/min-y; those pass all four numbers.
|
|
1225
|
+
const box = asArray(viewBox);
|
|
1226
|
+
const [originX, originY, canvasWidth, canvasHeight] = box.length === 4 ? box : [0, 0, box[0], box[1]];
|
|
1227
|
+
if (!isFinitePoint(originX, originY, canvasWidth, canvasHeight)) return [];
|
|
1228
|
+
const hits = [];
|
|
1229
|
+
for (const [fallbackIndex, label] of asArray(labels).entries()) {
|
|
1230
|
+
const rect = label?.rect || label;
|
|
1231
|
+
if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height)) continue;
|
|
1232
|
+
// `check` parses foreign markup, so a malformed negative-size rect is
|
|
1233
|
+
// skipped like the other label collectors do — its flipped interval would
|
|
1234
|
+
// otherwise read as contained.
|
|
1235
|
+
if (rect.width < 0 || rect.height < 0) continue;
|
|
1236
|
+
const horizontal = axisCanvasOverflow(rect.x, rect.width, canvasWidth, originX);
|
|
1237
|
+
const vertical = axisCanvasOverflow(rect.y, rect.height, canvasHeight, originY);
|
|
1238
|
+
const overflow = {
|
|
1239
|
+
left: horizontal.start,
|
|
1240
|
+
right: horizontal.end,
|
|
1241
|
+
top: vertical.start,
|
|
1242
|
+
bottom: vertical.end,
|
|
1243
|
+
};
|
|
1244
|
+
const sides = Object.keys(overflow).filter((side) => overflow[side] > tolerance);
|
|
1245
|
+
if (!sides.length) continue;
|
|
1246
|
+
hits.push({
|
|
1247
|
+
label,
|
|
1248
|
+
relation: label?.relation,
|
|
1249
|
+
relationIndex: Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex,
|
|
1250
|
+
rect,
|
|
1251
|
+
viewBox: [canvasWidth, canvasHeight],
|
|
1252
|
+
viewBoxOrigin: [originX, originY],
|
|
1253
|
+
sides,
|
|
1254
|
+
overflowPx: Object.fromEntries(sides.map((side) => [side, Math.round(overflow[side] * 10) / 10])),
|
|
1255
|
+
tolerance,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
return hits;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export function describeLabelCanvasOverflow(hit) {
|
|
1262
|
+
return hit.sides.map((side) => `${side} edge by ${hit.overflowPx[side]}px`).join(' and ');
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export function cleanLabelCanvasContainmentProblems({
|
|
1266
|
+
labels,
|
|
1267
|
+
viewBox,
|
|
1268
|
+
diagramType,
|
|
1269
|
+
relationCollection,
|
|
1270
|
+
profile,
|
|
1271
|
+
profileIsAuthoritative = false,
|
|
1272
|
+
routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise enlarge meta.viewBox',
|
|
1273
|
+
// Label widths are estimated from the text, not measured, so sub-pixel
|
|
1274
|
+
// overhang is rounding noise rather than a visible truncation.
|
|
1275
|
+
tolerance = 0.5,
|
|
1276
|
+
}) {
|
|
1277
|
+
if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
|
|
1278
|
+
return collectLabelCanvasOverflow({ labels, viewBox, tolerance }).map((hit) => {
|
|
1279
|
+
const relation = hit.relation;
|
|
1280
|
+
const relationId = relation?.id ? ` id "${relation.id}"` : '';
|
|
1281
|
+
const labelText = hit.label?.label || relation?.label || '';
|
|
1282
|
+
const hint = rePlanHint([relation], routeHint);
|
|
1283
|
+
const message = `[composition/label-canvas-containment] showcase ${diagramType} label "${labelText}" on ${relationCollection}[${hit.relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}" extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}) — ${hint}.`;
|
|
1284
|
+
recordDiagnostic({
|
|
1285
|
+
code: 'composition/label-canvas-containment',
|
|
1286
|
+
severity: 'error',
|
|
1287
|
+
message,
|
|
1288
|
+
subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
|
|
1289
|
+
evidence: {
|
|
1290
|
+
label: labelText,
|
|
1291
|
+
labelRect: { x: hit.rect.x, y: hit.rect.y, width: hit.rect.width, height: hit.rect.height },
|
|
1292
|
+
viewBox: hit.viewBox,
|
|
1293
|
+
overflowPx: hit.overflowPx,
|
|
1294
|
+
tolerancePx: hit.tolerance,
|
|
1295
|
+
},
|
|
1296
|
+
supportedFixes: [hint],
|
|
941
1297
|
});
|
|
942
1298
|
return message;
|
|
943
1299
|
});
|
|
@@ -963,18 +1319,54 @@ function segmentPosition(index, segmentCount) {
|
|
|
963
1319
|
return 'interior';
|
|
964
1320
|
}
|
|
965
1321
|
|
|
1322
|
+
// Normalizing a route is pure, and the same route array is normalized again by
|
|
1323
|
+
// every predicate that inspects it. Keyed by the array itself, so a rebuilt
|
|
1324
|
+
// route simply gets a new entry. The result's outer array is frozen. Callers
|
|
1325
|
+
// must also treat input arrays and their coordinate pairs as immutable, since
|
|
1326
|
+
// edits in place cannot invalidate this identity-based cache.
|
|
1327
|
+
const NORMALIZED_ROUTE_POINTS = new WeakMap();
|
|
1328
|
+
|
|
1329
|
+
// Assembling a route from an anchor, corridor points and an anchor is the same
|
|
1330
|
+
// normalization without the intermediate array, and the result is seeded in the
|
|
1331
|
+
// cache so later predicates do not normalize it again.
|
|
1332
|
+
export function joinRoutePoints(start, via, end) {
|
|
1333
|
+
const normalized = [];
|
|
1334
|
+
const accept = (point) => {
|
|
1335
|
+
if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) return;
|
|
1336
|
+
const previous = normalized.at(-1);
|
|
1337
|
+
if (previous
|
|
1338
|
+
&& Math.abs(point[0] - previous[0]) <= 0.0001
|
|
1339
|
+
&& Math.abs(point[1] - previous[1]) <= 0.0001) return;
|
|
1340
|
+
while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
|
|
1341
|
+
normalized.push(point);
|
|
1342
|
+
};
|
|
1343
|
+
accept(start);
|
|
1344
|
+
for (const point of Array.isArray(via) ? via : []) accept(point);
|
|
1345
|
+
accept(end);
|
|
1346
|
+
Object.freeze(normalized);
|
|
1347
|
+
NORMALIZED_ROUTE_POINTS.set(normalized, normalized);
|
|
1348
|
+
return normalized;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
966
1351
|
export function normalizeRoutePoints(points) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const previous = deduped.at(-1);
|
|
971
|
-
if (!previous || Math.abs(point[0] - previous[0]) > 0.0001 || Math.abs(point[1] - previous[1]) > 0.0001) deduped.push(point);
|
|
1352
|
+
if (Array.isArray(points)) {
|
|
1353
|
+
const cached = NORMALIZED_ROUTE_POINTS.get(points);
|
|
1354
|
+
if (cached) return cached;
|
|
972
1355
|
}
|
|
973
1356
|
const normalized = [];
|
|
974
|
-
for (const point of
|
|
1357
|
+
for (const point of Array.isArray(points) ? points : []) {
|
|
1358
|
+
if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) continue;
|
|
1359
|
+
const previous = normalized.at(-1);
|
|
1360
|
+
if (previous
|
|
1361
|
+
&& Math.abs(point[0] - previous[0]) <= 0.0001
|
|
1362
|
+
&& Math.abs(point[1] - previous[1]) <= 0.0001) continue;
|
|
975
1363
|
while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
|
|
976
1364
|
normalized.push(point);
|
|
977
1365
|
}
|
|
1366
|
+
if (Array.isArray(points)) {
|
|
1367
|
+
Object.freeze(normalized);
|
|
1368
|
+
NORMALIZED_ROUTE_POINTS.set(points, normalized);
|
|
1369
|
+
}
|
|
978
1370
|
return normalized;
|
|
979
1371
|
}
|
|
980
1372
|
|
|
@@ -984,13 +1376,17 @@ function pointRectDistance(point, rect) {
|
|
|
984
1376
|
return Math.hypot(dx, dy);
|
|
985
1377
|
}
|
|
986
1378
|
|
|
987
|
-
function
|
|
1379
|
+
function pointSegmentDistanceXY(px, py, start, end) {
|
|
988
1380
|
const dx = end[0] - start[0];
|
|
989
1381
|
const dy = end[1] - start[1];
|
|
990
1382
|
const lengthSquared = dx * dx + dy * dy;
|
|
991
|
-
if (lengthSquared <= 0.0000001) return Math.hypot(
|
|
992
|
-
const
|
|
993
|
-
return Math.hypot(
|
|
1383
|
+
if (lengthSquared <= 0.0000001) return Math.hypot(px - start[0], py - start[1]);
|
|
1384
|
+
const t = Math.max(0, Math.min(1, ((px - start[0]) * dx + (py - start[1]) * dy) / lengthSquared));
|
|
1385
|
+
return Math.hypot(px - (start[0] + t * dx), py - (start[1] + t * dy));
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function pointSegmentDistance(point, start, end) {
|
|
1389
|
+
return pointSegmentDistanceXY(point[0], point[1], start, end);
|
|
994
1390
|
}
|
|
995
1391
|
|
|
996
1392
|
function collinearForward(a, b, c) {
|
|
@@ -998,7 +1394,7 @@ function collinearForward(a, b, c) {
|
|
|
998
1394
|
return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001;
|
|
999
1395
|
}
|
|
1000
1396
|
|
|
1001
|
-
function frameBorderSegments(frame) {
|
|
1397
|
+
export function frameBorderSegments(frame) {
|
|
1002
1398
|
if (!frame || typeof frame !== 'object') return [];
|
|
1003
1399
|
if (frame.shape === 'line') {
|
|
1004
1400
|
const start = frame.start || [frame.x1, frame.y1];
|
|
@@ -1062,7 +1458,7 @@ function collinearAxisOverlap(a, b, c, d) {
|
|
|
1062
1458
|
};
|
|
1063
1459
|
}
|
|
1064
1460
|
|
|
1065
|
-
function properSegmentIntersection(a, b, c, d) {
|
|
1461
|
+
export function properSegmentIntersection(a, b, c, d) {
|
|
1066
1462
|
const abC = crossProduct(a, b, c);
|
|
1067
1463
|
const abD = crossProduct(a, b, d);
|
|
1068
1464
|
const cdA = crossProduct(c, d, a);
|
|
@@ -1129,6 +1525,82 @@ export function anchor(rect, side) {
|
|
|
1129
1525
|
}
|
|
1130
1526
|
}
|
|
1131
1527
|
|
|
1528
|
+
export function portHasCornerClearance(rect, side, point) {
|
|
1529
|
+
if (side === 'left' || side === 'right') {
|
|
1530
|
+
const inset = Math.min(16, rect.height / 2);
|
|
1531
|
+
return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
|
|
1532
|
+
}
|
|
1533
|
+
if (side === 'top' || side === 'bottom') {
|
|
1534
|
+
const inset = Math.min(16, rect.width / 2);
|
|
1535
|
+
return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
|
|
1536
|
+
}
|
|
1537
|
+
return false;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
// Reuse the same bounded alignment for facing automatic ports; callers own
|
|
1542
|
+
// obstacle and occupied-port checks for their renderer. Spread slots stay fixed.
|
|
1543
|
+
export function alignAutomaticFacingPorts(conn, from, to, start, end, fromSide, toSide, ports, { accept } = {}) {
|
|
1544
|
+
const hasExplicitGeometry = (
|
|
1545
|
+
conn.via
|
|
1546
|
+
|| (conn.route && conn.route !== 'auto')
|
|
1547
|
+
|| conn.channelX !== undefined
|
|
1548
|
+
|| conn.channelY !== undefined
|
|
1549
|
+
|| conn.labelAt
|
|
1550
|
+
);
|
|
1551
|
+
const horizontallyFacing = (
|
|
1552
|
+
(fromSide === 'right' && toSide === 'left')
|
|
1553
|
+
|| (fromSide === 'left' && toSide === 'right')
|
|
1554
|
+
);
|
|
1555
|
+
const verticallyFacing = (
|
|
1556
|
+
(fromSide === 'bottom' && toSide === 'top')
|
|
1557
|
+
|| (fromSide === 'top' && toSide === 'bottom')
|
|
1558
|
+
);
|
|
1559
|
+
if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
|
|
1560
|
+
|
|
1561
|
+
const fromSpread = Boolean(ports?.from);
|
|
1562
|
+
const toSpread = Boolean(ports?.to);
|
|
1563
|
+
if (fromSpread && toSpread) return { start, end };
|
|
1564
|
+
const hasExplicitSides = (
|
|
1565
|
+
(conn.fromSide && conn.fromSide !== 'auto')
|
|
1566
|
+
|| (conn.toSide && conn.toSide !== 'auto')
|
|
1567
|
+
);
|
|
1568
|
+
if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
|
|
1569
|
+
|
|
1570
|
+
const alignmentDelta = horizontallyFacing
|
|
1571
|
+
? Math.abs(start[1] - end[1])
|
|
1572
|
+
: Math.abs(start[0] - end[0]);
|
|
1573
|
+
if (alignmentDelta >= 16) return { start, end };
|
|
1574
|
+
|
|
1575
|
+
// Keep the shared endpoint's distinct spread slot and move only the
|
|
1576
|
+
// relationship's unshared endpoint onto that axis. With no spread endpoint,
|
|
1577
|
+
// retain the existing least-movement choice between the two facing sides.
|
|
1578
|
+
// If both endpoints are shared, preserve the outside bridge so no competing
|
|
1579
|
+
// port is silently collapsed.
|
|
1580
|
+
const alignEndToStart = horizontallyFacing
|
|
1581
|
+
? { start, end: [end[0], start[1]] }
|
|
1582
|
+
: { start, end: [start[0], end[1]] };
|
|
1583
|
+
const alignStartToEnd = horizontallyFacing
|
|
1584
|
+
? { start: [start[0], end[1]], end }
|
|
1585
|
+
: { start: [end[0], start[1]], end };
|
|
1586
|
+
const candidates = fromSpread
|
|
1587
|
+
? [alignEndToStart]
|
|
1588
|
+
: toSpread
|
|
1589
|
+
? [alignStartToEnd]
|
|
1590
|
+
: [alignEndToStart, alignStartToEnd];
|
|
1591
|
+
for (const candidate of candidates) {
|
|
1592
|
+
const points = [candidate.start, candidate.end];
|
|
1593
|
+
if (portHasCornerClearance(from, fromSide, candidate.start)
|
|
1594
|
+
&& portHasCornerClearance(to, toSide, candidate.end)
|
|
1595
|
+
&& routeHonorsEndpointSides(points, fromSide, toSide)
|
|
1596
|
+
&& (typeof accept !== 'function' || accept(points))) {
|
|
1597
|
+
return candidate;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return { start, end };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
|
|
1132
1604
|
const PORT_OUTWARD_VECTOR = {
|
|
1133
1605
|
left: [-1, 0],
|
|
1134
1606
|
right: [1, 0],
|
|
@@ -1154,48 +1626,65 @@ export function automaticPortRhythmBridge(
|
|
|
1154
1626
|
const toVector = PORT_OUTWARD_VECTOR[toSide];
|
|
1155
1627
|
if (!fromVector || !toVector) return null;
|
|
1156
1628
|
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
start[
|
|
1160
|
-
]
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1629
|
+
const opposedFacingGap = (
|
|
1630
|
+
(fromSide === 'right' && toSide === 'left' && end[0] > start[0])
|
|
1631
|
+
|| (fromSide === 'left' && toSide === 'right' && start[0] > end[0])
|
|
1632
|
+
) ? Math.abs(end[0] - start[0]) : (
|
|
1633
|
+
(fromSide === 'bottom' && toSide === 'top' && end[1] > start[1])
|
|
1634
|
+
|| (fromSide === 'top' && toSide === 'bottom' && start[1] > end[1])
|
|
1635
|
+
) ? Math.abs(end[1] - start[1]) : null;
|
|
1636
|
+
const boundedStubPx = opposedFacingGap != null
|
|
1637
|
+
&& opposedFacingGap - endpointStubPx * 2 < interiorSegmentPx
|
|
1638
|
+
? Math.max(8, Math.min(endpointStubPx, (opposedFacingGap - interiorSegmentPx) / 2))
|
|
1639
|
+
: null;
|
|
1640
|
+
const stubDistances = [endpointStubPx];
|
|
1641
|
+
if (boundedStubPx != null && boundedStubPx >= 8 && boundedStubPx !== endpointStubPx) {
|
|
1642
|
+
stubDistances.push(boundedStubPx);
|
|
1643
|
+
}
|
|
1165
1644
|
const candidates = [];
|
|
1166
1645
|
const verticalSides = new Set(['top', 'bottom']);
|
|
1167
1646
|
const horizontalSides = new Set(['left', 'right']);
|
|
1168
1647
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
[
|
|
1180
|
-
|
|
1181
|
-
end,
|
|
1182
|
-
|
|
1648
|
+
for (const stubPx of stubDistances) {
|
|
1649
|
+
const startStub = [
|
|
1650
|
+
start[0] + fromVector[0] * stubPx,
|
|
1651
|
+
start[1] + fromVector[1] * stubPx,
|
|
1652
|
+
];
|
|
1653
|
+
const endStub = [
|
|
1654
|
+
end[0] + toVector[0] * stubPx,
|
|
1655
|
+
end[1] + toVector[1] * stubPx,
|
|
1656
|
+
];
|
|
1657
|
+
if (verticalSides.has(fromSide) && verticalSides.has(toSide)
|
|
1658
|
+
&& Math.abs(start[0] - end[0]) < interiorSegmentPx) {
|
|
1659
|
+
for (const channelX of [
|
|
1660
|
+
Math.max(start[0], end[0]) + interiorSegmentPx,
|
|
1661
|
+
Math.min(start[0], end[0]) - interiorSegmentPx,
|
|
1662
|
+
]) {
|
|
1663
|
+
candidates.push([
|
|
1664
|
+
start,
|
|
1665
|
+
startStub,
|
|
1666
|
+
[channelX, startStub[1]],
|
|
1667
|
+
[channelX, endStub[1]],
|
|
1668
|
+
endStub,
|
|
1669
|
+
end,
|
|
1670
|
+
]);
|
|
1671
|
+
}
|
|
1183
1672
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1673
|
+
if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
|
|
1674
|
+
&& Math.abs(start[1] - end[1]) < interiorSegmentPx) {
|
|
1675
|
+
for (const channelY of [
|
|
1676
|
+
Math.max(start[1], end[1]) + interiorSegmentPx,
|
|
1677
|
+
Math.min(start[1], end[1]) - interiorSegmentPx,
|
|
1678
|
+
]) {
|
|
1679
|
+
candidates.push([
|
|
1680
|
+
start,
|
|
1681
|
+
startStub,
|
|
1682
|
+
[startStub[0], channelY],
|
|
1683
|
+
[endStub[0], channelY],
|
|
1684
|
+
endStub,
|
|
1685
|
+
end,
|
|
1686
|
+
]);
|
|
1687
|
+
}
|
|
1199
1688
|
}
|
|
1200
1689
|
}
|
|
1201
1690
|
|
|
@@ -1211,7 +1700,7 @@ export function automaticPortRhythmBridge(
|
|
|
1211
1700
|
// Keep conservative auto-routed fan-out/fan-in relationships visually
|
|
1212
1701
|
// distinct without changing authored route controls. The returned map only
|
|
1213
1702
|
// contains endpoints that belong to a shared automatic midpoint anchor.
|
|
1214
|
-
export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor } = {}) {
|
|
1703
|
+
export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor, spacingFor } = {}) {
|
|
1215
1704
|
const groups = new Map();
|
|
1216
1705
|
const spread = new Map();
|
|
1217
1706
|
|
|
@@ -1257,8 +1746,22 @@ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing
|
|
|
1257
1746
|
const spacing = Math.min(maxSpacing, usable / (items.length - 1));
|
|
1258
1747
|
if (!(spacing > 0)) continue;
|
|
1259
1748
|
|
|
1749
|
+
// Width-aware callers reserve the whole group together. Moving a single
|
|
1750
|
+
// port around the legacy 14px slots can wrongly report a full side while
|
|
1751
|
+
// its still-unrouted neighbours could have fitted farther apart.
|
|
1752
|
+
let offsets;
|
|
1753
|
+
if (spacingFor) {
|
|
1754
|
+
const gaps = items.slice(1).map((item, index) => Math.max(maxSpacing,
|
|
1755
|
+
spacingFor(items[index].relation, item.relation)));
|
|
1756
|
+
const span = gaps.reduce((sum, gap) => sum + gap, 0);
|
|
1757
|
+
if (span <= usable && gaps.some((gap) => gap > maxSpacing)) {
|
|
1758
|
+
let offset = -span / 2;
|
|
1759
|
+
offsets = [offset, ...gaps.map((gap) => (offset += gap))];
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1260
1763
|
for (const [index, item] of items.entries()) {
|
|
1261
|
-
const offset = (index - (items.length - 1) / 2) * spacing;
|
|
1764
|
+
const offset = offsets?.[index] ?? (index - (items.length - 1) / 2) * spacing;
|
|
1262
1765
|
const point = anchor(item.rect, item.side);
|
|
1263
1766
|
if (verticalSide) point[1] += offset;
|
|
1264
1767
|
else point[0] += offset;
|
|
@@ -1271,14 +1774,37 @@ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing
|
|
|
1271
1774
|
return spread;
|
|
1272
1775
|
}
|
|
1273
1776
|
|
|
1777
|
+
// A centre delta on the horizontal axis does not by itself mean the route
|
|
1778
|
+
// leaves sideways: a hub above an offset spoke has both a horizontal and a
|
|
1779
|
+
// larger vertical delta, and the router draws the vertical dogleg. Compare the
|
|
1780
|
+
// deltas so the inferred side matches the axis the route actually uses. Equal
|
|
1781
|
+
// deltas select horizontal sides; dx === 0 preserves the previous vertical
|
|
1782
|
+
// result, while a vertical-dominant nonzero dx selects vertical sides.
|
|
1274
1783
|
export function defaultFromSide(from, to) {
|
|
1784
|
+
const dx = to.cx - from.cx;
|
|
1785
|
+
const dy = to.cy - from.cy;
|
|
1786
|
+
if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'left' : 'right';
|
|
1787
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
export function defaultToSide(from, to) {
|
|
1791
|
+
const dx = to.cx - from.cx;
|
|
1792
|
+
const dy = to.cy - from.cy;
|
|
1793
|
+
if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'right' : 'left';
|
|
1794
|
+
return dy > 0 ? 'top' : 'bottom';
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Non-architecture renderers retain their established horizontal-first
|
|
1798
|
+
// endpoint contract. Architecture opts into dominant-axis inference above so
|
|
1799
|
+
// a vertical hub-and-spoke route can use perpendicular automatic ports.
|
|
1800
|
+
export function legacyDefaultFromSide(from, to) {
|
|
1275
1801
|
if (to.cx < from.cx) return 'left';
|
|
1276
1802
|
if (to.cx > from.cx) return 'right';
|
|
1277
1803
|
if (to.cy > from.cy) return 'bottom';
|
|
1278
1804
|
return 'top';
|
|
1279
1805
|
}
|
|
1280
1806
|
|
|
1281
|
-
export function
|
|
1807
|
+
export function legacyDefaultToSide(from, to) {
|
|
1282
1808
|
if (to.cx < from.cx) return 'right';
|
|
1283
1809
|
if (to.cx > from.cx) return 'left';
|
|
1284
1810
|
if (to.cy > from.cy) return 'top';
|
|
@@ -1300,6 +1826,16 @@ export function routePointsValue(points) {
|
|
|
1300
1826
|
.join(';');
|
|
1301
1827
|
}
|
|
1302
1828
|
|
|
1829
|
+
// Only a direct, explicitly authored diagonal needs an artifact-check exception.
|
|
1830
|
+
// Nonempty via takes precedence; empty via adds no intermediate geometry.
|
|
1831
|
+
export function authoredStraightRouteAttrs(relation, points) {
|
|
1832
|
+
if (relation.route !== 'straight' || relation.via?.length || points.length !== 2) return '';
|
|
1833
|
+
const [start, end] = points;
|
|
1834
|
+
return Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
|
|
1835
|
+
? ' data-composition-route="straight"'
|
|
1836
|
+
: '';
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1303
1839
|
export function roundedPath(points, radius) {
|
|
1304
1840
|
if (points.length < 3 || radius <= 0) {
|
|
1305
1841
|
return polylinePath(points);
|
|
@@ -1370,38 +1906,138 @@ export const arrowClassMap = {
|
|
|
1370
1906
|
dashed: ['a-dashed', 'arrowhead-dashed']
|
|
1371
1907
|
};
|
|
1372
1908
|
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1375
|
-
|
|
1909
|
+
// Structural phase/group accents retain their existing semantic colors.
|
|
1910
|
+
export function variantAccent(variant) {
|
|
1911
|
+
return variant === 'security' ? 't-security'
|
|
1912
|
+
: variant === 'emphasis' ? 't-backend'
|
|
1913
|
+
: variant === 'dashed' ? 't-messagebus' : 't-muted';
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// Relationship labels use the same theme token as their path. Keep this map
|
|
1917
|
+
// edge-specific: node-kind text colors only coincide with some path colors in
|
|
1918
|
+
// the classic preset and must not define the relationship's visual meaning.
|
|
1919
|
+
export function edgeLabelAccent(variant) {
|
|
1376
1920
|
return variant === 'security'
|
|
1377
|
-
? 't-security'
|
|
1921
|
+
? 't-edge-security'
|
|
1378
1922
|
: variant === 'emphasis'
|
|
1379
|
-
? 't-
|
|
1923
|
+
? 't-edge-emphasis'
|
|
1380
1924
|
: variant === 'dashed'
|
|
1381
|
-
? dashed
|
|
1382
|
-
: 't-
|
|
1925
|
+
? 't-edge-dashed'
|
|
1926
|
+
: 't-edge-default';
|
|
1383
1927
|
}
|
|
1384
1928
|
|
|
1385
1929
|
export function formatRect(r) {
|
|
1386
1930
|
return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`;
|
|
1387
1931
|
}
|
|
1388
1932
|
|
|
1389
|
-
function
|
|
1390
|
-
|
|
1391
|
-
return v >= 0 ? `+${v}` : String(v);
|
|
1933
|
+
function formatValue(n) {
|
|
1934
|
+
return String(Math.round(n));
|
|
1392
1935
|
}
|
|
1393
1936
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1937
|
+
// A label anchor is the text origin, so keeping a suggestion on the canvas
|
|
1938
|
+
// means moving the whole rect: offset is `rect - anchor` on that axis.
|
|
1939
|
+
function anchorFitsCanvas(anchorValue, offset, size, extent) {
|
|
1940
|
+
const overflow = axisCanvasOverflow(anchorValue + offset, size, extent);
|
|
1941
|
+
return overflow.start <= 0 && overflow.end <= 0;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Suggestions are emitted as integers, so the real-valued bounds are tightened
|
|
1945
|
+
// inward with ceil/floor first: every integer between them keeps the whole rect
|
|
1946
|
+
// on the canvas, which a bound like 616.7 would not. Returns null when the rect
|
|
1947
|
+
// is wider than the canvas and no anchor can contain it.
|
|
1948
|
+
function clampAnchorToCanvas(anchorValue, offset, size, extent) {
|
|
1949
|
+
const rounded = Math.round(anchorValue);
|
|
1950
|
+
if (!Number.isFinite(extent)) return rounded;
|
|
1951
|
+
const min = Math.ceil(-offset);
|
|
1952
|
+
const max = Math.floor(extent - size - offset);
|
|
1953
|
+
return max < min ? null : Math.min(Math.max(rounded, min), max);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Actionable hint when an edge label rect hits a node/component box (#7).
|
|
1958
|
+
* Every suggested value is a replacement for the authored field, not an
|
|
1959
|
+
* increment, and has to survive being applied to the document:
|
|
1960
|
+
* - the absolute form is nudged along x so the rect stays on the canvas, and a
|
|
1961
|
+
* vertical placement that cannot fit is dropped rather than clamped, since
|
|
1962
|
+
* clamping it back would push the label onto the obstacle it must clear;
|
|
1963
|
+
* - the relative form is offered only when labelDx/labelDy actually move this
|
|
1964
|
+
* label (labelPoint returns an authored labelAt as-is) and only when the
|
|
1965
|
+
* replacements, computed from the document's own labelDx/labelDy, land the
|
|
1966
|
+
* rect inside the canvas.
|
|
1967
|
+
* The authored values therefore come from `labelRect.relation`, the authored
|
|
1968
|
+
* relationship the renderer already attaches to every label record.
|
|
1969
|
+
* `obstacles` is every box the callers' own overlap loop tests (defaults to
|
|
1970
|
+
* the named obstacle alone): a placement that merely traded the named obstacle
|
|
1971
|
+
* for its neighbor would fail the same rule again when applied.
|
|
1972
|
+
*/
|
|
1973
|
+
export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component', viewBox, obstacles) {
|
|
1974
|
+
// Renderer canvases are origin-zero [width, height] by schema, but read a
|
|
1975
|
+
// 4-number [min-x, min-y, width, height] the same way the containment
|
|
1976
|
+
// collector does — degraded no-ajv runs must not clamp against the origin
|
|
1977
|
+
// pair as if it were the canvas size.
|
|
1978
|
+
const box = asArray(viewBox);
|
|
1979
|
+
const [canvasWidth, canvasHeight] = box.length === 4 ? box.slice(2) : box;
|
|
1980
|
+
const xOffset = labelRect.x - lx;
|
|
1981
|
+
const yOffset = labelRect.y - ly;
|
|
1982
|
+
const anchorX = clampAnchorToCanvas(lx, xOffset, labelRect.width, canvasWidth);
|
|
1983
|
+
// The placement anchors are derived from the label's own rect, not from the
|
|
1984
|
+
// obstacle alone: an obstacle-only "above" anchor assumes the 14px
|
|
1985
|
+
// single-line rect and lands a 27px two-line rect (dataflow classification,
|
|
1986
|
+
// lifecycle note) back on the obstacle it must clear. The overlap filter
|
|
1987
|
+
// uses the callers' own detection call, so a surviving hint cannot re-raise
|
|
1988
|
+
// the problem it repairs.
|
|
1989
|
+
const blockers = obstacles ? [...obstacles] : [obstacle];
|
|
1990
|
+
const placements = [
|
|
1991
|
+
{ name: 'below', y: Math.round(obstacle.y + obstacle.height + 4 - yOffset) },
|
|
1992
|
+
{ name: 'above', y: Math.round(obstacle.y - 4 - labelRect.height - yOffset) },
|
|
1993
|
+
].filter(({ y }) => anchorX !== null
|
|
1994
|
+
&& anchorFitsCanvas(y, yOffset, labelRect.height, canvasHeight)
|
|
1995
|
+
&& blockers.every((blocker) => !rectsOverlap(
|
|
1996
|
+
{ x: anchorX + xOffset, y: y + yOffset, width: labelRect.width, height: labelRect.height },
|
|
1997
|
+
blocker,
|
|
1998
|
+
-2,
|
|
1999
|
+
)));
|
|
2000
|
+
const lines = [
|
|
1401
2001
|
` label rect: ${formatRect(labelRect)}`,
|
|
1402
2002
|
` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`,
|
|
1403
|
-
|
|
1404
|
-
|
|
2003
|
+
];
|
|
2004
|
+
if (!placements.length) {
|
|
2005
|
+
// Only the vertical slots beside the named obstacle were tried, so an
|
|
2006
|
+
// unclaimed spot elsewhere may still exist — never assert that none does.
|
|
2007
|
+
lines.push(anchorX === null
|
|
2008
|
+
? ` Suggested fix: the ${Math.round(labelRect.width)}px label rect cannot fit the ${canvasWidth}x${canvasHeight} viewBox at any anchor — shorten the label or enlarge meta.viewBox`
|
|
2009
|
+
: ` Suggested fix: no placement directly above or below "${obstacle.id}" stays clear inside the ${canvasWidth}x${canvasHeight} viewBox — move the label to an open area with labelAt, shorten the label, move the ${obstacleKind}, or enlarge meta.viewBox`);
|
|
2010
|
+
return lines.join('\n');
|
|
2011
|
+
}
|
|
2012
|
+
const authored = labelRect.relation || {};
|
|
2013
|
+
const authoredDx = Number.isFinite(authored.labelDx) ? authored.labelDx : 0;
|
|
2014
|
+
const authoredDy = Number.isFinite(authored.labelDy) ? authored.labelDy : 0;
|
|
2015
|
+
const relativeHint = (y) => {
|
|
2016
|
+
if (Array.isArray(authored.labelAt)) return null;
|
|
2017
|
+
// Both replacements are measured from the automatic label point that the
|
|
2018
|
+
// authored offsets are applied to, and against the unrounded anchor: a
|
|
2019
|
+
// rounded base would shift the applied label by up to a pixel.
|
|
2020
|
+
const dx = Math.round(authoredDx + anchorX - lx);
|
|
2021
|
+
const dy = Math.round(authoredDy + y - ly);
|
|
2022
|
+
// labelDx is authored as a number, so an authored fraction must not read as
|
|
2023
|
+
// a needed nudge once the replacement is rounded to an integer.
|
|
2024
|
+
const movesX = Math.abs(dx - authoredDx) >= 0.5;
|
|
2025
|
+
const landsAtX = lx - authoredDx + (movesX ? dx : authoredDx);
|
|
2026
|
+
const landsAtY = ly - authoredDy + dy;
|
|
2027
|
+
if (!anchorFitsCanvas(landsAtX, xOffset, labelRect.width, canvasWidth)) return null;
|
|
2028
|
+
if (!anchorFitsCanvas(landsAtY, yOffset, labelRect.height, canvasHeight)) return null;
|
|
2029
|
+
return movesX
|
|
2030
|
+
? `set labelDx ${formatValue(dx)} with labelDy ${formatValue(dy)}`
|
|
2031
|
+
: `set labelDy ${formatValue(dy)}`;
|
|
2032
|
+
};
|
|
2033
|
+
const hint = placements
|
|
2034
|
+
.map(({ name, y }) => {
|
|
2035
|
+
const relative = relativeHint(y);
|
|
2036
|
+
return `set labelAt [${anchorX}, ${y}]${relative ? ` or ${relative}` : ''} (${name})`;
|
|
2037
|
+
})
|
|
2038
|
+
.join('; or ');
|
|
2039
|
+
lines.push(` Suggested fix: ${hint}`);
|
|
2040
|
+
return lines.join('\n');
|
|
1405
2041
|
}
|
|
1406
2042
|
|
|
1407
2043
|
/** Hint when two edge labels collide. */
|
|
@@ -1412,12 +2048,26 @@ export function suggestLabelPairFix(a, b) {
|
|
|
1412
2048
|
].join('\n');
|
|
1413
2049
|
}
|
|
1414
2050
|
|
|
2051
|
+
/**
|
|
2052
|
+
* The two placements that separate b from a, in the order the prose hint
|
|
2053
|
+
* offers them. Shared so the text hint and its structured repair cannot
|
|
2054
|
+
* offer different coordinates.
|
|
2055
|
+
*/
|
|
2056
|
+
export function componentSeparationOptions(a, b, minGap = 8) {
|
|
2057
|
+
// Ceil, not round: on fractional geometry a rounded right/below coordinate
|
|
2058
|
+
// can land a fraction of a pixel short of minGap, and the advised position
|
|
2059
|
+
// would never clear the diagnostic that offered it.
|
|
2060
|
+
return [
|
|
2061
|
+
{ pos: [Math.ceil(a.x + a.width + minGap), Math.round(b.y)], relation: `right of "${a.id}"` },
|
|
2062
|
+
{ pos: [Math.round(b.x), Math.ceil(a.y + a.height + minGap)], relation: 'below' },
|
|
2063
|
+
];
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1415
2066
|
/** Hint when two components/nodes are too close. */
|
|
1416
2067
|
export function suggestComponentSeparation(a, b, minGap = 8) {
|
|
1417
|
-
const
|
|
1418
|
-
const belowY = Math.round(a.y + a.height + minGap);
|
|
2068
|
+
const [right, below] = componentSeparationOptions(a, b, minGap);
|
|
1419
2069
|
return [
|
|
1420
2070
|
` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`,
|
|
1421
|
-
` Suggested fix: move "${b.id}" pos to [${
|
|
2071
|
+
` Suggested fix: move "${b.id}" pos to [${right.pos[0]}, ${right.pos[1]}] (${right.relation}) or [${below.pos[0]}, ${below.pos[1]}] (${below.relation})`,
|
|
1422
2072
|
].join('\n');
|
|
1423
2073
|
}
|