@stacksjs/defaults 0.75.360 → 0.75.362
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 +4 -4
- package/ai/skills/stacks-payments/SKILL.md +28 -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/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { recordDiagnostic } from './diagnostics.mjs';
|
|
2
|
+
import {
|
|
3
|
+
asArray,
|
|
4
|
+
isFinitePoint,
|
|
5
|
+
normalizeRoutePoints,
|
|
6
|
+
properSegmentIntersection,
|
|
7
|
+
segmentIntersectsRect,
|
|
8
|
+
} from './geometry.mjs';
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = Object.freeze({
|
|
11
|
+
clearance: 2,
|
|
12
|
+
minimumDetourRatio: 2.5,
|
|
13
|
+
minimumExcessLengthPx: 200,
|
|
14
|
+
minimumEmptyExcursionPx: 96,
|
|
15
|
+
maximumObstacleCount: 80,
|
|
16
|
+
sharedCorridorMinimumPx: 32,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const OUTWARD = Object.freeze({
|
|
20
|
+
left: [-1, 0],
|
|
21
|
+
right: [1, 0],
|
|
22
|
+
top: [0, -1],
|
|
23
|
+
bottom: [0, 1],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function rounded(value) {
|
|
27
|
+
return Math.round(value * 100) / 100;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pointKey(point) {
|
|
31
|
+
return `${point[0]}\u0000${point[1]}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class MinHeap {
|
|
35
|
+
constructor() {
|
|
36
|
+
this.entries = [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
push(key, distance) {
|
|
40
|
+
const entry = { key, distance };
|
|
41
|
+
this.entries.push(entry);
|
|
42
|
+
let index = this.entries.length - 1;
|
|
43
|
+
while (index > 0) {
|
|
44
|
+
const parent = Math.floor((index - 1) / 2);
|
|
45
|
+
if (this.entries[parent].distance <= distance) break;
|
|
46
|
+
this.entries[index] = this.entries[parent];
|
|
47
|
+
index = parent;
|
|
48
|
+
}
|
|
49
|
+
this.entries[index] = entry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pop() {
|
|
53
|
+
if (!this.entries.length) return null;
|
|
54
|
+
const first = this.entries[0];
|
|
55
|
+
const last = this.entries.pop();
|
|
56
|
+
if (!this.entries.length) return first;
|
|
57
|
+
let index = 0;
|
|
58
|
+
while (true) {
|
|
59
|
+
const left = index * 2 + 1;
|
|
60
|
+
const right = left + 1;
|
|
61
|
+
if (left >= this.entries.length) break;
|
|
62
|
+
const child = right < this.entries.length
|
|
63
|
+
&& this.entries[right].distance < this.entries[left].distance ? right : left;
|
|
64
|
+
if (this.entries[child].distance >= last.distance) break;
|
|
65
|
+
this.entries[index] = this.entries[child];
|
|
66
|
+
index = child;
|
|
67
|
+
}
|
|
68
|
+
this.entries[index] = last;
|
|
69
|
+
return first;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function orthogonalLength(points) {
|
|
74
|
+
let total = 0;
|
|
75
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
76
|
+
const [x1, y1] = points[index];
|
|
77
|
+
const [x2, y2] = points[index + 1];
|
|
78
|
+
if (x1 !== x2 && y1 !== y2) return null;
|
|
79
|
+
total += Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
80
|
+
}
|
|
81
|
+
return total;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inferredSide(points, endpoint) {
|
|
85
|
+
if (points.length < 2) return null;
|
|
86
|
+
const start = endpoint === 'source' ? points[0] : points.at(-2);
|
|
87
|
+
const end = endpoint === 'source' ? points[1] : points.at(-1);
|
|
88
|
+
const dx = end[0] - start[0];
|
|
89
|
+
const dy = end[1] - start[1];
|
|
90
|
+
if (endpoint === 'source') {
|
|
91
|
+
if (dx > 0 && dy === 0) return 'right';
|
|
92
|
+
if (dx < 0 && dy === 0) return 'left';
|
|
93
|
+
if (dy > 0 && dx === 0) return 'bottom';
|
|
94
|
+
if (dy < 0 && dx === 0) return 'top';
|
|
95
|
+
} else {
|
|
96
|
+
if (dx > 0 && dy === 0) return 'left';
|
|
97
|
+
if (dx < 0 && dy === 0) return 'right';
|
|
98
|
+
if (dy > 0 && dx === 0) return 'top';
|
|
99
|
+
if (dy < 0 && dx === 0) return 'bottom';
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function moveOutward(point, side, distance) {
|
|
105
|
+
const [dx, dy] = OUTWARD[side] || [0, 0];
|
|
106
|
+
return [point[0] + dx * distance, point[1] + dy * distance];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function expandedRect(rect, clearance) {
|
|
110
|
+
return {
|
|
111
|
+
id: rect.id,
|
|
112
|
+
x: rect.x - clearance,
|
|
113
|
+
y: rect.y - clearance,
|
|
114
|
+
width: rect.width + clearance * 2,
|
|
115
|
+
height: rect.height + clearance * 2,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function boundsForRects(rects) {
|
|
120
|
+
const usable = [...rects].filter((rect) => (
|
|
121
|
+
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
|
|
122
|
+
&& rect.width >= 0 && rect.height >= 0
|
|
123
|
+
));
|
|
124
|
+
if (!usable.length) return null;
|
|
125
|
+
const left = Math.min(...usable.map((rect) => rect.x));
|
|
126
|
+
const top = Math.min(...usable.map((rect) => rect.y));
|
|
127
|
+
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
|
|
128
|
+
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
|
|
129
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function boundsForPoints(points) {
|
|
133
|
+
if (!points.length) return null;
|
|
134
|
+
const xs = points.map(([x]) => x);
|
|
135
|
+
const ys = points.map(([, y]) => y);
|
|
136
|
+
const left = Math.min(...xs);
|
|
137
|
+
const top = Math.min(...ys);
|
|
138
|
+
const right = Math.max(...xs);
|
|
139
|
+
const bottom = Math.max(...ys);
|
|
140
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function outsideExcursion(routeBounds, contentBounds) {
|
|
144
|
+
if (!routeBounds || !contentBounds) return null;
|
|
145
|
+
const sides = {
|
|
146
|
+
left: Math.max(0, contentBounds.left - routeBounds.left),
|
|
147
|
+
top: Math.max(0, contentBounds.top - routeBounds.top),
|
|
148
|
+
right: Math.max(0, routeBounds.right - contentBounds.right),
|
|
149
|
+
bottom: Math.max(0, routeBounds.bottom - contentBounds.bottom),
|
|
150
|
+
};
|
|
151
|
+
return { ...sides, maximum: Math.max(...Object.values(sides)) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pointDistanceFromRect(point, rect) {
|
|
155
|
+
const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
|
|
156
|
+
const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
|
|
157
|
+
return dx + dy;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function emptyControlPointClearance(points, contentRects) {
|
|
161
|
+
const controls = points.slice(1, -1);
|
|
162
|
+
const rects = [...contentRects].filter((rect) => (
|
|
163
|
+
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
|
|
164
|
+
&& rect.width >= 0 && rect.height >= 0
|
|
165
|
+
));
|
|
166
|
+
if (!controls.length || !rects.length) return null;
|
|
167
|
+
const distances = controls.map((point) => Math.min(
|
|
168
|
+
...rects.map((rect) => pointDistanceFromRect(point, rect)),
|
|
169
|
+
));
|
|
170
|
+
const maximum = Math.max(...distances);
|
|
171
|
+
return { maximum, point: controls[distances.indexOf(maximum)] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pointBlocked(point, obstacles) {
|
|
175
|
+
return obstacles.some((rect) => (
|
|
176
|
+
point[0] >= rect.x && point[0] <= rect.x + rect.width
|
|
177
|
+
&& point[1] >= rect.y && point[1] <= rect.y + rect.height
|
|
178
|
+
));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Prune only against the candidate segment, never the route's initial bounds:
|
|
182
|
+
// a later detour can still encounter any obstacle in the complete inventory.
|
|
183
|
+
// Keep epsilon-near bounds for the existing exact predicates to decide.
|
|
184
|
+
function segmentBlocked(start, end, obstacles) {
|
|
185
|
+
const minX = Math.min(start[0], end[0]);
|
|
186
|
+
const maxX = Math.max(start[0], end[0]);
|
|
187
|
+
const minY = Math.min(start[1], end[1]);
|
|
188
|
+
const maxY = Math.max(start[1], end[1]);
|
|
189
|
+
return obstacles.some((rect) => {
|
|
190
|
+
if (maxX < Math.min(rect.x, rect.x + rect.width) - 0.0001
|
|
191
|
+
|| minX > Math.max(rect.x, rect.x + rect.width) + 0.0001
|
|
192
|
+
|| maxY < Math.min(rect.y, rect.y + rect.height) - 0.0001
|
|
193
|
+
|| minY > Math.max(rect.y, rect.y + rect.height) + 0.0001) return false;
|
|
194
|
+
return segmentIntersectsRect({ start, end }, rect);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function segmentConflictsWithAvoided(start, end, avoidedSegments, minimumOverlapPx, allowCrossings) {
|
|
199
|
+
const minX = Math.min(start[0], end[0]);
|
|
200
|
+
const maxX = Math.max(start[0], end[0]);
|
|
201
|
+
const minY = Math.min(start[1], end[1]);
|
|
202
|
+
const maxY = Math.max(start[1], end[1]);
|
|
203
|
+
return avoidedSegments.some((segment) => {
|
|
204
|
+
// A zero overlap threshold also rejects disjoint segments (0 >= 0).
|
|
205
|
+
if (minimumOverlapPx > 0 && (maxX < Math.min(segment.start[0], segment.end[0]) - 0.0001
|
|
206
|
+
|| minX > Math.max(segment.start[0], segment.end[0]) + 0.0001
|
|
207
|
+
|| maxY < Math.min(segment.start[1], segment.end[1]) - 0.0001
|
|
208
|
+
|| minY > Math.max(segment.start[1], segment.end[1]) + 0.0001)) return false;
|
|
209
|
+
return (!allowCrossings && (properSegmentIntersection(start, end, segment.start, segment.end)
|
|
210
|
+
|| orthogonalTouchOnAvoidedInterior(start, end, segment.start, segment.end)))
|
|
211
|
+
|| collinearOverlap(start, end, segment.start, segment.end) >= minimumOverlapPx;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function orthogonalTouchOnAvoidedInterior(start, end, avoidedStart, avoidedEnd) {
|
|
216
|
+
const epsilon = 0.0001;
|
|
217
|
+
const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
|
|
218
|
+
const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
|
|
219
|
+
const avoidedHorizontal = Math.abs(avoidedStart[1] - avoidedEnd[1]) <= epsilon;
|
|
220
|
+
const avoidedVertical = Math.abs(avoidedStart[0] - avoidedEnd[0]) <= epsilon;
|
|
221
|
+
if (candidateHorizontal && avoidedVertical) {
|
|
222
|
+
const x = avoidedStart[0];
|
|
223
|
+
const y = start[1];
|
|
224
|
+
return x >= Math.min(start[0], end[0]) - epsilon
|
|
225
|
+
&& x <= Math.max(start[0], end[0]) + epsilon
|
|
226
|
+
&& y > Math.min(avoidedStart[1], avoidedEnd[1]) + epsilon
|
|
227
|
+
&& y < Math.max(avoidedStart[1], avoidedEnd[1]) - epsilon;
|
|
228
|
+
}
|
|
229
|
+
if (candidateVertical && avoidedHorizontal) {
|
|
230
|
+
const x = start[0];
|
|
231
|
+
const y = avoidedStart[1];
|
|
232
|
+
return y >= Math.min(start[1], end[1]) - epsilon
|
|
233
|
+
&& y <= Math.max(start[1], end[1]) + epsilon
|
|
234
|
+
&& x > Math.min(avoidedStart[0], avoidedEnd[0]) + epsilon
|
|
235
|
+
&& x < Math.max(avoidedStart[0], avoidedEnd[0]) - epsilon;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pointOnSegmentInterior(point, start, end) {
|
|
241
|
+
const epsilon = 0.0001;
|
|
242
|
+
const cross = (end[0] - start[0]) * (point[1] - start[1])
|
|
243
|
+
- (end[1] - start[1]) * (point[0] - start[0]);
|
|
244
|
+
if (Math.abs(cross) > epsilon) return false;
|
|
245
|
+
const dot = (point[0] - start[0]) * (point[0] - end[0])
|
|
246
|
+
+ (point[1] - start[1]) * (point[1] - end[1]);
|
|
247
|
+
return dot < -epsilon;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function pointOnAvoidedInterior(point, avoidedSegments) {
|
|
251
|
+
return avoidedSegments.some((segment) => (
|
|
252
|
+
pointOnSegmentInterior(point, segment.start, segment.end)
|
|
253
|
+
));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function writeGridMetrics(metrics, patch) {
|
|
257
|
+
if (!metrics || typeof metrics !== 'object') return;
|
|
258
|
+
Object.assign(metrics, patch);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function shortestOrthogonalGridRoute({
|
|
262
|
+
start,
|
|
263
|
+
end,
|
|
264
|
+
points,
|
|
265
|
+
obstacles,
|
|
266
|
+
fromSide,
|
|
267
|
+
toSide,
|
|
268
|
+
clearance,
|
|
269
|
+
maximumObstacleCount,
|
|
270
|
+
endpointStubPx = clearance + 2,
|
|
271
|
+
maximumGridNodes = Infinity,
|
|
272
|
+
avoidedSegments = [],
|
|
273
|
+
allowAvoidedCrossings = false,
|
|
274
|
+
minimumAvoidedOverlapPx = 8,
|
|
275
|
+
routeSeparationPx = 8,
|
|
276
|
+
minimumSegmentPx = 8,
|
|
277
|
+
borderSegments = [],
|
|
278
|
+
bendPenaltyPx = 0,
|
|
279
|
+
accept = () => true,
|
|
280
|
+
metrics,
|
|
281
|
+
}) {
|
|
282
|
+
writeGridMetrics(metrics, {
|
|
283
|
+
status: 'initializing',
|
|
284
|
+
maximumGridNodes,
|
|
285
|
+
obstacleCount: 0,
|
|
286
|
+
avoidedSegmentCount: 0,
|
|
287
|
+
coordinateCount: 0,
|
|
288
|
+
candidateNodeCount: 0,
|
|
289
|
+
usableNodeCount: 0,
|
|
290
|
+
graphEdgeCount: 0,
|
|
291
|
+
visitedNodeCount: 0,
|
|
292
|
+
attemptCount: 0,
|
|
293
|
+
});
|
|
294
|
+
if (!OUTWARD[fromSide] || !OUTWARD[toSide]) {
|
|
295
|
+
writeGridMetrics(metrics, { status: 'unsupported-endpoint-side' });
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const startStub = moveOutward(start, fromSide, endpointStubPx);
|
|
299
|
+
const endStub = moveOutward(end, toSide, endpointStubPx);
|
|
300
|
+
// The graph may legally leave the initial endpoint bounds to find a clear
|
|
301
|
+
// corridor. Keep every bounded obstacle and occupied relationship visible
|
|
302
|
+
// to that search; filtering them against the initial box lets a detour walk
|
|
303
|
+
// straight through geometry that only becomes relevant after it leaves the
|
|
304
|
+
// box. The explicit obstacle/node budgets below keep this deterministic.
|
|
305
|
+
const expanded = [...obstacles]
|
|
306
|
+
.filter((rect) => rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height))
|
|
307
|
+
.map((rect) => expandedRect(rect, clearance));
|
|
308
|
+
const relevantAvoidedSegments = [...avoidedSegments]
|
|
309
|
+
.filter((segment) => segment?.start && segment?.end);
|
|
310
|
+
// Frame borders may be crossed perpendicularly but never borrowed as a
|
|
311
|
+
// corridor: the composition gate rejects any collinear run along them.
|
|
312
|
+
const relevantBorderSegments = [...borderSegments]
|
|
313
|
+
.filter((segment) => segment?.start && segment?.end);
|
|
314
|
+
writeGridMetrics(metrics, {
|
|
315
|
+
obstacleCount: expanded.length,
|
|
316
|
+
avoidedSegmentCount: relevantAvoidedSegments.length,
|
|
317
|
+
});
|
|
318
|
+
if (expanded.length > maximumObstacleCount) {
|
|
319
|
+
writeGridMetrics(metrics, { status: 'obstacle-budget-exceeded' });
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const xs = new Set([startStub[0], endStub[0], ...points.map(([x]) => x)]);
|
|
324
|
+
const ys = new Set([startStub[1], endStub[1], ...points.map(([, y]) => y)]);
|
|
325
|
+
for (const rect of expanded) {
|
|
326
|
+
xs.add(rect.x - 1);
|
|
327
|
+
xs.add(rect.x + rect.width + 1);
|
|
328
|
+
ys.add(rect.y - 1);
|
|
329
|
+
ys.add(rect.y + rect.height + 1);
|
|
330
|
+
}
|
|
331
|
+
for (const segment of relevantAvoidedSegments) {
|
|
332
|
+
const [segmentStart, segmentEnd] = [segment.start, segment.end];
|
|
333
|
+
xs.add(segmentStart[0]);
|
|
334
|
+
xs.add(segmentEnd[0]);
|
|
335
|
+
ys.add(segmentStart[1]);
|
|
336
|
+
ys.add(segmentEnd[1]);
|
|
337
|
+
if (Math.abs(segmentStart[0] - segmentEnd[0]) <= 0.0001) {
|
|
338
|
+
xs.add(segmentStart[0] - routeSeparationPx);
|
|
339
|
+
xs.add(segmentStart[0] + routeSeparationPx);
|
|
340
|
+
}
|
|
341
|
+
if (Math.abs(segmentStart[1] - segmentEnd[1]) <= 0.0001) {
|
|
342
|
+
ys.add(segmentStart[1] - routeSeparationPx);
|
|
343
|
+
ys.add(segmentStart[1] + routeSeparationPx);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function addBorderCoordinates(distance) {
|
|
347
|
+
for (const segment of relevantBorderSegments) {
|
|
348
|
+
if (Math.abs(segment.start[0] - segment.end[0]) <= 0.0001) {
|
|
349
|
+
xs.add(segment.start[0] - distance);
|
|
350
|
+
xs.add(segment.start[0] + distance);
|
|
351
|
+
}
|
|
352
|
+
if (Math.abs(segment.start[1] - segment.end[1]) <= 0.0001) {
|
|
353
|
+
ys.add(segment.start[1] - distance);
|
|
354
|
+
ys.add(segment.start[1] + distance);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
addBorderCoordinates(routeSeparationPx);
|
|
359
|
+
// Grid lines closer than a readable segment would let the search emit a
|
|
360
|
+
// micro jog between two obstacle edges; keep the endpoint stubs and coalesce
|
|
361
|
+
// the rest so every turn the route can take is at least one segment long.
|
|
362
|
+
const coalesce = (values, keep, fillGaps) => {
|
|
363
|
+
const sorted = values.sort((a, b) => a - b);
|
|
364
|
+
if (!fillGaps) return sorted.filter((value, index) => (
|
|
365
|
+
index === 0 || keep.has(value) || value - sorted[index - 1] >= minimumSegmentPx
|
|
366
|
+
));
|
|
367
|
+
// A dense chain of discarded coordinates can erase a usable corridor.
|
|
368
|
+
// Compare with the last retained line while reserving readable spacing
|
|
369
|
+
// around the endpoint lines that must be kept.
|
|
370
|
+
return sorted.reduce((retained, value) => {
|
|
371
|
+
if (keep.has(value) || ((!retained.length || value - retained.at(-1) >= minimumSegmentPx)
|
|
372
|
+
&& [...keep].every(endpoint => Math.abs(value - endpoint) >= minimumSegmentPx))) retained.push(value);
|
|
373
|
+
return retained;
|
|
374
|
+
}, []);
|
|
375
|
+
};
|
|
376
|
+
// Keep accepted routes stable. A failed or caller-rejected search can use
|
|
377
|
+
// the denser coordinate set, within the same original node budget.
|
|
378
|
+
let remainingGridNodes = maximumGridNodes;
|
|
379
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
380
|
+
// A pinned endpoint stub may land on a frame border. Nearby candidates
|
|
381
|
+
// closer than the minimum segment disappear during coalescing. Recovery
|
|
382
|
+
// adds a full-segment corridor without changing the attempt or node budget.
|
|
383
|
+
if (attempt > 0 && routeSeparationPx < minimumSegmentPx) addBorderCoordinates(minimumSegmentPx);
|
|
384
|
+
const orderedX = coalesce([...xs], new Set([startStub[0], endStub[0]]), attempt > 0);
|
|
385
|
+
const orderedY = coalesce([...ys], new Set([startStub[1], endStub[1]]), attempt > 0);
|
|
386
|
+
const candidateNodeCount = orderedX.length * orderedY.length;
|
|
387
|
+
writeGridMetrics(metrics, {
|
|
388
|
+
coordinateCount: (metrics?.coordinateCount || 0) + orderedX.length + orderedY.length,
|
|
389
|
+
candidateNodeCount: (metrics?.candidateNodeCount || 0) + candidateNodeCount,
|
|
390
|
+
attemptCount: attempt + 1,
|
|
391
|
+
});
|
|
392
|
+
if (candidateNodeCount > remainingGridNodes) {
|
|
393
|
+
writeGridMetrics(metrics, { status: 'node-budget-exceeded' });
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
remainingGridNodes -= candidateNodeCount;
|
|
397
|
+
const nodes = new Map();
|
|
398
|
+
for (const x of orderedX) {
|
|
399
|
+
for (const y of orderedY) {
|
|
400
|
+
const point = [x, y];
|
|
401
|
+
if (!pointBlocked(point, expanded)
|
|
402
|
+
&& (allowAvoidedCrossings || !pointOnAvoidedInterior(point, relevantAvoidedSegments))) {
|
|
403
|
+
nodes.set(pointKey(point), point);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
writeGridMetrics(metrics, { usableNodeCount: (metrics?.usableNodeCount || 0) + nodes.size });
|
|
408
|
+
if (!nodes.has(pointKey(startStub)) || !nodes.has(pointKey(endStub))) {
|
|
409
|
+
writeGridMetrics(metrics, { status: 'endpoint-blocked' });
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const adjacency = new Map([...nodes.keys()].map((key) => [key, []]));
|
|
414
|
+
let graphEdgeCount = 0;
|
|
415
|
+
const connectLine = (line, axis) => {
|
|
416
|
+
for (let index = 0; index < line.length - 1; index += 1) {
|
|
417
|
+
const left = line[index];
|
|
418
|
+
const right = line[index + 1];
|
|
419
|
+
if (segmentBlocked(left, right, expanded)) continue;
|
|
420
|
+
if (segmentConflictsWithAvoided(
|
|
421
|
+
left,
|
|
422
|
+
right,
|
|
423
|
+
relevantAvoidedSegments,
|
|
424
|
+
minimumAvoidedOverlapPx,
|
|
425
|
+
allowAvoidedCrossings,
|
|
426
|
+
)) continue;
|
|
427
|
+
if (relevantBorderSegments.some((segment) => (
|
|
428
|
+
collinearOverlap(left, right, segment.start, segment.end) > 0.0001
|
|
429
|
+
))) continue;
|
|
430
|
+
const distance = Math.abs(right[0] - left[0]) + Math.abs(right[1] - left[1]);
|
|
431
|
+
const leftKey = pointKey(left);
|
|
432
|
+
const rightKey = pointKey(right);
|
|
433
|
+
adjacency.get(leftKey).push([rightKey, distance, axis === 'h' ? 'R' : 'D']);
|
|
434
|
+
adjacency.get(rightKey).push([leftKey, distance, axis === 'h' ? 'L' : 'U']);
|
|
435
|
+
graphEdgeCount += 1;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
for (const y of orderedY) {
|
|
439
|
+
connectLine(orderedX.map((x) => nodes.get(pointKey([x, y]))).filter(Boolean), 'h');
|
|
440
|
+
}
|
|
441
|
+
for (const x of orderedX) {
|
|
442
|
+
connectLine(orderedY.map((y) => nodes.get(pointKey([x, y]))).filter(Boolean), 'v');
|
|
443
|
+
}
|
|
444
|
+
writeGridMetrics(metrics, { graphEdgeCount: (metrics?.graphEdgeCount || 0) + graphEdgeCount });
|
|
445
|
+
|
|
446
|
+
// The search state carries the incoming direction so a turn can cost extra
|
|
447
|
+
// and a reversal is never taken: the pure shortest path hugs every obstacle
|
|
448
|
+
// corner with a staircase of short jogs, while a bend-penalised one takes
|
|
449
|
+
// the same corridor in a few long strokes. The first stub already leaves
|
|
450
|
+
// the endpoint along its side and the last one arrives along the end side.
|
|
451
|
+
const directionOf = ([dx, dy]) => (dx > 0 ? 'R' : dx < 0 ? 'L' : dy > 0 ? 'D' : 'U');
|
|
452
|
+
const opposite = { R: 'L', L: 'R', D: 'U', U: 'D' };
|
|
453
|
+
const stateKey = (key, direction) => `${key}|${direction}`;
|
|
454
|
+
const sourceAxis = directionOf(OUTWARD[fromSide]);
|
|
455
|
+
const targetAxis = opposite[directionOf(OUTWARD[toSide])];
|
|
456
|
+
const source = pointKey(startStub);
|
|
457
|
+
const target = pointKey(endStub);
|
|
458
|
+
const sourceState = stateKey(source, sourceAxis);
|
|
459
|
+
const distances = new Map([[sourceState, 0]]);
|
|
460
|
+
const previous = new Map();
|
|
461
|
+
const queue = new MinHeap();
|
|
462
|
+
queue.push(sourceState, 0);
|
|
463
|
+
let visitedNodeCount = 0;
|
|
464
|
+
let targetState = null;
|
|
465
|
+
while (queue.entries.length) {
|
|
466
|
+
const next = queue.pop();
|
|
467
|
+
const current = next.key;
|
|
468
|
+
const currentDistance = next.distance;
|
|
469
|
+
if (currentDistance !== distances.get(current)) continue;
|
|
470
|
+
visitedNodeCount += 1;
|
|
471
|
+
const [currentNode, currentAxis] = current.split('|');
|
|
472
|
+
if (currentNode === target) {
|
|
473
|
+
// The end stub is outside the graph: never reverse back onto it.
|
|
474
|
+
if (currentAxis === opposite[targetAxis]) continue;
|
|
475
|
+
// Arriving on the wrong axis costs one final turn onto the end stub.
|
|
476
|
+
const arrival = currentDistance + (currentAxis === targetAxis ? 0 : bendPenaltyPx);
|
|
477
|
+
if (targetState == null || arrival < targetState.distance) {
|
|
478
|
+
targetState = { key: current, distance: arrival };
|
|
479
|
+
}
|
|
480
|
+
if (currentAxis === targetAxis || bendPenaltyPx === 0) break;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (targetState && currentDistance >= targetState.distance) break;
|
|
484
|
+
for (const [neighbor, weight, axis] of adjacency.get(currentNode) || []) {
|
|
485
|
+
if (axis === opposite[currentAxis]) continue;
|
|
486
|
+
const candidate = currentDistance + weight + (axis === currentAxis ? 0 : bendPenaltyPx);
|
|
487
|
+
const neighborState = stateKey(neighbor, axis);
|
|
488
|
+
if (candidate >= (distances.get(neighborState) ?? Infinity)) continue;
|
|
489
|
+
distances.set(neighborState, candidate);
|
|
490
|
+
previous.set(neighborState, current);
|
|
491
|
+
queue.push(neighborState, candidate);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
writeGridMetrics(metrics, { visitedNodeCount: (metrics?.visitedNodeCount || 0) + visitedNodeCount });
|
|
495
|
+
if (!targetState) {
|
|
496
|
+
writeGridMetrics(metrics, { status: 'no-route' });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const reversed = [];
|
|
500
|
+
for (let key = targetState.key; key; key = previous.get(key)) {
|
|
501
|
+
reversed.push(nodes.get(key.split('|')[0]));
|
|
502
|
+
if (key === sourceState) break;
|
|
503
|
+
}
|
|
504
|
+
if (pointKey(reversed.at(-1)) !== source) {
|
|
505
|
+
writeGridMetrics(metrics, { status: 'broken-predecessor-chain' });
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
const shortestPoints = normalizeRoutePoints([start, ...reversed.reverse(), end]);
|
|
509
|
+
if (!accept(shortestPoints)) {
|
|
510
|
+
writeGridMetrics(metrics, { status: 'rejected' });
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
writeGridMetrics(metrics, { status: 'routed' });
|
|
514
|
+
return {
|
|
515
|
+
points: shortestPoints,
|
|
516
|
+
length: orthogonalLength(shortestPoints),
|
|
517
|
+
obstacleCount: expanded.length,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function collinearOverlap(leftStart, leftEnd, rightStart, rightEnd) {
|
|
524
|
+
if (leftStart[0] === leftEnd[0] && rightStart[0] === rightEnd[0]
|
|
525
|
+
&& leftStart[0] === rightStart[0]) {
|
|
526
|
+
return Math.max(0, Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
|
|
527
|
+
- Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
|
|
528
|
+
}
|
|
529
|
+
if (leftStart[1] === leftEnd[1] && rightStart[1] === rightEnd[1]
|
|
530
|
+
&& leftStart[1] === rightStart[1]) {
|
|
531
|
+
return Math.max(0, Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
|
|
532
|
+
- Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
|
|
533
|
+
}
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function segmentOutsideContent(start, end, contentBounds) {
|
|
538
|
+
if (!contentBounds) return false;
|
|
539
|
+
const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
|
|
540
|
+
return midpoint[0] < contentBounds.left || midpoint[0] > contentBounds.right
|
|
541
|
+
|| midpoint[1] < contentBounds.top || midpoint[1] > contentBounds.bottom;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function sharesOuterCorridor({ relation, relations, pathFor, points, contentBounds, minimumOverlap }) {
|
|
545
|
+
for (const other of asArray(relations)) {
|
|
546
|
+
if (!other || other === relation) continue;
|
|
547
|
+
const related = relation.from === other.from || relation.from === other.to
|
|
548
|
+
|| relation.to === other.from || relation.to === other.to;
|
|
549
|
+
if (!related) continue;
|
|
550
|
+
const otherPoints = normalizeRoutePoints(pathFor(other)?.points || []);
|
|
551
|
+
for (let left = 0; left < points.length - 1; left += 1) {
|
|
552
|
+
if (!segmentOutsideContent(points[left], points[left + 1], contentBounds)) continue;
|
|
553
|
+
for (let right = 0; right < otherPoints.length - 1; right += 1) {
|
|
554
|
+
if (collinearOverlap(points[left], points[left + 1], otherPoints[right], otherPoints[right + 1]) >= minimumOverlap) {
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
|
|
564
|
+
return {
|
|
565
|
+
diagramType,
|
|
566
|
+
collection: relationCollection,
|
|
567
|
+
index: relationIndex,
|
|
568
|
+
...(relation.id ? { id: relation.id } : {}),
|
|
569
|
+
from: relation.from,
|
|
570
|
+
to: relation.to,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Reject conspicuous authored detours without penalizing routes whose length is
|
|
576
|
+
* explained by opaque-node avoidance or a related shared outer corridor.
|
|
577
|
+
*/
|
|
578
|
+
export function cleanRouteDetourProblems({
|
|
579
|
+
relations,
|
|
580
|
+
obstacles,
|
|
581
|
+
contentRects = obstacles,
|
|
582
|
+
endpointIds,
|
|
583
|
+
pathFor,
|
|
584
|
+
fromSideFor,
|
|
585
|
+
toSideFor,
|
|
586
|
+
diagramType,
|
|
587
|
+
relationCollection,
|
|
588
|
+
profile,
|
|
589
|
+
thresholds = {},
|
|
590
|
+
}) {
|
|
591
|
+
if (profile !== 'showcase') return [];
|
|
592
|
+
const policy = { ...DEFAULTS, ...thresholds };
|
|
593
|
+
const obstacleList = [...obstacles];
|
|
594
|
+
const contentBounds = boundsForRects(contentRects);
|
|
595
|
+
const problems = [];
|
|
596
|
+
for (const [relationIndex, relation] of asArray(relations).entries()) {
|
|
597
|
+
if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
|
|
598
|
+
if (!Array.isArray(relation.via) || relation.via.length === 0) continue;
|
|
599
|
+
const points = normalizeRoutePoints(pathFor(relation)?.points || []);
|
|
600
|
+
if (points.length < 3 || !points.every((point) => Array.isArray(point) && isFinitePoint(...point))) continue;
|
|
601
|
+
const actualLength = orthogonalLength(points);
|
|
602
|
+
if (!Number.isFinite(actualLength)) continue;
|
|
603
|
+
const start = points[0];
|
|
604
|
+
const end = points.at(-1);
|
|
605
|
+
const manhattan = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
|
|
606
|
+
if (actualLength < manhattan * policy.minimumDetourRatio
|
|
607
|
+
|| actualLength - manhattan < policy.minimumExcessLengthPx) continue;
|
|
608
|
+
const routeBounds = boundsForPoints(points);
|
|
609
|
+
const excursion = outsideExcursion(routeBounds, contentBounds);
|
|
610
|
+
const emptyClearance = emptyControlPointClearance(points, obstacleList);
|
|
611
|
+
if (Math.max(excursion?.maximum || 0, emptyClearance?.maximum || 0)
|
|
612
|
+
< policy.minimumEmptyExcursionPx) continue;
|
|
613
|
+
if (sharesOuterCorridor({
|
|
614
|
+
relation,
|
|
615
|
+
relations,
|
|
616
|
+
pathFor,
|
|
617
|
+
points,
|
|
618
|
+
contentBounds,
|
|
619
|
+
minimumOverlap: policy.sharedCorridorMinimumPx,
|
|
620
|
+
})) continue;
|
|
621
|
+
|
|
622
|
+
const fromSide = fromSideFor?.(relation) || inferredSide(points, 'source');
|
|
623
|
+
const toSide = toSideFor?.(relation) || inferredSide(points, 'target');
|
|
624
|
+
const shortest = shortestOrthogonalGridRoute({
|
|
625
|
+
start,
|
|
626
|
+
end,
|
|
627
|
+
points,
|
|
628
|
+
obstacles: obstacleList,
|
|
629
|
+
fromSide,
|
|
630
|
+
toSide,
|
|
631
|
+
clearance: policy.clearance,
|
|
632
|
+
maximumObstacleCount: policy.maximumObstacleCount,
|
|
633
|
+
});
|
|
634
|
+
if (!shortest || !Number.isFinite(shortest.length) || shortest.length <= 0) continue;
|
|
635
|
+
const detourRatio = actualLength / shortest.length;
|
|
636
|
+
const excessLength = actualLength - shortest.length;
|
|
637
|
+
if (detourRatio < policy.minimumDetourRatio || excessLength < policy.minimumExcessLengthPx) continue;
|
|
638
|
+
|
|
639
|
+
const relationId = relation.id ? ` id "${relation.id}"` : '';
|
|
640
|
+
const message = `[composition/excessive-route-detour] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" travels ${Math.round(actualLength)}px, ${rounded(detourRatio)}x the ${Math.round(shortest.length)}px shortest obstacle-clearing orthogonal route, and reaches ${Math.round(excursion.maximum)}px beyond the content bounds — remove the distant via corridor or move it close to the connected content.`;
|
|
641
|
+
const supportedFix = 'remove the distant via points and retry automatic routing, or keep the endpoint sides and move the via corridor near the connected nodes while preserving labels and direction';
|
|
642
|
+
recordDiagnostic({
|
|
643
|
+
code: 'composition/excessive-route-detour',
|
|
644
|
+
severity: 'error',
|
|
645
|
+
message,
|
|
646
|
+
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
|
|
647
|
+
evidence: {
|
|
648
|
+
points,
|
|
649
|
+
actualLengthPx: rounded(actualLength),
|
|
650
|
+
shortestLegalPoints: shortest.points,
|
|
651
|
+
shortestLegalLengthPx: rounded(shortest.length),
|
|
652
|
+
detourRatio: rounded(detourRatio),
|
|
653
|
+
excessLengthPx: rounded(excessLength),
|
|
654
|
+
routeBounds,
|
|
655
|
+
contentBounds,
|
|
656
|
+
emptyExcursionPx: excursion,
|
|
657
|
+
emptyControlPointClearancePx: emptyClearance,
|
|
658
|
+
obstacleCount: shortest.obstacleCount,
|
|
659
|
+
thresholds: {
|
|
660
|
+
minimumDetourRatio: policy.minimumDetourRatio,
|
|
661
|
+
minimumExcessLengthPx: policy.minimumExcessLengthPx,
|
|
662
|
+
minimumEmptyExcursionPx: policy.minimumEmptyExcursionPx,
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
supportedFixes: [supportedFix],
|
|
666
|
+
});
|
|
667
|
+
problems.push(message);
|
|
668
|
+
}
|
|
669
|
+
return problems;
|
|
670
|
+
}
|