@opendata-ai/openchart-engine 8.0.0-rc.8 → 8.0.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 (271) hide show
  1. package/dist/index.d.ts +149 -12
  2. package/dist/index.js +894 -171
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2339
  7. package/src/__tests__/annotation-behavior.test.ts +0 -852
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -376
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -545
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -626
  133. package/src/compile/size-scale.ts +0 -141
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1450
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -734
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -2041
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -1004
  185. package/src/geo/compile-map.ts +0 -1341
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -26
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -747
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. package/src/transforms/window.ts +0 -185
@@ -1,162 +0,0 @@
1
- /** Default font size for annotation labels. */
2
- export const DEFAULT_ANNOTATION_FONT_SIZE = 13;
3
-
4
- /**
5
- * Default font weight for annotation labels. Regular, not bold: emphasis comes
6
- * from inline `**bold**` spans, per the NYT/Datawrapper voice.
7
- */
8
- export const DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
9
-
10
- /**
11
- * Weight the primary text takes when a subtitle is present and the author set no
12
- * explicit `fontWeight` — the "Feb. 25 / 2015 maximum" lede + context stack.
13
- */
14
- export const LEDE_FONT_WEIGHT = 700;
15
-
16
- /** Subtitles are always regular, so they never inherit a lede weight. */
17
- export const SUBTITLE_FONT_WEIGHT = 400;
18
-
19
- /** Font stack used when the theme font isn't threaded through (SSR/legacy paths). */
20
- export const FALLBACK_FONT_FAMILY = 'Inter, system-ui, sans-serif';
21
-
22
- /** Default line height multiplier for annotation text. */
23
- export const DEFAULT_LINE_HEIGHT = 1.3;
24
-
25
- /** Default fill color for range annotations. */
26
- export const DEFAULT_RANGE_FILL = '#f0c040';
27
-
28
- /** Default opacity for range annotations. */
29
- export const DEFAULT_RANGE_OPACITY = 0.15;
30
-
31
- /** Default dash pattern for reference lines. */
32
- export const DEFAULT_REFLINE_DASH = '4 3';
33
-
34
- // Theme-aware defaults for text and stroke colors
35
- export const LIGHT_TEXT_FILL = '#333333';
36
- export const DARK_TEXT_FILL = '#d1d5db';
37
- export const LIGHT_REFLINE_STROKE = '#888888';
38
- export const DARK_REFLINE_STROKE = '#9ca3af';
39
-
40
- // Quiet connector voice: gray hairline leaders (non-arrowed straight, drop-line).
41
- // Arrowed connectors take the label's text ink instead, so the emphasis callout
42
- // reads as one gesture with the words.
43
- export const LIGHT_CONNECTOR_STROKE = '#8f8f8f';
44
- export const DARK_CONNECTOR_STROKE = '#9aa0a6';
45
-
46
- // Muted text fill for annotation subtitles (~60% perceived contrast vs primary)
47
- export const LIGHT_MUTED_TEXT_FILL = '#6b7280';
48
- export const DARK_MUTED_TEXT_FILL = '#9ca3af';
49
-
50
- // Background fills used as the default "open ring" dot interior
51
- export const LIGHT_DOT_FILL = '#ffffff';
52
- export const DARK_DOT_FILL = '#0a0a0a';
53
-
54
- // Surface fills for the text-annotation background plate (the mask that hides
55
- // chart lines behind label text). `background: true` resolves to these, so
56
- // authors don't hardcode '#ffffff' and get light-gray-on-white in dark mode.
57
- export const LIGHT_LABEL_BACKGROUND = '#ffffff';
58
- export const DARK_LABEL_BACKGROUND = '#0a0a0a';
59
-
60
- /** Default annotation dot radius in pixels. */
61
- export const DEFAULT_DOT_RADIUS = 4;
62
-
63
- /** Default annotation dot stroke width in pixels. */
64
- export const DEFAULT_DOT_STROKE_WIDTH = 1.5;
65
-
66
- /** Vertical gap (px) between the primary annotation text and its subtitle. */
67
- export const SUBTITLE_GAP = 2;
68
-
69
- /** Subtitle font size multiplier (relative to primary annotation font size). */
70
- export const SUBTITLE_FONT_SIZE_RATIO = 0.85;
71
-
72
- /**
73
- * The subtitle's resolved font size. Rounded, and shared by every caller: the
74
- * auto-placement pass measures the subtitle to score candidate bounds while the
75
- * resolver sets the size that actually renders. If one rounds and the other
76
- * doesn't (13 * 0.85 = 11.05 vs 11), the scored bounds are a lie and the drift
77
- * flows into collisions and thinning.
78
- */
79
- export function subtitleFontSize(primaryFontSize: number): number {
80
- return Math.round(primaryFontSize * SUBTITLE_FONT_SIZE_RATIO);
81
- }
82
-
83
- /**
84
- * The subtitle's baseline, given the label's. Same contract as `subtitleFontSize`,
85
- * and shared for the same reason: the auto-placement pass SCORES a candidate
86
- * subtitle position and the resolver STAMPS the one that renders. Two copies of
87
- * this sum is two chances to disagree -- and they did, one spelling the gap
88
- * `SUBTITLE_GAP` and the other a bare `+ 2`.
89
- *
90
- * `primaryLineHeightPx` is the line's height in PIXELS, not the ratio -- so a caller
91
- * holding a ratio multiplies by the font size on the way in. Taking the two factors
92
- * separately meant the one caller that already had the product had to pass a magic
93
- * `1` for the font size, and a signature you have to outsmart is a signature that
94
- * will eventually be outsmarted wrong.
95
- */
96
- export function subtitleBaselineY(
97
- labelY: number,
98
- primaryLineHeightPx: number,
99
- primaryLineCount: number,
100
- ): number {
101
- return labelY + primaryLineHeightPx * primaryLineCount + SUBTITLE_GAP;
102
- }
103
-
104
- /** Gap (px) between the label bounding box and the start of its connector. */
105
- export const CONNECTOR_STANDOFF = 6;
106
-
107
- /**
108
- * Connectors shorter than this are suppressed: a nub between a label and the
109
- * marker it's already touching reads as noise, not as a leader.
110
- *
111
- * Keep this well under the standoff + marker-pullback overhead (~18px), which
112
- * is spent before the line is drawn at all. Setting it near or above that
113
- * overhead makes the default annotation structurally unable to draw a
114
- * connector, however far the author offsets the label.
115
- */
116
- export const MIN_CONNECTOR_LENGTH = 8;
117
-
118
- /**
119
- * Arrowhead length along the tangent. The renderer pulls the connector's line end
120
- * back by exactly this much so the stroke stops at the open V instead of poking
121
- * through its tip — a hand-copied duplicate on the vanilla side would silently
122
- * drift the next time this changes.
123
- */
124
- export const ARROWHEAD_LENGTH = 7;
125
-
126
- /** Arrowhead half-width perpendicular to the tangent. */
127
- export const ARROWHEAD_HALF_WIDTH = 3.5;
128
-
129
- /**
130
- * Is a connector of this length worth drawing?
131
- *
132
- * An arrowed connector doesn't stroke its full length: the renderer stops the line
133
- * `ARROWHEAD_LENGTH` short and spends that budget on the head. So a bare
134
- * `length >= MIN_CONNECTOR_LENGTH` test is arrow-blind — an 8.1px arrowed
135
- * connector clears it, and then ships a 1.1px stub with an arrowhead stuck on the
136
- * end. The minimum has to be measured against the part that actually gets stroked,
137
- * which means the gate has to know about the head.
138
- *
139
- * Both suppression sites (`resolveTextAnnotation`, `refreshConnector`) call this
140
- * instead of comparing against the constant themselves.
141
- */
142
- export function connectorIsDrawable(length: number, arrow: boolean | undefined): boolean {
143
- return length - (arrow ? ARROWHEAD_LENGTH : 0) >= MIN_CONNECTOR_LENGTH;
144
- }
145
-
146
- /**
147
- * Default label setback from the data point when using anchor directions.
148
- *
149
- * Sized so a bare `{ type: 'text' }` annotation lands clear of its marker and
150
- * draws a real leader with no offset authoring — a minimal spec should render
151
- * publication-ready. Authors tighten it with `offset`.
152
- */
153
- export const ANCHOR_OFFSET = 28;
154
-
155
- /** Vertical gap between the top of a drop-line and the top of its label box. */
156
- export const DROP_LINE_TOP_GAP = 4;
157
-
158
- /** Padding between annotation and obstacle when nudging. */
159
- export const NUDGE_PADDING = 6;
160
-
161
- /** Small inset margin so labels don't touch the SVG edge. */
162
- export const CLAMP_MARGIN = 4;
@@ -1,376 +0,0 @@
1
- /**
2
- * Geometry utilities for annotation text bounds, connector origins, and offsets.
3
- */
4
-
5
- import type {
6
- AnnotationAnchor,
7
- AnnotationOffset,
8
- Rect,
9
- ResolvedLabel,
10
- } from '@opendata-ai/openchart-core';
11
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
- import {
13
- ANCHOR_OFFSET,
14
- ARROWHEAD_HALF_WIDTH,
15
- ARROWHEAD_LENGTH,
16
- CONNECTOR_STANDOFF,
17
- connectorIsDrawable,
18
- DEFAULT_ANNOTATION_FONT_SIZE,
19
- DEFAULT_ANNOTATION_FONT_WEIGHT,
20
- DEFAULT_LINE_HEIGHT,
21
- DROP_LINE_TOP_GAP,
22
- } from './constants';
23
- import { BOLD_SPAN_FONT_WEIGHT, parseAnnotationSpans } from './rich-text';
24
-
25
- /**
26
- * Measure one run of text at a given size and weight.
27
- *
28
- * Deliberately takes no `fontFamily`: neither implementation could use one.
29
- * `heuristicMeasure` below is family-blind, and the real canvas measurer gets its
30
- * family from the `createMeasureText(fontFamily)` closure, not per call -- so a
31
- * family passed here would be silently dropped while implying the opposite.
32
- * `style.fontFamily` on a *resolved label* is a different thing: that one is real
33
- * and gets rendered.
34
- */
35
- export type AnnotationMeasureTextFn = (
36
- text: string,
37
- font: { fontSize: number; fontWeight: number },
38
- ) => number;
39
-
40
- export const heuristicMeasure: AnnotationMeasureTextFn = (text, { fontSize, fontWeight }) =>
41
- estimateTextWidth(text, fontSize, fontWeight);
42
-
43
- /**
44
- * Width of one line of annotation text, `**bold**` spans included: each span is
45
- * measured at its own weight and the widths are summed. Measuring the raw string
46
- * would count the `**` markers and use one weight for the whole line, so bounds,
47
- * collisions, placement, and connector exits would all be wrong for rich text.
48
- */
49
- export function measureRichLine(
50
- line: string,
51
- style: { fontSize: number; fontWeight: number },
52
- measure: AnnotationMeasureTextFn,
53
- ): number {
54
- const spans = parseAnnotationSpans(line);
55
- let width = 0;
56
- for (const span of spans) {
57
- width += measure(span.text, {
58
- fontSize: style.fontSize,
59
- fontWeight: span.bold ? BOLD_SPAN_FONT_WEIGHT : style.fontWeight,
60
- });
61
- }
62
- return width;
63
- }
64
-
65
- /**
66
- * Compute the bounding box of a text block, aware of textAnchor and multi-line layout.
67
- * labelY is the first-line baseline.
68
- */
69
- export function computeTextBlockBounds(
70
- labelX: number,
71
- labelY: number,
72
- text: string,
73
- style: {
74
- fontSize: number;
75
- fontWeight: number;
76
- lineHeight: number;
77
- textAnchor?: 'start' | 'middle' | 'end';
78
- fontFamily?: string;
79
- },
80
- measure: AnnotationMeasureTextFn = heuristicMeasure,
81
- ): Rect {
82
- const lines = text.split('\n');
83
- const maxWidth = Math.max(...lines.map((line) => measureRichLine(line, style, measure)));
84
- const height =
85
- style.fontSize + (lines.length - 1) * style.fontSize * style.lineHeight + style.fontSize * 0.3;
86
-
87
- let x: number;
88
- if (style.textAnchor === 'middle') {
89
- x = labelX - maxWidth / 2;
90
- } else if (style.textAnchor === 'end') {
91
- x = labelX - maxWidth;
92
- } else {
93
- x = labelX;
94
- }
95
-
96
- return {
97
- x,
98
- y: labelY - style.fontSize,
99
- width: maxWidth,
100
- height,
101
- };
102
- }
103
-
104
- export function unionRects(a: Rect, b: Rect): Rect {
105
- const x = Math.min(a.x, b.x);
106
- const y = Math.min(a.y, b.y);
107
- return {
108
- x,
109
- y,
110
- width: Math.max(a.x + a.width, b.x + b.width) - x,
111
- height: Math.max(a.y + a.height, b.y + b.height) - y,
112
- };
113
- }
114
-
115
- /**
116
- * Apply anchor direction to compute label offset from data point.
117
- * Returns { dx, dy } pixel offsets.
118
- */
119
- export function computeAnchorOffset(
120
- anchor: AnnotationAnchor | undefined,
121
- _px: number,
122
- py: number,
123
- chartArea: Rect,
124
- ): { dx: number; dy: number } {
125
- if (!anchor || anchor === 'auto') {
126
- // Auto: place above if in the lower half, below if upper half
127
- const isUpperHalf = py < chartArea.y + chartArea.height / 2;
128
- return isUpperHalf
129
- ? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } // below-right
130
- : { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET }; // above-right
131
- }
132
-
133
- switch (anchor) {
134
- case 'top':
135
- return { dx: 0, dy: -ANCHOR_OFFSET };
136
- case 'bottom':
137
- return { dx: 0, dy: ANCHOR_OFFSET };
138
- case 'left':
139
- return { dx: -ANCHOR_OFFSET, dy: 0 };
140
- case 'right':
141
- return { dx: ANCHOR_OFFSET, dy: 0 };
142
- }
143
- }
144
-
145
- /** Apply user offset on top of computed anchor offset. */
146
- export function applyOffset(
147
- base: { dx: number; dy: number },
148
- offset: AnnotationOffset | undefined,
149
- ): { dx: number; dy: number } {
150
- if (!offset) return base;
151
- return {
152
- dx: base.dx + (offset.dx ?? 0),
153
- dy: base.dy + (offset.dy ?? 0),
154
- };
155
- }
156
-
157
- /** Inflation applied to the label box before intersecting the connector ray. */
158
- const BOX_INFLATE = 2;
159
-
160
- /**
161
- * Compute where a connector leaves the annotation's text block on its way to the
162
- * target. Casts a ray from the box center toward the target, intersects the
163
- * (slightly inflated) box, then advances `standoff` px along the ray so the line
164
- * never touches the glyphs.
165
- *
166
- * Returns `null` when the target sits inside the inflated box (a connector would
167
- * cross its own text) or when the standoff would overshoot the target.
168
- */
169
- export function connectorExit(
170
- bounds: Rect,
171
- targetX: number,
172
- targetY: number,
173
- standoff = CONNECTOR_STANDOFF,
174
- ): { x: number; y: number; exit: 'horizontal' | 'vertical' } | null {
175
- const box = {
176
- x: bounds.x - BOX_INFLATE,
177
- y: bounds.y - BOX_INFLATE,
178
- width: bounds.width + BOX_INFLATE * 2,
179
- height: bounds.height + BOX_INFLATE * 2,
180
- };
181
- const cx = box.x + box.width / 2;
182
- const cy = box.y + box.height / 2;
183
- const halfW = box.width / 2;
184
- const halfH = box.height / 2;
185
-
186
- const dx = targetX - cx;
187
- const dy = targetY - cy;
188
-
189
- // Target inside the inflated box: any connector would cross the text.
190
- if (Math.abs(dx) <= halfW && Math.abs(dy) <= halfH) return null;
191
- if (dx === 0 && dy === 0) return null;
192
-
193
- // Parametric ray: center + t * (dx, dy). Find the smallest t that lands on an
194
- // edge of the box, and which axis that edge belongs to.
195
- const tx = halfW > 0 && dx !== 0 ? halfW / Math.abs(dx) : Number.POSITIVE_INFINITY;
196
- const ty = halfH > 0 && dy !== 0 ? halfH / Math.abs(dy) : Number.POSITIVE_INFINITY;
197
- const t = Math.min(tx, ty);
198
- if (!Number.isFinite(t)) return null;
199
-
200
- const exit: 'horizontal' | 'vertical' = tx <= ty ? 'horizontal' : 'vertical';
201
-
202
- const edgeX = cx + dx * t;
203
- const edgeY = cy + dy * t;
204
-
205
- // Advance along the ray by the standoff distance.
206
- const rayLen = Math.sqrt(dx * dx + dy * dy);
207
- const ux = dx / rayLen;
208
- const uy = dy / rayLen;
209
-
210
- // Overshoot guard: the standoff must not push the origin past the target.
211
- // Measure edge-to-target in pixels rather than scaling the ray by (1 - t):
212
- // t is a ratio of box extents, so that form understates the clearance when the
213
- // ray leaves through the long side of a wide label, and killed connectors that
214
- // had ample room. Whether the line is long enough to be worth drawing is
215
- // decided downstream against MIN_CONNECTOR_LENGTH, once the marker pullback is
216
- // known — this guard only rejects a standoff that would overshoot the target.
217
- const edgeToTarget = Math.hypot(targetX - edgeX, targetY - edgeY);
218
- if (standoff >= edgeToTarget) return null;
219
-
220
- const x = edgeX + ux * standoff;
221
- const y = edgeY + uy * standoff;
222
-
223
- return { x, y, exit };
224
- }
225
-
226
- /** Estimate the bounding box of an annotation label using its resolved style. */
227
- export function estimateLabelBounds(
228
- label: ResolvedLabel,
229
- measure: AnnotationMeasureTextFn = heuristicMeasure,
230
- ): Rect {
231
- const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
232
- const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
233
- return computeTextBlockBounds(
234
- label.x,
235
- label.y,
236
- label.text,
237
- {
238
- fontSize,
239
- fontWeight,
240
- lineHeight: label.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
241
- textAnchor: label.style.textAnchor ?? 'start',
242
- fontFamily: label.style.fontFamily,
243
- },
244
- measure,
245
- );
246
- }
247
-
248
- /** Arrowhead triangle points: tip + two base corners. */
249
- export interface ArrowheadPoints {
250
- tip: { x: number; y: number };
251
- baseLeft: { x: number; y: number };
252
- baseRight: { x: number; y: number };
253
- }
254
-
255
- // Both live in `constants.ts` (the min-length gate has to reason about the
256
- // arrowhead's stroke budget, and constants.ts can't import from here without a
257
- // cycle). Re-exported so the renderer's import path doesn't move.
258
- export { ARROWHEAD_HALF_WIDTH, ARROWHEAD_LENGTH };
259
-
260
- /**
261
- * Compute arrowhead triangle geometry at a connector endpoint.
262
- * Returns the tip (at the endpoint) and two base corners perpendicular to the
263
- * tangent direction.
264
- *
265
- * @param tipX - X coordinate of the arrowhead tip
266
- * @param tipY - Y coordinate of the arrowhead tip
267
- * @param tangentX - X component of the tangent direction (toward the tip)
268
- * @param tangentY - Y component of the tangent direction (toward the tip)
269
- * @param length - Arrow length along the tangent (default ARROWHEAD_LENGTH)
270
- * @param halfWidth - Arrow half-width perpendicular to tangent (default ARROWHEAD_HALF_WIDTH)
271
- */
272
- export function computeArrowheadPoints(
273
- tipX: number,
274
- tipY: number,
275
- tangentX: number,
276
- tangentY: number,
277
- length = ARROWHEAD_LENGTH,
278
- halfWidth = ARROWHEAD_HALF_WIDTH,
279
- ): ArrowheadPoints {
280
- const tLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY) || 1;
281
- const ux = tangentX / tLen;
282
- const uy = tangentY / tLen;
283
-
284
- const baseX = tipX - ux * length;
285
- const baseY = tipY - uy * length;
286
-
287
- const px = -uy;
288
- const py = ux;
289
-
290
- return {
291
- tip: { x: tipX, y: tipY },
292
- baseLeft: { x: baseX + px * halfWidth, y: baseY + py * halfWidth },
293
- baseRight: { x: baseX - px * halfWidth, y: baseY - py * halfWidth },
294
- };
295
- }
296
-
297
- /**
298
- * Distance the connector's data-point end is pulled back so the line stops short
299
- * of the endpoint marker instead of piercing it.
300
- */
301
- export function connectorPullbackGap(markerRadius: number, arrow: boolean): number {
302
- return arrow ? Math.max(markerRadius + 2, 2) : Math.max(markerRadius + 3, 4);
303
- }
304
-
305
- /**
306
- * Rebuild a connector's geometry against a (possibly moved) annotation bounds.
307
- *
308
- * `from` comes from the ray-box exit; `to` is recomputed by pulling back from
309
- * `connector.endpoint` — never from the already-pulled-back `to`, which would
310
- * shrink the line a little more on every pass.
311
- *
312
- * Returns `undefined` when the connector should be suppressed: the target sits
313
- * inside the text block, or the resulting line is shorter than
314
- * `MIN_CONNECTOR_LENGTH`.
315
- */
316
- export function refreshConnector(
317
- connector: NonNullable<ResolvedLabel['connector']>,
318
- bounds: Rect,
319
- markerRadius: number,
320
- ): ResolvedLabel['connector'] {
321
- // `endpoint` is the un-pulled-back data point, and the only valid basis for a
322
- // rebuild: pulling back from `to` (already pulled back) shrinks the line a
323
- // little more on every pass. Every resolver sets it, but the field is optional
324
- // on the public `ResolvedLabel` type, so a layout from another producer could
325
- // omit it — bail rather than silently compound the error.
326
- if (!connector.endpoint) return undefined;
327
- const endpoint = connector.endpoint;
328
-
329
- // A drop-line is not a leader: it's a vertical rule pinned to the data point's
330
- // x, running from just above the label box down to the marker. Rebuilding it
331
- // from a ray-box exit tilts it into a diagonal (which the renderer draws with
332
- // shape-rendering: crispEdges, assuming axis alignment), and the
333
- // MIN_CONNECTOR_LENGTH rule below would delete it outright once the label sits
334
- // near its point.
335
- //
336
- // This mirrors `resolveDropLineAnnotation`'s formula exactly — `from` above the
337
- // label top, `to` pulled back from the point — so a nudged drop-line keeps the
338
- // same geometry it resolved with, just at the label's new position.
339
- if (connector.style === 'drop-line') {
340
- const gap = connectorPullbackGap(markerRadius, false);
341
- return {
342
- ...connector,
343
- from: { x: endpoint.x, y: bounds.y - DROP_LINE_TOP_GAP },
344
- to: { x: endpoint.x, y: endpoint.y - gap },
345
- exit: 'vertical',
346
- };
347
- }
348
-
349
- const exit = connectorExit(bounds, endpoint.x, endpoint.y);
350
- if (!exit) return undefined;
351
-
352
- // Re-apply the author's from-end nudge BEFORE the pullback, so the pullback
353
- // direction is measured from the real origin -- exactly as
354
- // `resolveTextAnnotation` does on first resolve.
355
- const from = {
356
- x: exit.x + (connector.fromOffset?.dx ?? 0),
357
- y: exit.y + (connector.fromOffset?.dy ?? 0),
358
- };
359
-
360
- const gap = connectorPullbackGap(markerRadius, connector.arrow);
361
- const cdx = endpoint.x - from.x;
362
- const cdy = endpoint.y - from.y;
363
- const dist = Math.sqrt(cdx * cdx + cdy * cdy);
364
- const to =
365
- dist > 0
366
- ? { x: endpoint.x - (cdx / dist) * gap, y: endpoint.y - (cdy / dist) * gap }
367
- : { ...endpoint };
368
-
369
- // Min-length check runs AFTER the pullback: a near-degenerate line can flip
370
- // direction once its end is pulled back, and checking first would miss it.
371
- const lx = to.x - from.x;
372
- const ly = to.y - from.y;
373
- if (!connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connector.arrow)) return undefined;
374
-
375
- return { ...connector, from, to, exit: exit.exit };
376
- }
@@ -1,135 +0,0 @@
1
- /**
2
- * The single owner of "where a resolved annotation sits".
3
- *
4
- * WHY THIS MODULE EXISTS
5
- *
6
- * An annotation's position is not one number, it's five that have to agree: the
7
- * label origin, the subtitle origin (absolute, not relative), the label-only
8
- * bounds, the label-union-subtitle block bounds, and the connector endpoints. The
9
- * resolver establishes that agreement -- and then FOUR later passes re-position
10
- * the block (obstacle nudge, mutual-collision nudge, clamp-to-bounds, and the
11
- * auto-placement search). Every one of them has to re-establish all five.
12
- *
13
- * When two of those passes each did their own arithmetic, the two copies drifted:
14
- * one spelled the subtitle gap `SUBTITLE_GAP`, the other spelled it `+ 2`, and a
15
- * subtitled auto-placed callout rendered its second line 3px off from where the
16
- * search had scored it. That is the bug this module exists to make unrepresentable.
17
- *
18
- * So: nothing outside this file writes annotation geometry. `moveAnnotationTo`
19
- * (absolute) and `moveAnnotationBy` (delta) are the only doors, and they always
20
- * leave all five in agreement.
21
- */
22
-
23
- import type { Rect, ResolvedAnnotation, ResolvedLabel } from '@opendata-ai/openchart-core';
24
- import {
25
- DEFAULT_ANNOTATION_FONT_SIZE,
26
- DEFAULT_LINE_HEIGHT,
27
- SUBTITLE_FONT_WEIGHT,
28
- subtitleFontSize,
29
- } from './constants';
30
- import {
31
- type AnnotationMeasureTextFn,
32
- computeTextBlockBounds,
33
- estimateLabelBounds,
34
- refreshConnector,
35
- unionRects,
36
- } from './geometry';
37
- import { markerClearance } from './resolve-text';
38
-
39
- /**
40
- * The full block an annotation occupies: label text plus its subtitle.
41
- *
42
- * This is the box other annotations must avoid, and the box the connector exits.
43
- * It is NOT `label.bounds` -- that one is label-only by contract, because the
44
- * renderer sizes the `background` plate from it and a subtitled annotation must
45
- * not get a plate sized to both lines.
46
- */
47
- export function annotationBlockBounds(
48
- annotation: ResolvedAnnotation,
49
- label: ResolvedLabel,
50
- measure: AnnotationMeasureTextFn,
51
- ): Rect {
52
- const labelBounds = estimateLabelBounds(label, measure);
53
- const sub = annotation.subtitle;
54
- if (!sub) return labelBounds;
55
-
56
- const subBounds = computeTextBlockBounds(
57
- sub.x,
58
- sub.y,
59
- sub.text,
60
- {
61
- // Every resolver stamps all three, so these fallbacks are unreachable today.
62
- // They still come from the constants: a hand-rolled `?? 10` here is a second
63
- // definition of the subtitle's type, free to drift from the real one.
64
- fontSize: sub.style.fontSize ?? subtitleFontSize(DEFAULT_ANNOTATION_FONT_SIZE),
65
- fontWeight: Number(sub.style.fontWeight) || SUBTITLE_FONT_WEIGHT,
66
- lineHeight: sub.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
67
- textAnchor: sub.style.textAnchor ?? 'start',
68
- },
69
- measure,
70
- );
71
- return unionRects(labelBounds, subBounds);
72
- }
73
-
74
- /**
75
- * Put a resolved annotation's block at an absolute origin, keeping every piece of
76
- * its geometry in agreement.
77
- *
78
- * The subtitle is TRANSLATED with the label rather than re-derived from a font-metric
79
- * sum: the resolver already placed it correctly relative to the label, and a second
80
- * derivation here is a second chance to disagree with the first.
81
- *
82
- * `textAnchor` is optional because only the auto-placement search chooses a side;
83
- * the nudge passes slide a block that has already picked one.
84
- */
85
- export function moveAnnotationTo(
86
- annotation: ResolvedAnnotation,
87
- label: ResolvedLabel,
88
- x: number,
89
- y: number,
90
- measure: AnnotationMeasureTextFn,
91
- textAnchor?: 'start' | 'middle' | 'end',
92
- ): void {
93
- moveAnnotationBy(annotation, label, x - label.x, y - label.y, measure, textAnchor);
94
- }
95
-
96
- /**
97
- * Slide a resolved annotation's block by (dx, dy), keeping every piece of its
98
- * geometry in agreement. See `moveAnnotationTo`.
99
- */
100
- export function moveAnnotationBy(
101
- annotation: ResolvedAnnotation,
102
- label: ResolvedLabel,
103
- dx: number,
104
- dy: number,
105
- measure: AnnotationMeasureTextFn,
106
- textAnchor?: 'start' | 'middle' | 'end',
107
- ): void {
108
- const movedLabel: ResolvedLabel = { ...label, x: label.x + dx, y: label.y + dy };
109
- if (textAnchor) {
110
- movedLabel.style = { ...movedLabel.style, textAnchor };
111
- }
112
-
113
- if (annotation.subtitle) {
114
- annotation.subtitle = {
115
- ...annotation.subtitle,
116
- x: annotation.subtitle.x + dx,
117
- y: annotation.subtitle.y + dy,
118
- ...(textAnchor ? { style: { ...annotation.subtitle.style, textAnchor } } : {}),
119
- };
120
- }
121
-
122
- movedLabel.bounds = estimateLabelBounds(movedLabel, measure);
123
- const blockBounds = annotationBlockBounds(annotation, movedLabel, measure);
124
-
125
- if (movedLabel.connector) {
126
- movedLabel.connector = refreshConnector(
127
- movedLabel.connector,
128
- blockBounds,
129
- markerClearance(annotation.dot),
130
- );
131
- }
132
-
133
- annotation.label = movedLabel;
134
- annotation.bounds = blockBounds;
135
- }