@pioneer_zmc/dsh-workbench 0.2.1

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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/README.zh.md +100 -0
  4. package/client.js +122 -0
  5. package/cordis.patch.yml +8 -0
  6. package/dist/check-building.js +1669 -0
  7. package/dist/check-room.js +980 -0
  8. package/dist/index.js +2873 -0
  9. package/package.json +60 -0
  10. package/skills/specdev-building/SKILL.md +85 -0
  11. package/skills/specdev-building/assets/layouts/court.json +53 -0
  12. package/skills/specdev-building/assets/layouts/row.json +48 -0
  13. package/skills/specdev-building/assets/layouts/single.json +27 -0
  14. package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
  15. package/skills/specdev-building/assets/layouts/tower.json +59 -0
  16. package/skills/specdev-building/references/blueprint-contract.md +126 -0
  17. package/skills/specdev-building/references/diagnostics.md +101 -0
  18. package/skills/specdev-building/references/layouts.md +112 -0
  19. package/skills/specdev-business/SKILL.md +91 -0
  20. package/skills/specdev-business/assets/plan-template.md +66 -0
  21. package/skills/specdev-business/assets/spec-template.md +111 -0
  22. package/skills/specdev-business/assets/tasks-template.md +36 -0
  23. package/skills/specdev-business/references/change.md +117 -0
  24. package/skills/specdev-business/references/evidence-review.md +125 -0
  25. package/skills/specdev-business/references/implementation.md +67 -0
  26. package/skills/specdev-business/references/project-contract.md +211 -0
  27. package/skills/specdev-business/references/source-map.md +110 -0
  28. package/skills/specdev-business/references/specification.md +93 -0
  29. package/skills/specdev-business/references/workflow.md +126 -0
  30. package/skills/specdev-room/SKILL.md +77 -0
  31. package/skills/specdev-room/assets/intro-room.json +14 -0
  32. package/skills/specdev-room/references/diagnostics.md +21 -0
  33. package/skills/specdev-room/references/room-contract.md +59 -0
  34. package/vendor/archify-renderer/VERSION.md +52 -0
  35. package/vendor/archify-renderer/archify/LICENSE +22 -0
  36. package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
  37. package/vendor/archify-renderer/archify/assets/template.html +14787 -0
  38. package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
  39. package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
  40. package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
  41. package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
  42. package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
  43. package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
  44. package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  45. package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
  46. package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
  47. package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
  48. package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
  49. package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
  50. package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
  51. package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
  52. package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
  53. package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
  54. package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
  55. package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  56. package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  57. package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
  58. package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
  59. package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
  60. package/vendor/archify-renderer/shims/process.mjs +18 -0
  61. package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
  62. package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
  63. package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
  64. package/vendor/archify-renderer/stubs/fs.mjs +3 -0
  65. package/vendor/archify-renderer/stubs/http.mjs +3 -0
  66. package/vendor/archify-renderer/stubs/https.mjs +3 -0
  67. package/vendor/archify-renderer/stubs/net.mjs +3 -0
  68. package/vendor/archify-renderer/stubs/path.mjs +3 -0
  69. package/vendor/archify-renderer/stubs/url.mjs +3 -0
  70. package/web/assets/building-kit.js +1157 -0
  71. package/web/assets/building-look.js +185 -0
  72. package/web/assets/building-plan.js +495 -0
  73. package/web/assets/building-scene.js +529 -0
  74. package/web/assets/building-walk.js +248 -0
  75. package/web/assets/building.js +859 -0
  76. package/web/assets/business.js +124 -0
  77. package/web/assets/chart-link.js +165 -0
  78. package/web/assets/common.js +302 -0
  79. package/web/assets/details.js +197 -0
  80. package/web/assets/evidence.js +30 -0
  81. package/web/assets/furniture/SOURCES.md +24 -0
  82. package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
  83. package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
  84. package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
  85. package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
  86. package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
  87. package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
  88. package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
  89. package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
  90. package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
  91. package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
  92. package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
  93. package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
  94. package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
  95. package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
  96. package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
  97. package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
  98. package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
  99. package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
  100. package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
  101. package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
  102. package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
  103. package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
  104. package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
  105. package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
  106. package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
  107. package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
  108. package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
  109. package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
  110. package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
  111. package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
  112. package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
  113. package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
  114. package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
  115. package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
  116. package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
  117. package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
  118. package/web/assets/furniture/catalog.js +137 -0
  119. package/web/assets/furniture/index.json +110 -0
  120. package/web/assets/furniture/registry.js +72 -0
  121. package/web/assets/furniture-page.css +68 -0
  122. package/web/assets/furniture-page.js +302 -0
  123. package/web/assets/home.js +65 -0
  124. package/web/assets/node-detail.js +148 -0
  125. package/web/assets/panel-link.js +47 -0
  126. package/web/assets/read.js +746 -0
  127. package/web/assets/room-scene.js +386 -0
  128. package/web/assets/room.css +287 -0
  129. package/web/assets/room.js +437 -0
  130. package/web/assets/rooms/bindings.js +449 -0
  131. package/web/assets/rooms/ceramic-reflection.js +193 -0
  132. package/web/assets/rooms/ceramic-room.js +302 -0
  133. package/web/assets/rooms/door.js +35 -0
  134. package/web/assets/rooms/fairy-room.js +492 -0
  135. package/web/assets/rooms/furniture-assembly.js +113 -0
  136. package/web/assets/rooms/navigation.js +262 -0
  137. package/web/assets/rooms/placeholder-catalog.json +71 -0
  138. package/web/assets/rooms/placeholder-furniture.js +76 -0
  139. package/web/assets/rooms/placement.js +588 -0
  140. package/web/assets/rooms/reader.js +441 -0
  141. package/web/assets/rooms/reading-gate.js +157 -0
  142. package/web/assets/rooms/room-lighting.js +50 -0
  143. package/web/assets/rooms/templates.json +50 -0
  144. package/web/assets/rooms/walk-frame.js +64 -0
  145. package/web/assets/rooms/walk-session.js +138 -0
  146. package/web/assets/save-result.js +89 -0
  147. package/web/assets/screenshots/business.png +0 -0
  148. package/web/assets/screenshots/node-detail.png +0 -0
  149. package/web/assets/screenshots/project-home.png +0 -0
  150. package/web/assets/screenshots/workflow.png +0 -0
  151. package/web/assets/showcase.js +232 -0
  152. package/web/assets/style.css +836 -0
  153. package/web/assets/template.js +7 -0
  154. package/web/assets/theme.js +29 -0
  155. package/web/assets/three/OrbitControls.js +1972 -0
  156. package/web/assets/three/VERSION.md +12 -0
  157. package/web/assets/three/three.core.js +60586 -0
  158. package/web/assets/three/three.module.js +19719 -0
  159. package/web/assets/workspaces.css +21 -0
  160. package/web/assets/workspaces.js +81 -0
  161. package/web/building.html +131 -0
  162. package/web/business.html +43 -0
  163. package/web/furniture.html +47 -0
  164. package/web/index.html +57 -0
  165. package/web/read.html +107 -0
  166. package/web/room.html +146 -0
  167. package/web/showcase.html +50 -0
  168. package/web/workspaces.html +32 -0
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
6
+ import {
7
+ DESKTOP_READABILITY_VIEWPORT,
8
+ DESKTOP_READER_DIAGRAM_WIDTH,
9
+ MIN_PROJECTED_NODE_TEXT_PX,
10
+ projectedNodeTextPx,
11
+ } from '../renderers/shared/desktop-readability.mjs';
12
+
13
+ const input = process.argv[2];
14
+
15
+ if (!input || input === '-h' || input === '--help') {
16
+ console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
17
+ process.exit(input ? 0 : 2);
18
+ }
19
+
20
+ const htmlPath = path.resolve(input);
21
+ let html;
22
+ try {
23
+ html = fs.readFileSync(htmlPath, 'utf8');
24
+ } catch (err) {
25
+ console.error(JSON.stringify({
26
+ ok: false,
27
+ file: htmlPath,
28
+ checks: [{ name: 'file_readable', ok: false, details: [err.message] }],
29
+ }, null, 2));
30
+ process.exit(1);
31
+ }
32
+
33
+ const checks = [];
34
+ let composition = {
35
+ schemaVersion: 1,
36
+ profile: 'standard',
37
+ status: 'pass',
38
+ summary: { errors: 0, warnings: 0 },
39
+ metrics: {
40
+ properCrossings: 0,
41
+ ambiguousCorridors: 0,
42
+ containerBorderRuns: 0,
43
+ labelRouteClearanceIssues: 0,
44
+ minLabelRouteClearance: null,
45
+ maxBends: 0,
46
+ routesOverSuggestedBends: 0,
47
+ maxStretch: null,
48
+ routesOverSuggestedStretch: 0,
49
+ minSegmentPx: null,
50
+ minInteriorSegmentPx: null,
51
+ shortSegmentCount: 0,
52
+ shortEndpointSegmentCount: 0,
53
+ shortInteriorSegmentCount: 0,
54
+ microSegmentCount: 0,
55
+ desktopReadabilityIssues: 0,
56
+ minProjectedNodeTextPx: null,
57
+ },
58
+ suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
59
+ issues: [],
60
+ };
61
+
62
+ function addCheck(name, ok, details = []) {
63
+ checks.push({ name, ok, details });
64
+ }
65
+
66
+ const svgMatches = [...html.matchAll(/<svg\b[\s\S]*?<\/svg>/gi)];
67
+ addCheck('single_svg', svgMatches.length === 1, [`found ${svgMatches.length} <svg> block(s)`]);
68
+
69
+ if (svgMatches.length === 1) {
70
+ const svg = svgMatches[0][0];
71
+ const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
72
+ const svgAttrs = parseAttrs(svgRoot);
73
+ const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
74
+ const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
75
+ addCheck('finite_svg', !/\b(?:NaN|undefined|Infinity|-Infinity)\b/.test(svg));
76
+ const legendStart = svg.indexOf('<!-- Legend -->');
77
+ const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
78
+ const desktopReadabilityIssue = collectDesktopReadability(svgAttrs, beforeLegend);
79
+ const arrows = collectArrows(beforeLegend);
80
+ const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
81
+ addCheck(
82
+ 'orthogonal_arrows',
83
+ diagonal.length === 0,
84
+ diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`),
85
+ );
86
+ const relationshipCrossings = collectRelationshipCrossings(arrows);
87
+ const compositionFrames = collectCompositionFrames(beforeLegend);
88
+ const containerBorderRuns = collectBorderRuns({
89
+ routedRelations: arrows
90
+ .filter((arrow) => arrow.from && arrow.to && arrow.borderSegments.length)
91
+ .map((arrow) => ({
92
+ relation: arrow,
93
+ relationIndex: arrow.index,
94
+ segments: arrow.borderSegments,
95
+ })),
96
+ frames: compositionFrames,
97
+ });
98
+ const routedRelationships = arrows
99
+ .filter((arrow) => arrow.from && arrow.to && arrow.routePoints.length)
100
+ .map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
101
+ const routeMetrics = routeBudgetMetrics({ routedRelations: routedRelationships });
102
+ const routeRhythmIssues = collectRouteRhythmIssues({ routedRelations: routedRelationships });
103
+ const ambiguousCorridors = collectAmbiguousCorridors({ routedRelations: routedRelationships });
104
+ const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
105
+ const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
106
+ const labelRouteMeasurements = collectLabelRouteClearance({
107
+ labels: relationshipLabels,
108
+ routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
109
+ threshold: Number.MAX_VALUE,
110
+ });
111
+ const labelRouteClearance = collectLabelRouteClearance({
112
+ labels: relationshipLabels,
113
+ routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
114
+ threshold: labelClearanceThreshold,
115
+ });
116
+ const crossingIsError = qualityProfile === 'showcase';
117
+ const corridorIsError = qualityProfile === 'showcase';
118
+ const rhythmIsError = qualityProfile === 'showcase';
119
+ const labelClearanceIsError = qualityProfile === 'showcase';
120
+ const desktopReadabilityIsError = qualityProfile === 'showcase';
121
+ const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
122
+ + (crossingIsError ? relationshipCrossings.length : 0)
123
+ + (corridorIsError ? ambiguousCorridors.length : 0)
124
+ + (labelClearanceIsError ? labelRouteClearance.length : 0)
125
+ + (rhythmIsError ? routeRhythmIssues.length : 0)
126
+ + (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0);
127
+ const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
128
+ + (crossingIsError ? 0 : relationshipCrossings.length)
129
+ + (corridorIsError ? 0 : ambiguousCorridors.length)
130
+ + (labelClearanceIsError ? 0 : labelRouteClearance.length)
131
+ + (rhythmIsError ? 0 : routeRhythmIssues.length)
132
+ + (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1);
133
+ composition = {
134
+ schemaVersion: 1,
135
+ profile: qualityProfile,
136
+ status: compositionErrors ? 'fail' : 'pass',
137
+ summary: {
138
+ errors: compositionErrors,
139
+ warnings: compositionWarnings,
140
+ },
141
+ metrics: {
142
+ properCrossings: relationshipCrossings.length,
143
+ ambiguousCorridors: ambiguousCorridors.length,
144
+ containerBorderRuns: containerBorderRuns.length,
145
+ labelRouteClearanceIssues: labelRouteClearance.length,
146
+ minLabelRouteClearance: labelRouteMeasurements.length
147
+ ? Math.round(Math.min(...labelRouteMeasurements.map((hit) => hit.clearance)) * 10) / 10
148
+ : null,
149
+ desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
150
+ minProjectedNodeTextPx: desktopReadabilityIssue?.projectedFontPx ?? null,
151
+ ...roundedRouteMetrics(routeMetrics),
152
+ },
153
+ suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
154
+ issues: [
155
+ ...containerBorderRuns.map((hit) => ({
156
+ severity: qualityGatesEnforced ? 'error' : 'warning',
157
+ code: 'composition/container-border-run',
158
+ relationship: relationshipRecord(hit.relation),
159
+ frame: frameRecord(hit.frame),
160
+ side: hit.side,
161
+ segmentIndex: hit.segmentIndex,
162
+ overlapLength: Math.round(hit.overlapLength * 10) / 10,
163
+ from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
164
+ to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
165
+ })),
166
+ ...labelRouteClearance.map((hit) => ({
167
+ severity: labelClearanceIsError ? 'error' : 'warning',
168
+ code: 'composition/label-route-clearance',
169
+ label: hit.label?.label || hit.labelRelation?.label || '',
170
+ labelRelationship: relationshipRecord(hit.labelRelation),
171
+ otherRelationship: relationshipRecord(hit.otherRelation),
172
+ segmentIndex: hit.segmentIndex,
173
+ labelRect: roundedRect(hit.rect),
174
+ clearance: Math.round(hit.clearance * 10) / 10,
175
+ intersectionLength: Math.round((hit.intersectionLength || 0) * 10) / 10,
176
+ threshold: hit.threshold,
177
+ from: hit.start.map((value) => Math.round(value * 10) / 10),
178
+ to: hit.end.map((value) => Math.round(value * 10) / 10),
179
+ })),
180
+ ...relationshipCrossings.map((hit) => ({
181
+ severity: crossingIsError ? 'error' : 'warning',
182
+ code: 'composition/proper-crossing',
183
+ relationship: relationshipRecord(hit.left),
184
+ otherRelationship: relationshipRecord(hit.right),
185
+ point: hit.point.map((value) => Math.round(value * 10) / 10),
186
+ })),
187
+ ...ambiguousCorridors.map((hit) => ({
188
+ severity: corridorIsError ? 'error' : 'warning',
189
+ code: 'composition/ambiguous-corridor',
190
+ relationship: relationshipRecord(hit.left.relation),
191
+ otherRelationship: relationshipRecord(hit.right.relation),
192
+ segmentIndex: hit.leftSegment,
193
+ otherSegmentIndex: hit.rightSegment,
194
+ overlapLength: Math.round(hit.overlapLength * 10) / 10,
195
+ from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
196
+ to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
197
+ })),
198
+ ...routeRhythmIssues.map((hit) => ({
199
+ severity: rhythmIsError ? 'error' : 'warning',
200
+ code: hit.code,
201
+ relationship: relationshipRecord(hit.relation),
202
+ segmentIndex: hit.segmentIndex,
203
+ position: hit.position,
204
+ length: Math.round(hit.length * 10) / 10,
205
+ from: hit.start.map((value) => Math.round(value * 10) / 10),
206
+ to: hit.end.map((value) => Math.round(value * 10) / 10),
207
+ })),
208
+ ...(desktopReadabilityIssue ? [{
209
+ severity: desktopReadabilityIsError ? 'error' : 'warning',
210
+ code: 'composition/desktop-readability',
211
+ viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
212
+ viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
213
+ availableDiagramWidth: DESKTOP_READER_DIAGRAM_WIDTH,
214
+ viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
215
+ scale: desktopReadabilityIssue.scale,
216
+ text: desktopReadabilityIssue.text,
217
+ detail: desktopReadabilityIssue.detail,
218
+ sourceFontPx: desktopReadabilityIssue.sourceFontPx,
219
+ projectedFontPx: desktopReadabilityIssue.projectedFontPx,
220
+ minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
221
+ }] : []),
222
+ ],
223
+ };
224
+ addCheck(
225
+ 'label_route_clearance',
226
+ !labelClearanceIsError || labelRouteClearance.length === 0,
227
+ labelRouteClearance.map((hit) => (
228
+ `[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
229
+ )),
230
+ );
231
+ addCheck(
232
+ 'relationship_crossings',
233
+ !crossingIsError || relationshipCrossings.length === 0,
234
+ relationshipCrossings.map((hit) => (
235
+ `[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
236
+ )),
237
+ );
238
+ addCheck(
239
+ 'relationship_corridors',
240
+ !corridorIsError || ambiguousCorridors.length === 0,
241
+ ambiguousCorridors.map((hit) => (
242
+ `[composition/ambiguous-corridor] ${qualityProfile} ${relationshipName(hit.left.relation)} shares a ${Math.round(hit.overlapLength * 10) / 10}px corridor with ${relationshipName(hit.right.relation)} at [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
243
+ )),
244
+ );
245
+ addCheck(
246
+ 'container_border_runs',
247
+ !qualityGatesEnforced || containerBorderRuns.length === 0,
248
+ containerBorderRuns.map((hit) => (
249
+ `[composition/container-border-run] ${relationshipName(hit.relation)} follows ${frameName(hit.frame)} ${hit.side} border for ${Math.round(hit.overlapLength * 10) / 10}px on segment ${hit.segmentIndex} [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
250
+ )),
251
+ );
252
+ addCheck(
253
+ 'route_rhythm',
254
+ !rhythmIsError || routeRhythmIssues.length === 0,
255
+ routeRhythmIssues.map((hit) => (
256
+ `[${hit.code}] ${qualityProfile} ${relationshipName(hit.relation)} has a ${Math.round(hit.length * 10) / 10}px ${hit.position} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]`
257
+ )),
258
+ );
259
+
260
+ if (legendStart >= 0) {
261
+ const legendFragment = svg.slice(legendStart);
262
+ const legendBoxes = collectLegendBoxes(legendFragment);
263
+ const collisions = collectLegendCollisions(arrows, legendBoxes);
264
+ addCheck(
265
+ 'legend_clearance',
266
+ collisions.length === 0,
267
+ collisions.map((hit) => `${hit.arrow.kind} ${hit.arrow.index} crosses legend ${hit.box.label}`),
268
+ );
269
+ } else {
270
+ addCheck('legend_clearance', true, ['no legend marker found']);
271
+ }
272
+ }
273
+
274
+ const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
275
+ console.log(JSON.stringify({ ok, file: htmlPath, checks, composition }, null, 2));
276
+ process.exit(ok ? 0 : 1);
277
+
278
+ function collectArrows(fragment) {
279
+ const arrows = [];
280
+ let index = 0;
281
+
282
+ for (const tag of fragment.matchAll(/<(path|line)\b[^>]*>/gi)) {
283
+ const raw = tag[0];
284
+ if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
285
+ if (!/\bmarker-end=/.test(raw)) continue;
286
+ const attrs = parseAttrs(raw);
287
+ const segments = tag[1].toLowerCase() === 'line'
288
+ ? lineSegments(attrs)
289
+ : pathSegments(attrs.d || '');
290
+ const borderSegments = tag[1].toLowerCase() === 'line'
291
+ ? segments
292
+ : straightPathSegments(attrs.d || '');
293
+ arrows.push({
294
+ kind: tag[1].toLowerCase(),
295
+ index: index += 1,
296
+ raw,
297
+ segments,
298
+ borderSegments,
299
+ routePoints: parseRoutePoints(attrs['data-composition-points']) || (
300
+ borderSegments.length ? [borderSegments[0].start, ...borderSegments.map((segment) => segment.end)] : []
301
+ ),
302
+ from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
303
+ to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
304
+ id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
305
+ key: attrs['data-edge-key'],
306
+ label: attrs['data-edge-label'],
307
+ offset: tag.index,
308
+ });
309
+ }
310
+
311
+ return arrows;
312
+ }
313
+
314
+ function collectRelationshipLabelMasks(fragment, arrows) {
315
+ const labels = [];
316
+ for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
317
+ const group = match[0];
318
+ const groupAttrs = parseAttrs(group.match(/<g\b[^>]*>/i)?.[0] || '');
319
+ const rectTag = [...group.matchAll(/<rect\b[^>]*>/gi)]
320
+ .map((item) => item[0])
321
+ .find((tag) => /\bclass="[^"]*\bc-mask\b/.test(tag));
322
+ if (!rectTag) continue;
323
+ const attrs = parseAttrs(rectTag);
324
+ const rect = {
325
+ x: numberAttr(attrs, 'x'),
326
+ y: numberAttr(attrs, 'y'),
327
+ width: numberAttr(attrs, 'width'),
328
+ height: numberAttr(attrs, 'height'),
329
+ };
330
+ if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue;
331
+ const groupStart = match.index;
332
+ const groupEnd = groupStart + group.length;
333
+ const containedOwner = arrows.find((arrow) => (
334
+ arrow.offset > groupStart
335
+ && arrow.offset < groupEnd
336
+ && arrow.from === groupAttrs['data-edge-from']
337
+ && arrow.to === groupAttrs['data-edge-to']
338
+ && (!groupAttrs['data-edge-id'] || !arrow.id || arrow.id === groupAttrs['data-edge-id'])
339
+ ));
340
+ const owner = arrows.find((arrow) => (
341
+ groupAttrs['data-edge-key'] !== undefined && arrow.key === groupAttrs['data-edge-key']
342
+ )) || containedOwner || arrows.find((arrow) => (
343
+ arrow.id === groupAttrs['data-edge-id']
344
+ && arrow.from === groupAttrs['data-edge-from']
345
+ && arrow.to === groupAttrs['data-edge-to']
346
+ ));
347
+ if (!owner) continue;
348
+ if (owner.key === undefined && groupAttrs['data-edge-key'] !== undefined) owner.key = groupAttrs['data-edge-key'];
349
+ if (!owner.id && groupAttrs['data-edge-id']) owner.id = groupAttrs['data-edge-id'];
350
+ if (!owner.label && groupAttrs['data-edge-label']) owner.label = groupAttrs['data-edge-label'];
351
+ labels.push({
352
+ relation: owner,
353
+ relationIndex: owner.index,
354
+ label: groupAttrs['data-edge-label'] || '',
355
+ rect,
356
+ });
357
+ }
358
+ return labels;
359
+ }
360
+
361
+ function roundedRect(rect) {
362
+ return Object.fromEntries(Object.entries(rect).map(([key, value]) => [key, Math.round(value * 10) / 10]));
363
+ }
364
+
365
+ function roundedRouteMetrics(metrics) {
366
+ return {
367
+ ...metrics,
368
+ maxStretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
369
+ minSegmentPx: metrics.minSegmentPx == null ? null : Math.round(metrics.minSegmentPx * 10) / 10,
370
+ minInteriorSegmentPx: metrics.minInteriorSegmentPx == null ? null : Math.round(metrics.minInteriorSegmentPx * 10) / 10,
371
+ };
372
+ }
373
+
374
+ function parseRoutePoints(value) {
375
+ if (!value) return null;
376
+ const points = value.split(';').map((pair) => pair.split(',').map(Number));
377
+ return points.length >= 2 && points.every(isPoint) ? points : null;
378
+ }
379
+
380
+ function collectRelationshipCrossings(arrows) {
381
+ const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length);
382
+ const crossings = [];
383
+ for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
384
+ const left = relationships[leftIndex];
385
+ for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
386
+ const right = relationships[rightIndex];
387
+ if ([left.from, left.to].some((id) => id === right.from || id === right.to)) continue;
388
+ let point = null;
389
+ for (const leftSegment of left.segments) {
390
+ for (const rightSegment of right.segments) {
391
+ point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
392
+ if (point) break;
393
+ }
394
+ if (point) break;
395
+ }
396
+ if (point) crossings.push({ left, right, point });
397
+ }
398
+ }
399
+ return crossings;
400
+ }
401
+
402
+ function relationshipName(arrow) {
403
+ return arrow.id
404
+ ? `relationship id "${arrow.id}" ("${arrow.from}" -> "${arrow.to}")`
405
+ : `relationship "${arrow.from}" -> "${arrow.to}"`;
406
+ }
407
+
408
+ function relationshipRecord(arrow) {
409
+ const stableIndex = Number(arrow.key);
410
+ return {
411
+ id: arrow.id,
412
+ from: arrow.from,
413
+ to: arrow.to,
414
+ label: arrow.label || '',
415
+ collectionIndex: Number.isInteger(stableIndex) && stableIndex >= 0 ? stableIndex : arrow.index - 1,
416
+ artifactIndex: arrow.index,
417
+ };
418
+ }
419
+
420
+ function collectCompositionFrames(fragment) {
421
+ const frames = [];
422
+ for (const match of fragment.matchAll(/<(rect|path|line)\b[^>]*>/gi)) {
423
+ const attrs = parseAttrs(match[0]);
424
+ const kind = attrs['data-composition-frame-kind'];
425
+ if (!kind) continue;
426
+ const identity = attrs['data-composition-frame-id'] || frames.length;
427
+ if (match[1].toLowerCase() === 'rect') {
428
+ const frame = {
429
+ kind,
430
+ id: identity,
431
+ x: numberAttr(attrs, 'x'),
432
+ y: numberAttr(attrs, 'y'),
433
+ width: numberAttr(attrs, 'width'),
434
+ height: numberAttr(attrs, 'height'),
435
+ radius: numberAttr(attrs, 'rx') || 0,
436
+ };
437
+ if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
438
+ continue;
439
+ }
440
+ const segments = match[1].toLowerCase() === 'line'
441
+ ? lineSegments(attrs)
442
+ : pathSegments(attrs.d || '');
443
+ for (const [segmentIndex, segment] of segments.entries()) {
444
+ frames.push({
445
+ kind,
446
+ id: segments.length > 1 ? `${identity}:${segmentIndex}` : identity,
447
+ shape: 'line',
448
+ start: segment.start,
449
+ end: segment.end,
450
+ });
451
+ }
452
+ }
453
+ return frames;
454
+ }
455
+
456
+ function frameName(frame) {
457
+ return `${frame.kind || 'frame'} "${frame.id}"`;
458
+ }
459
+
460
+ function frameRecord(frame) {
461
+ return { kind: frame.kind, id: frame.id };
462
+ }
463
+
464
+ function formatPoint(point) {
465
+ return point.map((value) => Math.round(value * 10) / 10).join(', ');
466
+ }
467
+
468
+ function lineSegments(attrs) {
469
+ const start = [numberAttr(attrs, 'x1'), numberAttr(attrs, 'y1')];
470
+ const end = [numberAttr(attrs, 'x2'), numberAttr(attrs, 'y2')];
471
+ if (!isPoint(start) || !isPoint(end)) return [];
472
+ return [{ start, end }];
473
+ }
474
+
475
+ function pathSegments(d) {
476
+ const points = pointsFromPath(d);
477
+ const segments = [];
478
+ for (let i = 1; i < points.length; i += 1) {
479
+ segments.push({ start: points[i - 1], end: points[i] });
480
+ }
481
+ return segments;
482
+ }
483
+
484
+ // Border runs use exact visible primitives. Non-collinear Q curves are never
485
+ // flattened into chords here: a tangent or sampled near-horizontal curve is
486
+ // not a structural border run. A fully collinear Q remains a straight visible
487
+ // primitive and is included.
488
+ function straightPathSegments(d) {
489
+ const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
490
+ const segments = [];
491
+ let i = 0;
492
+ let command = '';
493
+ let current = [0, 0];
494
+ let start = null;
495
+ while (i < tokens.length) {
496
+ if (isCommand(tokens[i])) command = tokens[i++];
497
+ if (!command) break;
498
+ const absolute = command === command.toUpperCase();
499
+ switch (command.toUpperCase()) {
500
+ case 'M':
501
+ case 'L': {
502
+ let first = true;
503
+ while (i + 1 < tokens.length && !isCommand(tokens[i])) {
504
+ const point = [Number.parseFloat(tokens[i++]), Number.parseFloat(tokens[i++])];
505
+ if (!point.every(Number.isFinite)) break;
506
+ const next = absolute ? point : [current[0] + point[0], current[1] + point[1]];
507
+ if (command.toUpperCase() === 'L' || !first) segments.push({ start: current, end: next });
508
+ current = next;
509
+ if (!start) start = current;
510
+ first = false;
511
+ }
512
+ break;
513
+ }
514
+ case 'H': {
515
+ while (i < tokens.length && !isCommand(tokens[i])) {
516
+ const value = Number.parseFloat(tokens[i++]);
517
+ if (!Number.isFinite(value)) break;
518
+ const next = [absolute ? value : current[0] + value, current[1]];
519
+ segments.push({ start: current, end: next });
520
+ current = next;
521
+ }
522
+ break;
523
+ }
524
+ case 'V': {
525
+ while (i < tokens.length && !isCommand(tokens[i])) {
526
+ const value = Number.parseFloat(tokens[i++]);
527
+ if (!Number.isFinite(value)) break;
528
+ const next = [current[0], absolute ? value : current[1] + value];
529
+ segments.push({ start: current, end: next });
530
+ current = next;
531
+ }
532
+ break;
533
+ }
534
+ case 'Q': {
535
+ while (i + 3 < tokens.length && !isCommand(tokens[i])) {
536
+ const values = [0, 0, 0, 0].map(() => Number.parseFloat(tokens[i++]));
537
+ if (!values.every(Number.isFinite)) break;
538
+ const control = absolute ? values.slice(0, 2) : [current[0] + values[0], current[1] + values[1]];
539
+ const end = absolute ? values.slice(2, 4) : [current[0] + values[2], current[1] + values[3]];
540
+ if (Math.abs(crossProduct(current, control, end)) <= 1e-9) segments.push({ start: current, end });
541
+ current = end;
542
+ }
543
+ break;
544
+ }
545
+ case 'Z':
546
+ if (start) segments.push({ start: current, end: start });
547
+ current = start || current;
548
+ command = '';
549
+ break;
550
+ default:
551
+ return [];
552
+ }
553
+ }
554
+ return segments.filter(({ start: a, end: b }) => isPoint(a) && isPoint(b));
555
+ }
556
+
557
+ function diagonalStraightSegments(arrow) {
558
+ return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
559
+ Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
560
+ ? [{ segmentIndex, start, end }]
561
+ : []
562
+ ));
563
+ }
564
+
565
+ function collectLegendBoxes(fragment) {
566
+ const boxes = [];
567
+
568
+ for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
569
+ const attrs = parseAttrs(match[0]);
570
+ const x = numberAttr(attrs, 'x');
571
+ const y = numberAttr(attrs, 'y');
572
+ const width = numberAttr(attrs, 'width');
573
+ const height = numberAttr(attrs, 'height');
574
+ if ([x, y, width, height].every(Number.isFinite)) {
575
+ boxes.push({ x1: x, y1: y, x2: x + width, y2: y + height, label: `rect@${x},${y}` });
576
+ }
577
+ }
578
+
579
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
580
+ const attrs = parseAttrs(match[1]);
581
+ const box = textBox(attrs, stripTags(match[2]).trim());
582
+ if (box) boxes.push(box);
583
+ }
584
+
585
+ return boxes;
586
+ }
587
+
588
+ function collectLegendCollisions(arrows, boxes) {
589
+ const collisions = [];
590
+ for (const arrow of arrows) {
591
+ for (const segment of arrow.segments) {
592
+ for (const box of boxes) {
593
+ if (segmentIntersectsBox(segment, padBox(box, 2))) {
594
+ collisions.push({ arrow, box });
595
+ }
596
+ }
597
+ }
598
+ }
599
+ return collisions;
600
+ }
601
+
602
+ function textBox(attrs, text) {
603
+ const x = numberAttr(attrs, 'x');
604
+ const y = numberAttr(attrs, 'y');
605
+ const fontSize = Number.parseFloat(attrs['font-size'] || '10');
606
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
607
+ const width = estimatedTextWidth(text, fontSize);
608
+ const anchor = attrs['text-anchor'] || 'start';
609
+ let x1 = x;
610
+ if (anchor === 'middle') x1 = x - width / 2;
611
+ if (anchor === 'end') x1 = x - width;
612
+ return {
613
+ x1,
614
+ y1: y - fontSize,
615
+ x2: x1 + width,
616
+ y2: y + fontSize * 0.25,
617
+ label: text || `text@${x},${y}`,
618
+ };
619
+ }
620
+
621
+ function collectDesktopReadability(svgAttrs, fragment) {
622
+ const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
623
+ const viewBoxWidth = viewBox.length === 4 ? viewBox[2] : Number.NaN;
624
+ if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return null;
625
+ const scale = Math.min(1, DESKTOP_READER_DIAGRAM_WIDTH / viewBoxWidth);
626
+ let worst = null;
627
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
628
+ const primary = /\bdata-node-label(?:\s*=|\s|$)/i.test(match[1]);
629
+ const boundary = /\bdata-boundary-label(?:\s*=|\s|$)/i.test(match[1]);
630
+ const context = /\bdata-detail\s*=\s*"context"/i.test(match[1]);
631
+ if (!primary && !boundary && !context) continue;
632
+ const attrs = parseAttrs(match[1]);
633
+ const fontSize = Number.parseFloat(attrs['font-size'] || '');
634
+ if (!Number.isFinite(fontSize)) continue;
635
+ const projected = projectedNodeTextPx(fontSize, viewBoxWidth);
636
+ if (projected >= MIN_PROJECTED_NODE_TEXT_PX) continue;
637
+ const candidate = {
638
+ viewBoxWidth,
639
+ scale,
640
+ text: stripTags(match[2]).trim(),
641
+ detail: primary
642
+ ? 'primary'
643
+ : boundary ? 'boundary' : 'context',
644
+ sourceFontPx: fontSize,
645
+ projectedFontPx: projected,
646
+ };
647
+ if (!worst || candidate.projectedFontPx < worst.projectedFontPx) worst = candidate;
648
+ }
649
+ return worst;
650
+ }
651
+
652
+ function estimatedTextWidth(text, fontSize) {
653
+ let units = 0;
654
+ for (const char of text) units += char.charCodeAt(0) > 255 ? 1.8 : 0.62;
655
+ return Math.max(fontSize, units * fontSize);
656
+ }
657
+
658
+ function pointsFromPath(d) {
659
+ const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
660
+ const points = [];
661
+ let i = 0;
662
+ let command = '';
663
+ let current = [0, 0];
664
+ let start = null;
665
+
666
+ while (i < tokens.length) {
667
+ if (isCommand(tokens[i])) command = tokens[i++];
668
+ if (!command) break;
669
+
670
+ const absolute = command === command.toUpperCase();
671
+ switch (command.toUpperCase()) {
672
+ case 'M':
673
+ case 'L': {
674
+ while (i + 1 < tokens.length && !isCommand(tokens[i])) {
675
+ const x = Number.parseFloat(tokens[i++]);
676
+ const y = Number.parseFloat(tokens[i++]);
677
+ if (!Number.isFinite(x) || !Number.isFinite(y)) break;
678
+ current = absolute ? [x, y] : [current[0] + x, current[1] + y];
679
+ points.push(current);
680
+ if (!start) start = current;
681
+ }
682
+ break;
683
+ }
684
+ case 'H': {
685
+ while (i < tokens.length && !isCommand(tokens[i])) {
686
+ const x = Number.parseFloat(tokens[i++]);
687
+ if (!Number.isFinite(x)) break;
688
+ current = absolute ? [x, current[1]] : [current[0] + x, current[1]];
689
+ points.push(current);
690
+ }
691
+ break;
692
+ }
693
+ case 'V': {
694
+ while (i < tokens.length && !isCommand(tokens[i])) {
695
+ const y = Number.parseFloat(tokens[i++]);
696
+ if (!Number.isFinite(y)) break;
697
+ current = absolute ? [current[0], y] : [current[0], current[1] + y];
698
+ points.push(current);
699
+ }
700
+ break;
701
+ }
702
+ case 'Q': {
703
+ while (i + 3 < tokens.length && !isCommand(tokens[i])) {
704
+ const controlX = Number.parseFloat(tokens[i++]);
705
+ const controlY = Number.parseFloat(tokens[i++]);
706
+ const endX = Number.parseFloat(tokens[i++]);
707
+ const endY = Number.parseFloat(tokens[i++]);
708
+ if (![controlX, controlY, endX, endY].every(Number.isFinite)) break;
709
+ const control = absolute
710
+ ? [controlX, controlY]
711
+ : [current[0] + controlX, current[1] + controlY];
712
+ const end = absolute
713
+ ? [endX, endY]
714
+ : [current[0] + endX, current[1] + endY];
715
+ const startPoint = current;
716
+ for (let step = 1; step <= 8; step += 1) {
717
+ const amount = step / 8;
718
+ const remaining = 1 - amount;
719
+ points.push([
720
+ remaining * remaining * startPoint[0] + 2 * remaining * amount * control[0] + amount * amount * end[0],
721
+ remaining * remaining * startPoint[1] + 2 * remaining * amount * control[1] + amount * amount * end[1],
722
+ ]);
723
+ }
724
+ current = end;
725
+ }
726
+ break;
727
+ }
728
+ case 'Z': {
729
+ if (start) points.push(start);
730
+ break;
731
+ }
732
+ default:
733
+ return [];
734
+ }
735
+ }
736
+
737
+ return points.filter(isPoint);
738
+ }
739
+
740
+ function properSegmentIntersection(a, b, c, d) {
741
+ const abC = crossProduct(a, b, c);
742
+ const abD = crossProduct(a, b, d);
743
+ const cdA = crossProduct(c, d, a);
744
+ const cdB = crossProduct(c, d, b);
745
+ const epsilon = 1e-9;
746
+ const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
747
+ if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
748
+ const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
749
+ if (Math.abs(denominator) < epsilon) return null;
750
+ const ab = a[0] * b[1] - a[1] * b[0];
751
+ const cd = c[0] * d[1] - c[1] * d[0];
752
+ return [
753
+ (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
754
+ (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator,
755
+ ];
756
+ }
757
+
758
+ function crossProduct(a, b, c) {
759
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
760
+ }
761
+
762
+ function segmentIntersectsBox(segment, box) {
763
+ const { start, end } = segment;
764
+ if (pointInsideBox(start, box) || pointInsideBox(end, box)) return true;
765
+ const edges = [
766
+ [[box.x1, box.y1], [box.x2, box.y1]],
767
+ [[box.x2, box.y1], [box.x2, box.y2]],
768
+ [[box.x2, box.y2], [box.x1, box.y2]],
769
+ [[box.x1, box.y2], [box.x1, box.y1]],
770
+ ];
771
+ return edges.some(([a, b]) => segmentsIntersect(start, end, a, b));
772
+ }
773
+
774
+ function segmentsIntersect(a, b, c, d) {
775
+ const o1 = orientation(a, b, c);
776
+ const o2 = orientation(a, b, d);
777
+ const o3 = orientation(c, d, a);
778
+ const o4 = orientation(c, d, b);
779
+
780
+ if (o1 !== o2 && o3 !== o4) return true;
781
+ if (o1 === 0 && onSegment(a, c, b)) return true;
782
+ if (o2 === 0 && onSegment(a, d, b)) return true;
783
+ if (o3 === 0 && onSegment(c, a, d)) return true;
784
+ if (o4 === 0 && onSegment(c, b, d)) return true;
785
+ return false;
786
+ }
787
+
788
+ function orientation(a, b, c) {
789
+ const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
790
+ if (Math.abs(value) < 1e-9) return 0;
791
+ return value > 0 ? 1 : 2;
792
+ }
793
+
794
+ function onSegment(a, b, c) {
795
+ return b[0] <= Math.max(a[0], c[0]) + 1e-9
796
+ && b[0] + 1e-9 >= Math.min(a[0], c[0])
797
+ && b[1] <= Math.max(a[1], c[1]) + 1e-9
798
+ && b[1] + 1e-9 >= Math.min(a[1], c[1]);
799
+ }
800
+
801
+ function pointInsideBox(point, box) {
802
+ return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
803
+ }
804
+
805
+ function padBox(box, padding) {
806
+ return {
807
+ ...box,
808
+ x1: box.x1 - padding,
809
+ y1: box.y1 - padding,
810
+ x2: box.x2 + padding,
811
+ y2: box.y2 + padding,
812
+ };
813
+ }
814
+
815
+ function parseAttrs(tag) {
816
+ const attrs = {};
817
+ for (const match of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[match[1]] = match[2];
818
+ return attrs;
819
+ }
820
+
821
+ function numberAttr(attrs, name) {
822
+ return Number.parseFloat(attrs[name]);
823
+ }
824
+
825
+ function isCommand(token) {
826
+ return /^[A-Za-z]$/.test(token);
827
+ }
828
+
829
+ function isPoint(point) {
830
+ return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
831
+ }
832
+
833
+ function stripTags(value) {
834
+ return value.replace(/<[^>]*>/g, '');
835
+ }