@opendata-ai/openchart-engine 8.0.0-rc.12 → 8.0.0-rc.3

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 (271) hide show
  1. package/dist/index.d.ts +0 -7
  2. package/dist/index.js +161 -579
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/__test-fixtures__/specs.ts +182 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +2339 -0
  7. package/src/__tests__/annotation-behavior.test.ts +721 -0
  8. package/src/__tests__/axes.test.ts +1579 -0
  9. package/src/__tests__/compile-animation.test.ts +161 -0
  10. package/src/__tests__/compile-chart.test.ts +1303 -0
  11. package/src/__tests__/compile-footnotes.test.ts +277 -0
  12. package/src/__tests__/compile-layer.test.ts +969 -0
  13. package/src/__tests__/compile-snapshot.test.ts +156 -0
  14. package/src/__tests__/compound-labels.test.ts +147 -0
  15. package/src/__tests__/dimensions.test.ts +918 -0
  16. package/src/__tests__/encoding-sugar.test.ts +390 -0
  17. package/src/__tests__/facet.test.ts +215 -0
  18. package/src/__tests__/helpers/invariants.ts +299 -0
  19. package/src/__tests__/known-bugs.test.ts +566 -0
  20. package/src/__tests__/layout-fuzz.test.ts +358 -0
  21. package/src/__tests__/layout-invariants.test.ts +535 -0
  22. package/src/__tests__/legend.test.ts +721 -0
  23. package/src/__tests__/mark-keys.test.ts +294 -0
  24. package/src/__tests__/ranking-recipes.test.ts +260 -0
  25. package/src/__tests__/rotation.test.ts +83 -0
  26. package/src/__tests__/scales.test.ts +323 -0
  27. package/src/__tests__/series-search.test.ts +92 -0
  28. package/src/__tests__/spec-sugar.test.ts +801 -0
  29. package/src/__tests__/you-draw-it.test.ts +160 -0
  30. package/src/annotations/__tests__/compute.test.ts +1672 -0
  31. package/src/annotations/__tests__/geometry.test.ts +66 -0
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  33. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  34. package/src/annotations/__tests__/placement.test.ts +316 -0
  35. package/src/annotations/__tests__/resolve-text.test.ts +788 -0
  36. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  37. package/src/annotations/__tests__/thinning.test.ts +285 -0
  38. package/src/annotations/collisions.ts +326 -0
  39. package/src/annotations/compute.ts +333 -0
  40. package/src/annotations/constants.ts +162 -0
  41. package/src/annotations/geometry.ts +370 -0
  42. package/src/annotations/move.ts +135 -0
  43. package/src/annotations/placement.ts +636 -0
  44. package/src/annotations/position.ts +131 -0
  45. package/src/annotations/resolve-range.ts +117 -0
  46. package/src/annotations/resolve-refline.ts +157 -0
  47. package/src/annotations/resolve-text.ts +539 -0
  48. package/src/annotations/rich-text.ts +65 -0
  49. package/src/annotations/thinning.ts +190 -0
  50. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  51. package/src/barlist/compile-barlist.ts +391 -0
  52. package/src/barlist/types.ts +28 -0
  53. package/src/charts/__tests__/post-process.test.ts +237 -0
  54. package/src/charts/__tests__/registry.test.ts +116 -0
  55. package/src/charts/__tests__/utils.test.ts +195 -0
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  57. package/src/charts/_shared/density-filter.ts +26 -0
  58. package/src/charts/_shared/format-label-value.ts +16 -0
  59. package/src/charts/bar/__tests__/compute.test.ts +934 -0
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  61. package/src/charts/bar/__tests__/labels.test.ts +292 -0
  62. package/src/charts/bar/compute.ts +539 -0
  63. package/src/charts/bar/index.ts +60 -0
  64. package/src/charts/bar/labels.ts +311 -0
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  67. package/src/charts/beeswarm/compute.ts +203 -0
  68. package/src/charts/beeswarm/dodge.ts +155 -0
  69. package/src/charts/beeswarm/index.ts +32 -0
  70. package/src/charts/builtin.ts +75 -0
  71. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  72. package/src/charts/calendar/compute.ts +319 -0
  73. package/src/charts/calendar/index.ts +32 -0
  74. package/src/charts/column/__tests__/compute.test.ts +540 -0
  75. package/src/charts/column/__tests__/labels.test.ts +179 -0
  76. package/src/charts/column/compute.ts +493 -0
  77. package/src/charts/column/index.ts +53 -0
  78. package/src/charts/column/labels.ts +229 -0
  79. package/src/charts/dot/__tests__/compute.test.ts +380 -0
  80. package/src/charts/dot/__tests__/labels.test.ts +98 -0
  81. package/src/charts/dot/compute.ts +285 -0
  82. package/src/charts/dot/index.ts +62 -0
  83. package/src/charts/dot/labels.ts +121 -0
  84. package/src/charts/line/__tests__/compute.test.ts +1194 -0
  85. package/src/charts/line/__tests__/curves.test.ts +51 -0
  86. package/src/charts/line/__tests__/labels.test.ts +94 -0
  87. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  88. package/src/charts/line/area.ts +484 -0
  89. package/src/charts/line/compute.ts +356 -0
  90. package/src/charts/line/curves.ts +85 -0
  91. package/src/charts/line/index.ts +176 -0
  92. package/src/charts/line/labels.ts +177 -0
  93. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  94. package/src/charts/parliament/compute.ts +313 -0
  95. package/src/charts/parliament/index.ts +32 -0
  96. package/src/charts/pie/__tests__/compute.test.ts +384 -0
  97. package/src/charts/pie/__tests__/labels.test.ts +132 -0
  98. package/src/charts/pie/compute.ts +324 -0
  99. package/src/charts/pie/index.ts +49 -0
  100. package/src/charts/pie/labels.ts +143 -0
  101. package/src/charts/post-process.ts +229 -0
  102. package/src/charts/range/__tests__/compute.test.ts +486 -0
  103. package/src/charts/range/compute.ts +273 -0
  104. package/src/charts/range/index.ts +82 -0
  105. package/src/charts/range/labels.ts +169 -0
  106. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  107. package/src/charts/rect/index.ts +127 -0
  108. package/src/charts/registry.ts +72 -0
  109. package/src/charts/rule/index.ts +139 -0
  110. package/src/charts/scatter/__tests__/compute.test.ts +488 -0
  111. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  112. package/src/charts/scatter/compute.ts +192 -0
  113. package/src/charts/scatter/index.ts +46 -0
  114. package/src/charts/scatter/trendline.ts +100 -0
  115. package/src/charts/text/__tests__/compute.test.ts +307 -0
  116. package/src/charts/text/index.ts +141 -0
  117. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  118. package/src/charts/tick/index.ts +99 -0
  119. package/src/charts/utils.ts +170 -0
  120. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  121. package/src/charts/waffle/compute.ts +197 -0
  122. package/src/charts/waffle/index.ts +31 -0
  123. package/src/compile/__tests__/color-scale-range.test.ts +158 -0
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  125. package/src/compile/__tests__/data-clip.test.ts +127 -0
  126. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  127. package/src/compile/__tests__/size-scale.test.ts +108 -0
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  129. package/src/compile/color-scale-range.ts +101 -0
  130. package/src/compile/data-clip.ts +48 -0
  131. package/src/compile/fill-patterns.ts +74 -0
  132. package/src/compile/layer.ts +626 -0
  133. package/src/compile/size-scale.ts +141 -0
  134. package/src/compile/spec-sugar.ts +758 -0
  135. package/src/compile/watermark-obstacle.ts +54 -0
  136. package/src/compile/you-draw-it.ts +123 -0
  137. package/src/compile.ts +1450 -0
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  139. package/src/compiler/__tests__/animation.test.ts +177 -0
  140. package/src/compiler/__tests__/compile.test.ts +87 -0
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  160. package/src/compiler/__tests__/keys.test.ts +83 -0
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  162. package/src/compiler/__tests__/normalize.test.ts +399 -0
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  165. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  167. package/src/compiler/__tests__/validate.test.ts +629 -0
  168. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  169. package/src/compiler/a11y-warnings.ts +110 -0
  170. package/src/compiler/animation.ts +142 -0
  171. package/src/compiler/index.ts +47 -0
  172. package/src/compiler/keys.ts +55 -0
  173. package/src/compiler/normalize.ts +734 -0
  174. package/src/compiler/sparkline-defaults.ts +138 -0
  175. package/src/compiler/types.ts +241 -0
  176. package/src/compiler/validate.ts +1871 -0
  177. package/src/endpoint-labels/__tests__/compute.test.ts +587 -0
  178. package/src/endpoint-labels/compute.ts +621 -0
  179. package/src/endpoint-labels/constants.ts +54 -0
  180. package/src/endpoint-labels/format.ts +60 -0
  181. package/src/endpoint-labels/predict.ts +139 -0
  182. package/src/format/__tests__/field-format.test.ts +164 -0
  183. package/src/format/field-format.ts +27 -0
  184. package/src/geo/__tests__/compile-map.test.ts +832 -0
  185. package/src/geo/compile-map.ts +1243 -0
  186. package/src/geo/join.ts +115 -0
  187. package/src/geo/projections.ts +45 -0
  188. package/src/geo/types.ts +26 -0
  189. package/src/graphs/__tests__/community.test.ts +228 -0
  190. package/src/graphs/__tests__/compile-graph.test.ts +396 -0
  191. package/src/graphs/__tests__/encoding.test.ts +415 -0
  192. package/src/graphs/community.ts +92 -0
  193. package/src/graphs/compile-graph.ts +329 -0
  194. package/src/graphs/encoding.ts +356 -0
  195. package/src/graphs/types.ts +106 -0
  196. package/src/index.ts +177 -0
  197. package/src/layout/__tests__/metrics.test.ts +97 -0
  198. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  199. package/src/layout/axes/rotation.ts +152 -0
  200. package/src/layout/axes/thinning.ts +98 -0
  201. package/src/layout/axes/ticks.ts +409 -0
  202. package/src/layout/axes.ts +757 -0
  203. package/src/layout/dimensions.ts +912 -0
  204. package/src/layout/facet.ts +91 -0
  205. package/src/layout/gridlines.ts +84 -0
  206. package/src/layout/metrics.ts +132 -0
  207. package/src/layout/plan.ts +631 -0
  208. package/src/layout/scales.ts +1053 -0
  209. package/src/layout/shared.ts +44 -0
  210. package/src/legend/__tests__/continuous.test.ts +318 -0
  211. package/src/legend/__tests__/redundancy.test.ts +108 -0
  212. package/src/legend/__tests__/size.test.ts +348 -0
  213. package/src/legend/__tests__/suppression.test.ts +382 -0
  214. package/src/legend/compute.ts +747 -0
  215. package/src/legend/continuous.ts +327 -0
  216. package/src/legend/size.ts +407 -0
  217. package/src/legend/suppression.ts +226 -0
  218. package/src/legend/wrap.ts +117 -0
  219. package/src/sankey/__tests__/compile-sankey.test.ts +458 -0
  220. package/src/sankey/__tests__/layout.test.ts +165 -0
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  222. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  223. package/src/sankey/compile-sankey.ts +706 -0
  224. package/src/sankey/layout.ts +184 -0
  225. package/src/sankey/types.ts +41 -0
  226. package/src/tables/__tests__/bar-column.test.ts +147 -0
  227. package/src/tables/__tests__/category-colors.test.ts +230 -0
  228. package/src/tables/__tests__/compile-table.test.ts +236 -0
  229. package/src/tables/__tests__/format-cells.test.ts +143 -0
  230. package/src/tables/__tests__/heatmap.test.ts +148 -0
  231. package/src/tables/__tests__/pagination.test.ts +78 -0
  232. package/src/tables/__tests__/search.test.ts +94 -0
  233. package/src/tables/__tests__/sort.test.ts +107 -0
  234. package/src/tables/__tests__/sparkline.test.ts +122 -0
  235. package/src/tables/bar-column.ts +94 -0
  236. package/src/tables/category-colors.ts +78 -0
  237. package/src/tables/compile-table.ts +427 -0
  238. package/src/tables/format-cells.ts +115 -0
  239. package/src/tables/heatmap.ts +141 -0
  240. package/src/tables/pagination.ts +46 -0
  241. package/src/tables/search.ts +66 -0
  242. package/src/tables/sort.ts +69 -0
  243. package/src/tables/sparkline.ts +113 -0
  244. package/src/tables/utils.ts +16 -0
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +576 -0
  246. package/src/tilemap/compile-tilemap.ts +755 -0
  247. package/src/tilemap/layout.ts +172 -0
  248. package/src/tilemap/types.ts +33 -0
  249. package/src/tooltips/__tests__/compute.test.ts +582 -0
  250. package/src/tooltips/compute.ts +700 -0
  251. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  252. package/src/transforms/__tests__/bin.test.ts +88 -0
  253. package/src/transforms/__tests__/calculate.test.ts +146 -0
  254. package/src/transforms/__tests__/conditional.test.ts +127 -0
  255. package/src/transforms/__tests__/filter-relative.test.ts +202 -0
  256. package/src/transforms/__tests__/filter.test.ts +59 -0
  257. package/src/transforms/__tests__/fold.test.ts +79 -0
  258. package/src/transforms/__tests__/index.test.ts +93 -0
  259. package/src/transforms/__tests__/predicates.test.ts +193 -0
  260. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  261. package/src/transforms/__tests__/window.test.ts +286 -0
  262. package/src/transforms/aggregate.ts +131 -0
  263. package/src/transforms/bin.ts +87 -0
  264. package/src/transforms/calculate.ts +60 -0
  265. package/src/transforms/conditional.ts +46 -0
  266. package/src/transforms/filter.ts +122 -0
  267. package/src/transforms/fold.ts +49 -0
  268. package/src/transforms/index.ts +60 -0
  269. package/src/transforms/predicates.ts +123 -0
  270. package/src/transforms/timeunit.ts +88 -0
  271. package/src/transforms/window.ts +185 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Layout invariant assertions.
3
+ *
4
+ * Extracts bounding boxes from a compiled ChartLayout and checks that
5
+ * layout elements don't overlap where they shouldn't.
6
+ */
7
+
8
+ import type {
9
+ CategoricalLegendLayout,
10
+ ChartLayout,
11
+ LegendLayout,
12
+ Rect,
13
+ } from '@opendata-ai/openchart-core';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface NamedRect extends Rect {
20
+ name: string;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Type guards
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function isCategoricalLegend(legend: LegendLayout): legend is CategoricalLegendLayout {
28
+ return 'entries' in legend && (!legend.type || legend.type === 'categorical');
29
+ }
30
+
31
+ /**
32
+ * Every legend on the layout. Falls back to the singular `legend` slot so this
33
+ * still works against layouts built by older code paths (and by tests that
34
+ * hand-assemble a ChartLayout).
35
+ */
36
+ function allLegends(layout: ChartLayout): LegendLayout[] {
37
+ if (layout.legends?.length) return layout.legends;
38
+ return layout.legend ? [layout.legend] : [];
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Box extraction
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const CHROME_KEYS = [
46
+ 'eyebrow',
47
+ 'title',
48
+ 'subtitle',
49
+ 'source',
50
+ 'byline',
51
+ 'footer',
52
+ 'brand',
53
+ ] as const;
54
+
55
+ /**
56
+ * Extracts every layout element's bounding box as a NamedRect.
57
+ */
58
+ export function extractBoxes(layout: ChartLayout): NamedRect[] {
59
+ const boxes: NamedRect[] = [];
60
+
61
+ // Chart area
62
+ boxes.push({ ...layout.area, name: 'chartArea' });
63
+
64
+ // Legend bounds -- EVERY legend, not just the primary. A chart can carry both
65
+ // a color legend and a size legend; registering only `layout.legend` would
66
+ // leave the overlap invariants blind to exactly the element most likely to be
67
+ // mis-reserved, and they would pass a chart whose size legend sits on the plot.
68
+ for (const legend of allLegends(layout)) {
69
+ const suffix = legend.channel && legend.channel !== 'color' ? `-${legend.channel}` : '';
70
+ boxes.push({ ...legend.bounds, name: `legend${suffix}` });
71
+
72
+ // Legend entry positions (categorical only)
73
+ if (isCategoricalLegend(legend) && legend.entryPositions) {
74
+ const rowHeight = legend.rowHeight ?? 16;
75
+ for (let i = 0; i < legend.entryPositions.length; i++) {
76
+ const pos = legend.entryPositions[i];
77
+ boxes.push({
78
+ x: pos.x,
79
+ y: pos.y,
80
+ width: pos.width,
81
+ height: rowHeight,
82
+ name: `legend${suffix}-entry-${i}`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ // X-axis label band
89
+ if (layout.axes.x?.extent && layout.axes.x.extent > 0) {
90
+ boxes.push({
91
+ x: layout.area.x,
92
+ y: layout.area.y + layout.area.height,
93
+ width: layout.area.width,
94
+ height: layout.axes.x.extent,
95
+ name: 'x-axis-labels',
96
+ });
97
+ }
98
+
99
+ // Y-axis label band
100
+ if (layout.axes.y?.extent && layout.axes.y.extent > 0) {
101
+ boxes.push({
102
+ x: layout.area.x - layout.axes.y.extent,
103
+ y: layout.area.y,
104
+ width: layout.axes.y.extent,
105
+ height: layout.area.height,
106
+ name: 'y-axis-labels',
107
+ });
108
+ }
109
+
110
+ // Chrome elements store y as the TOP edge of the text box (renderers convert
111
+ // to the alphabetic baseline via textAscent), so y is the box top directly.
112
+ for (const key of CHROME_KEYS) {
113
+ const el = layout.chrome[key];
114
+ if (el) {
115
+ const h = el.style.fontSize * el.style.lineHeight;
116
+ boxes.push({ x: el.x, y: el.y, width: el.maxWidth, height: h, name: key });
117
+ }
118
+ }
119
+
120
+ // Annotation bounds
121
+ for (let i = 0; i < layout.annotations.length; i++) {
122
+ const annotation = layout.annotations[i];
123
+ if (annotation.bounds) {
124
+ boxes.push({ ...annotation.bounds, name: `annotation-${i}` });
125
+ }
126
+ }
127
+
128
+ // Endpoint labels
129
+ if (layout.endpointLabels?.bounds) {
130
+ boxes.push({ ...layout.endpointLabels.bounds, name: 'endpoint-labels' });
131
+ }
132
+
133
+ return boxes;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Overlap check
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Throws if two rects overlap by more than epsilon pixels.
142
+ */
143
+ export function assertNoOverlap(a: NamedRect, b: NamedRect, epsilon = 0.5): void {
144
+ const overlaps =
145
+ a.x < b.x + b.width - epsilon &&
146
+ a.x + a.width > b.x + epsilon &&
147
+ a.y < b.y + b.height - epsilon &&
148
+ a.y + a.height > b.y + epsilon;
149
+
150
+ if (overlaps) {
151
+ throw new Error(
152
+ `Overlap detected between "${a.name}" and "${b.name}": ` +
153
+ `a=${JSON.stringify({ x: a.x, y: a.y, width: a.width, height: a.height })}, ` +
154
+ `b=${JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height })}`,
155
+ );
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Containment helper
161
+ // ---------------------------------------------------------------------------
162
+
163
+ function isContained(inner: Rect, outer: Rect, tolerance: number): boolean {
164
+ return (
165
+ inner.x >= outer.x - tolerance &&
166
+ inner.y >= outer.y - tolerance &&
167
+ inner.x + inner.width <= outer.x + outer.width + tolerance &&
168
+ inner.y + inner.height <= outer.y + outer.height + tolerance
169
+ );
170
+ }
171
+
172
+ function rectsOverlap(a: Rect, b: Rect, epsilon: number): boolean {
173
+ return (
174
+ a.x < b.x + b.width - epsilon &&
175
+ a.x + a.width > b.x + epsilon &&
176
+ a.y < b.y + b.height - epsilon &&
177
+ a.y + a.height > b.y + epsilon
178
+ );
179
+ }
180
+
181
+ function formatRect(r: Rect): string {
182
+ return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height });
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Full invariant check
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Returns a list of violation strings. Empty when clean.
191
+ */
192
+ export function checkLayoutInvariants(
193
+ layout: ChartLayout,
194
+ opts: { svgWidth: number; svgHeight: number },
195
+ ): string[] {
196
+ const violations: string[] = [];
197
+
198
+ // 1. No legend intrudes into chartArea (top/bottom only; right/bottom-right
199
+ // use area.width reduction, so overlap is a horizontal concern checked by rule 4)
200
+ //
201
+ // Checked for EVERY legend and every legend type. A size legend is not
202
+ // categorical, and it is the one whose space is most likely to be
203
+ // under-reserved -- gating this on `isCategoricalLegend` would skip it.
204
+ for (const legend of allLegends(layout)) {
205
+ if (legend.bounds.width <= 0 || legend.bounds.height <= 0) continue;
206
+ const pos = legend.position;
207
+ if (pos === 'top' || pos === 'bottom') {
208
+ if (rectsOverlap(legend.bounds, layout.area, 0.5)) {
209
+ const name = legend.channel ?? 'color';
210
+ violations.push(
211
+ `${name} legend intrudes into chartArea: legend=${formatRect(legend.bounds)} chartArea=${formatRect(layout.area)}`,
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ // 1b. Legends don't overlap each other.
218
+ const legends = allLegends(layout).filter((l) => l.bounds.width > 0 && l.bounds.height > 0);
219
+ for (let i = 0; i < legends.length; i++) {
220
+ for (let j = i + 1; j < legends.length; j++) {
221
+ if (rectsOverlap(legends[i].bounds, legends[j].bounds, 0.5)) {
222
+ violations.push(
223
+ `legends overlap: ${legends[i].channel ?? 'color'}=${formatRect(legends[i].bounds)} ${legends[j].channel ?? 'color'}=${formatRect(legends[j].bounds)}`,
224
+ );
225
+ }
226
+ }
227
+ }
228
+
229
+ // 2. Every legend entryPosition box lies inside legend bounds
230
+ if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
231
+ const rowHeight = layout.legend.rowHeight ?? 16;
232
+ // Entry width includes trailing entryGap; the last entry in a row doesn't
233
+ // need that gap to fit inside bounds, so allow entryGap as extra tolerance.
234
+ const gapTolerance = layout.legend.entryGap + 1;
235
+ for (let i = 0; i < layout.legend.entryPositions.length; i++) {
236
+ const pos = layout.legend.entryPositions[i];
237
+ const entryBox: Rect = {
238
+ x: pos.x,
239
+ y: pos.y,
240
+ width: pos.width,
241
+ height: rowHeight,
242
+ };
243
+ if (!isContained(entryBox, layout.legend.bounds, gapTolerance)) {
244
+ violations.push(
245
+ `legend entry ${i} outside legend bounds: entry=${formatRect(entryBox)} bounds=${formatRect(layout.legend.bounds)}`,
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ // 3. X-axis label band doesn't intersect bottom chrome
252
+ const xExtent = layout.axes.x?.extent;
253
+ if (xExtent && xExtent > 0) {
254
+ const xAxisBand: Rect = {
255
+ x: layout.area.x,
256
+ y: layout.area.y + layout.area.height,
257
+ width: layout.area.width,
258
+ height: xExtent,
259
+ };
260
+ const bottomChromeKeys = ['source', 'byline', 'footer'] as const;
261
+ for (const key of bottomChromeKeys) {
262
+ const el = layout.chrome[key];
263
+ if (el) {
264
+ const h = el.style.fontSize * el.style.lineHeight;
265
+ const chromeBox: Rect = { x: el.x, y: el.y, width: el.maxWidth, height: h };
266
+ if (rectsOverlap(xAxisBand, chromeBox, 0.5)) {
267
+ violations.push(
268
+ `x-axis labels intersect ${key}: xAxis=${formatRect(xAxisBand)} ${key}=${formatRect(chromeBox)}`,
269
+ );
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ // 4. All boxes stay within SVG viewport
276
+ const viewport: Rect = {
277
+ x: 0,
278
+ y: 0,
279
+ width: opts.svgWidth,
280
+ height: opts.svgHeight,
281
+ };
282
+ const allBoxes = extractBoxes(layout);
283
+ for (const box of allBoxes) {
284
+ if (!isContained(box, viewport, 2)) {
285
+ violations.push(
286
+ `${box.name} outside SVG viewport: box=${formatRect(box)} viewport=${formatRect(viewport)}`,
287
+ );
288
+ }
289
+ }
290
+
291
+ // 5. Reserved margins cover drawn content
292
+ if (layout.area.y < layout.chrome.topHeight - 1) {
293
+ violations.push(
294
+ `top chrome overflows into chart area: area.y=${layout.area.y} chrome.topHeight=${layout.chrome.topHeight}`,
295
+ );
296
+ }
297
+
298
+ return violations;
299
+ }