dsh-archify-projects 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +204 -2
  2. package/cordis.patch.yml +99 -0
  3. package/dist/client.js +2261 -0
  4. package/dist/client.js.map +1 -0
  5. package/docs/CONTAINER.md +197 -0
  6. package/docs/DESIGN.md +905 -0
  7. package/docs/HANDOVER.md +391 -0
  8. package/docs/PUBLISH.md +107 -0
  9. package/icon.svg +12 -0
  10. package/lib/host/archify-skill.js +116 -0
  11. package/lib/host/archify-skill.js.map +1 -0
  12. package/lib/host/config.js +95 -0
  13. package/lib/host/config.js.map +1 -0
  14. package/lib/host/context.js +28 -0
  15. package/lib/host/context.js.map +1 -0
  16. package/lib/host/git.js +185 -0
  17. package/lib/host/git.js.map +1 -0
  18. package/lib/host/http.js +184 -0
  19. package/lib/host/http.js.map +1 -0
  20. package/lib/host/index.js +90 -0
  21. package/lib/host/index.js.map +1 -0
  22. package/lib/host/policy.js +124 -0
  23. package/lib/host/policy.js.map +1 -0
  24. package/lib/host/routes.js +667 -0
  25. package/lib/host/routes.js.map +1 -0
  26. package/lib/host/runner.js +913 -0
  27. package/lib/host/runner.js.map +1 -0
  28. package/lib/host/store.js +370 -0
  29. package/lib/host/store.js.map +1 -0
  30. package/lib/host/workspace.js +65 -0
  31. package/lib/host/workspace.js.map +1 -0
  32. package/lib/shared/format.js +138 -0
  33. package/lib/shared/format.js.map +1 -0
  34. package/lib/shared/prompt.js +257 -0
  35. package/lib/shared/prompt.js.map +1 -0
  36. package/lib/shared/protocol.js +31 -0
  37. package/lib/shared/protocol.js.map +1 -0
  38. package/lib/shared/types.js +173 -0
  39. package/lib/shared/types.js.map +1 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +98 -4
  43. package/skills/archify/LICENSE +22 -0
  44. package/skills/archify/SKILL.md +109 -0
  45. package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
  46. package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
  47. package/skills/archify/assets/template.html +13709 -0
  48. package/skills/archify/bin/archify.mjs +6936 -0
  49. package/skills/archify/bin/delivery-update.mjs +91 -0
  50. package/skills/archify/bin/finalize.mjs +895 -0
  51. package/skills/archify/bin/open-artifact.mjs +146 -0
  52. package/skills/archify/bin/preview.mjs +969 -0
  53. package/skills/archify/bin/recover-output.mjs +41 -0
  54. package/skills/archify/bin/visual-check.mjs +2667 -0
  55. package/skills/archify/brand-marks/README.md +31 -0
  56. package/skills/archify/brand-marks/catalog.json +131 -0
  57. package/skills/archify/delta/architecture-delta.mjs +1313 -0
  58. package/skills/archify/examples/agent-run.lifecycle.json +55 -0
  59. package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
  60. package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
  61. package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
  62. package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
  63. package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
  64. package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
  65. package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
  66. package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
  67. package/skills/archify/examples/event-stream.dataflow.json +52 -0
  68. package/skills/archify/examples/holiday-planning.workflow.json +143 -0
  69. package/skills/archify/examples/incident-response.workflow.json +59 -0
  70. package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
  71. package/skills/archify/examples/locales/de.json +425 -0
  72. package/skills/archify/examples/locales/es.json +440 -0
  73. package/skills/archify/examples/locales/fr.json +425 -0
  74. package/skills/archify/examples/locales/fr.partial.json +22 -0
  75. package/skills/archify/examples/locales/it.json +425 -0
  76. package/skills/archify/examples/locales/ja.json +425 -0
  77. package/skills/archify/examples/locales/ko.json +440 -0
  78. package/skills/archify/examples/locales/pt.json +425 -0
  79. package/skills/archify/examples/locales/ru.json +425 -0
  80. package/skills/archify/examples/product-analytics.dataflow.json +71 -0
  81. package/skills/archify/examples/production-deployment.architecture.json +51 -0
  82. package/skills/archify/examples/release-delivery.workflow.json +57 -0
  83. package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
  84. package/skills/archify/examples/web-app-rendered.html +13808 -0
  85. package/skills/archify/examples/web-app.architecture.json +41 -0
  86. package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
  87. package/skills/archify/migrations/workflow-v2.mjs +280 -0
  88. package/skills/archify/package.json +17 -0
  89. package/skills/archify/recipes/scenarios.mjs +430 -0
  90. package/skills/archify/references/architecture-layout-repair.md +18 -0
  91. package/skills/archify/references/authoring-contract.md +419 -0
  92. package/skills/archify/references/authoring-defaults.md +41 -0
  93. package/skills/archify/references/brand-marks.md +82 -0
  94. package/skills/archify/references/delivery-contract.md +588 -0
  95. package/skills/archify/references/repository-authoring.md +105 -0
  96. package/skills/archify/references/update-awareness.md +12 -0
  97. package/skills/archify/references/viewer-runtime.md +35 -0
  98. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  99. package/skills/archify/renderers/architecture/labels.mjs +215 -0
  100. package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
  101. package/skills/archify/renderers/architecture/routing.mjs +1157 -0
  102. package/skills/archify/renderers/dataflow/README.md +105 -0
  103. package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
  104. package/skills/archify/renderers/lifecycle/README.md +171 -0
  105. package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
  106. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
  107. package/skills/archify/renderers/sequence/README.md +129 -0
  108. package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
  109. package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
  110. package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
  111. package/skills/archify/renderers/shared/cli.mjs +451 -0
  112. package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
  113. package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
  114. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  115. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  116. package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
  117. package/skills/archify/renderers/shared/geometry.mjs +1941 -0
  118. package/skills/archify/renderers/shared/i18n.mjs +603 -0
  119. package/skills/archify/renderers/shared/layout-report.mjs +42 -0
  120. package/skills/archify/renderers/shared/legend.mjs +217 -0
  121. package/skills/archify/renderers/shared/output-path.mjs +440 -0
  122. package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
  123. package/skills/archify/renderers/shared/portable-path.mjs +408 -0
  124. package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
  125. package/skills/archify/renderers/shared/repository-location.mjs +59 -0
  126. package/skills/archify/renderers/shared/route-quality.mjs +614 -0
  127. package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
  128. package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
  129. package/skills/archify/renderers/shared/text-fit.mjs +105 -0
  130. package/skills/archify/renderers/shared/utils.mjs +254 -0
  131. package/skills/archify/renderers/shared/validator.mjs +86 -0
  132. package/skills/archify/renderers/workflow/README.md +301 -0
  133. package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
  134. package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
  135. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  136. package/skills/archify/schemas/README.md +256 -0
  137. package/skills/archify/schemas/architecture.schema.json +152 -0
  138. package/skills/archify/schemas/common.schema.json +181 -0
  139. package/skills/archify/schemas/dataflow.schema.json +254 -0
  140. package/skills/archify/schemas/lifecycle.schema.json +280 -0
  141. package/skills/archify/schemas/sequence.schema.json +234 -0
  142. package/skills/archify/schemas/workflow.schema.json +439 -0
  143. package/skills/archify/scripts/check-render-output.mjs +1388 -0
  144. package/skills/archify/scripts/check-update.mjs +1710 -0
  145. package/skills/archify/scripts/delivery-update-child.mjs +23 -0
  146. package/skills/archify/scripts/render-examples.mjs +26 -0
  147. package/skills/archify/scripts/update-contract.mjs +182 -0
  148. package/skills/archify/skill-release.json +10 -0
@@ -0,0 +1,105 @@
1
+ # Repository-backed architecture authoring
2
+
3
+ Use this reference when a diagram must explain a real repository. The source is
4
+ the authority for responsibilities, calls, boundaries, and persistence. The
5
+ diagram is complete when the requested meaning is covered and every asserted
6
+ fact has supporting source evidence.
7
+
8
+ ## Explore on demand
9
+
10
+ 1. **Freeze identity.** From the target repository, record `git rev-parse
11
+ HEAD`, `git remote get-url origin`, and `git status --short`. Remove HTTP(S)
12
+ userinfo (including usernames, passwords, and tokens) before recording the
13
+ origin or placing it in the candidate. Preserve its transport, port, path and
14
+ `.git` suffix; do not rewrite an internal SSH origin as HTTPS. Pin the credential-free URL and
15
+ forty-character revision in `meta.repository`. Use `link_mode: "local-only"`
16
+ for an SSH origin, unsupported forge, intentionally local-only source links,
17
+ or a local fixture whose HTTPS URL is only a repository identity; retain the
18
+ URL and revision. Web links require a supported GitHub or Gitee HTTPS origin. If the
19
+ worktree is dirty, record the changed paths. Repository evidence is verified
20
+ against committed bytes at the pinned revision, not working-tree edits:
21
+ inspect a clean checkout at that revision for any cited changed path. Do not
22
+ present uncommitted bytes as evidence for `HEAD`; `local-only` does not record
23
+ a verifiable snapshot of those bytes.
24
+
25
+ 2. **Map the slice.** Use project instructions, manifests, entry points,
26
+ registrations, and deployment configuration to locate candidate runtime
27
+ units. Read the entry, configuration, and modules relevant to the request.
28
+ Follow imports and call sites
29
+ until the requested responsibility reaches its actual input, output, or
30
+ side effect. Read a small connected slice instead of scanning the repository
31
+ for a convenient label.
32
+
33
+ 3. **Trace ownership.** Derive runtime and I/O relationships from the observed
34
+ actor, operation, and target at their call sites; deployment and trust
35
+ relationships use the corresponding configuration or enforcement evidence. Distinguish the controller requesting
36
+ an operation from the runtime that executes it and the store receiving bytes.
37
+ For a file or database edge, the source must identify its actual reader or
38
+ writer; a responsibility statement such as “maintains tasks” does not prove
39
+ direct I/O. Keep these facts with the source locations while reading, without
40
+ a separate planning artifact. Choose which distinctions need separate
41
+ nodes using [Composition and meaning](authoring-defaults.md#composition-and-meaning);
42
+ discovering an implementation role does not automatically add it to the overview.
43
+ A configured provider, an injected adapter, a local stub, and a durable
44
+ service are different claims; label the one the source supports.
45
+
46
+ 4. **Record evidence while reading.** Keep exact repository-relative paths and
47
+ inclusive line ranges for each component and meaningful relationship. Follow
48
+ actual branches, retries, fallbacks, and error handling. A function that is
49
+ exported or configured but never called by the normal path is an optional
50
+ capability, not a required runtime edge. For a claim about authoritative
51
+ state change or control ownership, trace to the actual write or execution
52
+ site and the conditions that permit it; an upstream caller alone does not
53
+ establish those conditions.
54
+
55
+ 5. **Name uncertainty.** Write unresolved questions beside the claim they
56
+ affect: for example, “`writeFile` is called here; durability is unknown.”
57
+ Resolve a question by reading the next relevant source range or preserve it
58
+ as an explicit unknown. Never turn a label, package description, or config
59
+ value into an unobserved service or behavior.
60
+
61
+ Stop exploring when every requested responsibility, relationship, and boundary
62
+ has supporting source entailment and the remaining unknowns cannot change that
63
+ coverage. There is no node, edge, citation, view, card, or boundary count to
64
+ hit. Do not add a summary step merely to signal completion.
65
+
66
+ Batch independent relevant files when known. Each additional read should answer
67
+ an unresolved question that can change the diagram. Reuse concise facts and
68
+ their source ranges already verified in this task; across revisions, recheck
69
+ the affected entry points, configuration, dependencies, and evidence.
70
+
71
+ ## Choose an example by structure
72
+
73
+ Select the main example in the [Type router](../SKILL.md#type-router) before
74
+ loading its content, using the request and repository metadata already needed
75
+ for source inspection. Selection fits the existing read batch and needs no extra
76
+ message, command, or repository-wide scan. For mixed or unclear tasks, use the
77
+ requested responsibilities and entry points as they become known in normal
78
+ inspection; keep their actual roles. Read another example when a necessary
79
+ capability remains unexplained. Examples teach shape, not facts: a library need
80
+ not acquire filesystem nodes, and finished showcases still follow the
81
+ first-draft automatic-routing rule.
82
+
83
+ ## Author from evidence
84
+
85
+ Use the mode's complete JSON shape, including repository identity,
86
+ components, and connections; every repository-backed component needs supporting source
87
+ references, while boundaries or cards are added only when they
88
+ answer a real reader question. Let automatic routes and automatic
89
+ viewBox sizing work first. Keep the primary path readable, put exception paths
90
+ beside their owner, and leave filesystem stores outside a control boundary when
91
+ the source shows a separate responsibility.
92
+
93
+ An existing example teaches field shape, not facts or arbitrary values. It does
94
+ not authorize a new boundary kind, a long note, a viewBox size, or a route
95
+ control. Consult the specific mode schema and `schemas/common.schema.json`
96
+ whether or not the selected example already contains the field; use the
97
+ schema's enum, length, identifier, and repository rules. Architecture
98
+ boundaries currently use `kind: "region"` or `kind: "security-group"`; source
99
+ references use `path`, `line`, and optional `end_line`.
100
+
101
+ Repository-backed components need concise, truthful `sources` references. Preserve
102
+ control ownership when summarizing filesystem I/O: the code that reads or writes
103
+ a file owns that action, while a pure in-memory transform receives and returns
104
+ values. This fact-check does not require a separate overview node for every helper. Use the existing examples for valid field shape, then replace all
105
+ identifiers, wording, source paths, and claims with inspected repository facts.
@@ -0,0 +1,12 @@
1
+ # Update awareness
2
+
3
+ Read this file when a `finalize` or standalone `deliver` receipt has `update.noticeRequired: true`.
4
+
5
+ Keep one compact line in the final response, in the user's language, with `installedVersion`, `availableVersion`, and the official `releaseNotes` link. Say that the installed Skill has not changed and that the user can ask to snooze or ignore the reminder. If `source` is `cache`, say that a previous check at `checkedAt` found the update. A process message or tool output does not replace this final line.
6
+ For `severity: "security"`, label it as a security update without making installation automatic or urgent by default.
7
+
8
+ You may translate the fixed local `noticeText`. Never quote, summarize, or translate the remote manifest's summary.
9
+
10
+ When the user explicitly asks to pause or stop this reminder, run `node scripts/check-update.mjs --snooze "<eventKey>"` (seven days) or `--ignore "<eventKey>"` (this exact release only) from the Skill directory with the receipt's `update.eventKey`, then report the returned status. Never run them on your own initiative; `--ack` is a no-op. These commands do not install an update, and a newer release notifies again.
11
+
12
+ The notice is information, not permission. Keep the installed version unchanged. This workflow never downloads, installs, or executes an update, and silence is never consent.
@@ -0,0 +1,35 @@
1
+ # Viewer Runtime reference
2
+
3
+ Read this only when the user asks for a reader-facing capability. Ordinary generation does not require implementing or re-documenting these features; they are already in the generated HTML.
4
+
5
+ ## Exploration
6
+
7
+ - Diagram Guide lists current actions and shortcuts.
8
+ - Reading Depth starts at READ at the default 100% scale, reveals FULL detail at 175%, and falls back to MAP only below 100%. Focus, route, and semantic interactions reveal their exact facts at any scale.
9
+ - Semantic Lens summarizes selected node/relationship kinds without changing authored geometry.
10
+ - Intent Trace previews a fine-pointer or keyboard target before committed focus.
11
+ - Node Finder searches labels and stable IDs.
12
+ - Semantic Passport opens on focus, shows authored upstream/downstream facts, supports a copyable deep link, has an explicit close action, closes on true outside activation and Escape, and never enters canonical export.
13
+ - Semantic Radar mirrors the visible viewport and authored graph without becoming a second source of truth.
14
+ - Direct Relationship Pin makes a unique compiled relationship operable while preserving the authored line and stable relationship identity. It must fail closed on conflicting source/target/label/ID metadata.
15
+ - Route Probe resolves exactly two endpoints over authored directed relationships. It never infers a route from geometry.
16
+
17
+ ## Motion and presentation
18
+
19
+ `meta.animation: "trace"` enables a finite reader-controlled Live/Still trace. Static is the default. Still, reduced motion, page hiding, print, and canonical export preserve complete static meaning. Presentation Stage changes viewer chrome and framing, never authored geometry. This is not a mobile product feature; narrow layouts get containment only.
20
+
21
+ ## Canonical exports
22
+
23
+ The export menu can copy/download full-diagram PNG, download JPEG/WebP, download a dual-theme SVG, and record a trace-enabled WebM. Viewer state—Guide, Lens, finder, focus, route, camera, radar, presentation, motion ownership, and temporary overlays—must be removed from canonical export.
24
+
25
+ ### Route Share Card
26
+
27
+ After a real directed Route Probe resolves, the reader may use **Export → Route Share Card**. It reuses the exact ordered route snapshot and the shared Share Card seam: `format=share-card`, `variant=route`. The isolated clone may use only static `data-share-route-*` decoration. It is download-only, fails closed for stale/unreachable/conflicting routes, and never becomes the canonical artifact.
28
+
29
+ ### Reach Share Card
30
+
31
+ After a non-empty authored reachability query, the reader may use **Export → Reach Share Card**. It consumes the already resolved upstream/downstream node and edge set without rerunning traversal: `format=share-card`, `variant=reach`. The isolated clone may use only static `data-share-reach-*` decoration. It is download-only. Call it authored reachability—not impact, blast radius, breakage, or runtime causality.
32
+
33
+ ## Truth boundary
34
+
35
+ Viewer exports are communication assets. They do not replace the checked HTML, the deterministic delivery receipt, or a real visual review. Do not add a hosted service, storage surface, dependency, schema branch, or mobile product surface for these viewer-only capabilities.
@@ -0,0 +1,62 @@
1
+ /** Grid placement for architecture IR (#8). Not auto-layout — fixed cell math only. */
2
+
3
+ export const DEFAULT_GRID = {
4
+ mode: 'grid',
5
+ origin: [40, 80],
6
+ cols: 4,
7
+ gapX: 30,
8
+ gapY: 40,
9
+ cellW: 130,
10
+ cellH: 64,
11
+ };
12
+
13
+ export function gridLayout(arch) {
14
+ const raw = arch.layout;
15
+ if (!raw || raw.mode !== 'grid') return null;
16
+ return { ...DEFAULT_GRID, ...raw };
17
+ }
18
+
19
+ export function resolveComponentPos(component, grid) {
20
+ if (Array.isArray(component.pos) && component.pos.length === 2) {
21
+ return component.pos;
22
+ }
23
+ if (!grid) return [NaN, NaN];
24
+ if (!Number.isInteger(component.row) || !Number.isInteger(component.col)) {
25
+ return [NaN, NaN];
26
+ }
27
+ const [ox, oy] = grid.origin;
28
+ const stepX = grid.cellW + grid.gapX;
29
+ const stepY = grid.cellH + grid.gapY;
30
+ return [ox + component.col * stepX, oy + component.row * stepY];
31
+ }
32
+
33
+ export function validateGridPlacement(arch, grid, problems) {
34
+ if (!grid) return;
35
+ if (arch.layout !== undefined && arch.layout.mode !== 'grid') {
36
+ problems.push('layout.mode must be "grid" when layout is set (free placement omits layout entirely).');
37
+ return;
38
+ }
39
+ const seen = new Map();
40
+ for (const c of arch.components ?? []) {
41
+ const hasPos = Array.isArray(c.pos) && c.pos.length === 2;
42
+ const hasCell = Number.isInteger(c.row) && Number.isInteger(c.col);
43
+ if (hasPos) continue; // pos wins; row/col are optional hints only
44
+ if (!hasPos && !hasCell) {
45
+ problems.push(`Component "${c.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`);
46
+ continue;
47
+ }
48
+ if (c.row < 0 || c.col < 0) {
49
+ problems.push(`Component "${c.id}" row/col must be non-negative integers.`);
50
+ continue;
51
+ }
52
+ if (c.col >= grid.cols) {
53
+ problems.push(`Component "${c.id}" col ${c.col} exceeds layout.cols ${grid.cols} (valid: 0..${grid.cols - 1}).`);
54
+ }
55
+ const key = `${c.row},${c.col}`;
56
+ if (seen.has(key)) {
57
+ problems.push(`Components "${seen.get(key)}" and "${c.id}" share grid cell row ${c.row} col ${c.col}.`);
58
+ } else {
59
+ seen.set(key, c.id);
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,215 @@
1
+ import { normalizeRoutePoints, rectsOverlap, segmentRectClearanceWithin } from '../shared/geometry.mjs';
2
+ import { createSpatialGrid } from '../shared/spatial-grid.mjs';
3
+
4
+ // A bounded fallback for an unpinned label whose usual position collides.
5
+ // It never routes an edge, moves a node, expands the canvas, or rewrites input.
6
+ export function placeAutomaticLabels({
7
+ labels, routes, components, titles, viewBox, placementBottom = viewBox[1], fallbackRing = true, keepFallbackNearRoute = false,
8
+ gridSweep = false,
9
+ }) {
10
+ const placed = [...labels];
11
+ const obstacles = [...components, ...titles];
12
+ const segments = routes.flatMap(({ relationIndex, points }) => {
13
+ const normalized = normalizeRoutePoints(points);
14
+ return normalized.slice(1).map((end, index) => ({ relationIndex, start: normalized[index], end }));
15
+ });
16
+ const inside = rect => (
17
+ rect.x >= 0 && rect.y >= 0
18
+ && rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= viewBox[1]
19
+ );
20
+ // The mask test asked every segment about every candidate position. Segments
21
+ // go into a uniform grid once, and a candidate only asks the cells it covers.
22
+ const SEGMENT_CELL = 120;
23
+ const segmentGrid = createSpatialGrid(SEGMENT_CELL);
24
+ for (const segment of segments) {
25
+ const [sx, sy] = segment.start;
26
+ const [ex, ey] = segment.end;
27
+ segmentGrid.insert({
28
+ minX: Math.min(sx, ex), maxX: Math.max(sx, ex),
29
+ minY: Math.min(sy, ey), maxY: Math.max(sy, ey),
30
+ }, segment);
31
+ }
32
+ const segmentsNear = (rect, margin) => segmentGrid.query({
33
+ minX: rect.x - margin, maxX: rect.x + rect.width + margin,
34
+ minY: rect.y - margin, maxY: rect.y + rect.height + margin,
35
+ });
36
+ const masksRoute = rect => {
37
+ for (const segment of segmentsNear(rect, 4)) {
38
+ if (segment.relationIndex === rect.relationIndex) continue;
39
+ if (segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4) return true;
40
+ }
41
+ return false;
42
+ };
43
+ const overlapsLabel = (rect, index, gap = 0) => placed.some((other, otherIndex) => (
44
+ otherIndex !== index && rectsOverlap(rect, other, gap)
45
+ ));
46
+ const clear = (rect, index) => (
47
+ inside(rect) && rect.y + rect.height <= placementBottom
48
+ && !obstacles.some(obstacle => rectsOverlap(rect, obstacle, 2))
49
+ && !overlapsLabel(rect, index, 2) && !masksRoute(rect)
50
+ );
51
+ const rectAt = (label, lx, ly) => ({
52
+ ...label, lx, ly, x: lx - label.width / 2, y: ly - 10,
53
+ });
54
+ // An opted-in grid layout runs parallel lines through shared gaps. Rank
55
+ // every position along all of the label's own segments: beside the line
56
+ // first, then centred on its own line (the plate interrupts only that
57
+ // line), then stepping outward past neighbouring parallels.
58
+ const gridCandidates = (label) => {
59
+ const fractions = [0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875];
60
+ const ranked = [];
61
+ // A close parallel of another relationship on one side (a reciprocal
62
+ // pair) makes a label on that side read as the neighbour's: prefer the
63
+ // far side.
64
+ const parallelOnLowSide = (a, b, axis) => {
65
+ const across = 1 - axis;
66
+ const [low, high] = [Math.min(a[axis], b[axis]), Math.max(a[axis], b[axis])];
67
+ let nearest = null;
68
+ for (const other of segments) {
69
+ if (other.relationIndex === label.relationIndex) continue;
70
+ if (Math.abs(other.start[across] - other.end[across]) > 0.0001) continue;
71
+ const distance = other.start[across] - a[across];
72
+ if (Math.abs(distance) < 0.0001 || Math.abs(distance) > 36) continue;
73
+ const overlap = Math.min(high, Math.max(other.start[axis], other.end[axis]))
74
+ - Math.max(low, Math.min(other.start[axis], other.end[axis]));
75
+ if (overlap <= 0) continue;
76
+ if (nearest === null || Math.abs(distance) < Math.abs(nearest)) nearest = distance;
77
+ }
78
+ return nearest !== null && nearest < 0;
79
+ };
80
+ for (const { start: a, end: b } of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
81
+ if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
82
+ const [above, below] = parallelOnLowSide(a, b, 0) ? [1, 0] : [0, 1];
83
+ for (const fraction of fractions) {
84
+ const x = a[0] + (b[0] - a[0]) * fraction;
85
+ if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < label.width / 2 + 6) continue;
86
+ ranked.push([above, x, a[1] - 10], [below, x, a[1] + 20], [2, x, a[1] + 3], [3, x, a[1] - 18], [3, x, a[1] + 28]);
87
+ }
88
+ } else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
89
+ const leftFirst = !parallelOnLowSide(a, b, 1);
90
+ for (const fraction of fractions) {
91
+ const y = a[1] + (b[1] - a[1]) * fraction;
92
+ if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < label.height / 2 + 6) continue;
93
+ ranked.push([2, a[0], y + 3]);
94
+ [6, 14, 22, 30, 38, 46, 54].forEach((offset, step) => {
95
+ const tier = step === 0 ? 0 : step === 1 ? 1 : 2 + step;
96
+ const left = [tier + (leftFirst ? 0 : 0.5), a[0] - label.width / 2 - offset, y + 3];
97
+ const right = [tier + (leftFirst ? 0.5 : 0), a[0] + label.width / 2 + offset, y + 3];
98
+ ranked.push(left, right);
99
+ });
100
+ }
101
+ }
102
+ }
103
+ return ranked.map((entry, order) => [...entry, order])
104
+ .sort((left, right) => left[0] - right[0] || left[3] - right[3])
105
+ .map(([, lx, ly]) => [lx, ly]);
106
+ };
107
+
108
+ for (const [index, label] of placed.entries()) {
109
+ const relation = label.relation;
110
+ if (['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => relation[key] !== undefined)) continue;
111
+ // Match actual defect thresholds before searching; a valid placement is
112
+ // not a reason to restyle the diagram. New placements leave extra space.
113
+ // The grid ranking already starts from the preferred position, so it
114
+ // also re-ranks labels whose default spot is merely valid.
115
+ if (!gridSweep && inside(label) && !components.some(component => rectsOverlap(label, component, -2))
116
+ && !titles.some(title => rectsOverlap(label, title))
117
+ && !overlapsLabel(label, index) && !masksRoute(label)) continue;
118
+ if (gridSweep) {
119
+ // Callers measure the plate one pixel higher than rectAt; test with a
120
+ // pixel of slack so the chosen position also passes their clearance.
121
+ const replacement = gridCandidates(label).map(([lx, ly]) => rectAt(label, lx, ly))
122
+ .find(rect => clear({ ...rect, x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2 }, index));
123
+ if (replacement) {
124
+ placed[index] = replacement;
125
+ continue;
126
+ }
127
+ }
128
+ for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
129
+ const [a, b] = [segment.start, segment.end];
130
+ let candidates = [];
131
+ if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
132
+ candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
133
+ const x = a[0] + (b[0] - a[0]) * fraction;
134
+ if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < 8) return [];
135
+ return [
136
+ [x, a[1] - 10],
137
+ [x, a[1] + 20],
138
+ [x, a[1] - 18],
139
+ [x, a[1] + 28],
140
+ ];
141
+ });
142
+ } else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
143
+ candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
144
+ const y = a[1] + (b[1] - a[1]) * fraction;
145
+ if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < 8) return [];
146
+ return [
147
+ [a[0] - label.width / 2 - 6, y + 3],
148
+ [a[0] + label.width / 2 + 6, y + 3],
149
+ [a[0] - label.width / 2 - 14, y + 3],
150
+ [a[0] + label.width / 2 + 14, y + 3],
151
+ ];
152
+ });
153
+ }
154
+ const replacement = candidates.map(([lx, ly]) => rectAt(label, lx, ly))
155
+ .find(rect => clear(rect, index));
156
+ if (replacement) {
157
+ placed[index] = replacement;
158
+ break;
159
+ }
160
+ }
161
+ if (placed[index] !== label || !fallbackRing) continue;
162
+
163
+ // Dense but valid topologies can leave every point directly beside the
164
+ // relationship occupied by another route. Search a small deterministic
165
+ // ring around the current anchor and the relationship's segment centres.
166
+ // A collision-free island above a node is not a readable edge label.
167
+ // Architecture opts into keeping the mask within two label heights of
168
+ // its own route; shared callers retain their existing policy. If no nearby
169
+ // slot fits, retain the collision so validation can request more space.
170
+ const ownSegments = segments.filter(segment => segment.relationIndex === label.relationIndex);
171
+ const baseAnchors = [
172
+ [label.lx, label.ly],
173
+ ...ownSegments.map(segment => [
174
+ (segment.start[0] + segment.end[0]) / 2,
175
+ (segment.start[1] + segment.end[1]) / 2,
176
+ ]),
177
+ ];
178
+ const horizontalStep = label.width / 2 + 12;
179
+ const ringOffsets = [
180
+ [0, -28], [0, 38],
181
+ [-horizontalStep, -28], [horizontalStep, -28],
182
+ [-horizontalStep, 38], [horizontalStep, 38],
183
+ [-(label.width + 20), -52], [label.width + 20, -52],
184
+ [-(label.width + 20), 62], [label.width + 20, 62],
185
+ [-(label.width + 20), -76], [label.width + 20, -76],
186
+ [-(label.width + 20), 86], [label.width + 20, 86],
187
+ ];
188
+ const fallback = baseAnchors.flatMap(([baseX, baseY]) => (
189
+ ringOffsets.map(([dx, dy]) => rectAt(label, baseX + dx, baseY + dy))
190
+ )).find(rect => clear(rect, index) && (!keepFallbackNearRoute || ownSegments.some(segment => (
191
+ segmentRectClearanceWithin(segment, rect, label.height * 2) <= label.height * 2
192
+ ))));
193
+ if (fallback) placed[index] = fallback;
194
+ }
195
+ return placed;
196
+ }
197
+
198
+ // The rect a single unpinned label would occupy given only its own route and
199
+ // the nodes: what the planner reserves before the remaining routes are laid.
200
+ // Only a placement beside the route itself is worth reserving; a label that
201
+ // would already need the fallback ring is left to the final placement pass.
202
+ export function reservedLabelRect({
203
+ label, points, routes = [], labels = [], components, viewBox = [Infinity, Infinity], placementBottom = Infinity,
204
+ }) {
205
+ const [rect] = placeAutomaticLabels({
206
+ labels: [{ ...label, relationIndex: -1 }, ...labels.map(other => ({ ...other, relationIndex: -2 }))],
207
+ routes: [{ relationIndex: -1, points }, ...routes],
208
+ components,
209
+ titles: [],
210
+ viewBox,
211
+ placementBottom,
212
+ fallbackRing: false,
213
+ });
214
+ return components.some(component => rectsOverlap(rect, component, -2)) ? null : rect;
215
+ }