@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20

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 +140 -2
  2. package/dist/index.js +1403 -210
  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 -721
  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 -370
  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 -539
  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 -139
  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 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  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 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  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 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  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,407 +0,0 @@
1
- /**
2
- * Size legend: graduated circles keying a quantitative `size` encoding.
3
- *
4
- * Without this, `size` is an *unkeyed* channel: a bubble chart shows circles of
5
- * varying area and gives the reader nothing to decode them with.
6
- *
7
- * Three rules drive the module, and they are all about not lying to the reader.
8
- *
9
- * **Resolve the same scale the marks resolve.** The values come from
10
- * `buildSizeScale` -- the identical builder `scatter/compute.ts` and
11
- * `beeswarm/compute.ts` call -- rather than a re-derivation. A key computed
12
- * independently of the marks is a key that can silently drift from them.
13
- * (`continuous.ts` mirrors the color path for exactly this reason.)
14
- *
15
- * **Key the domain, not the data extent.** The size scale clamps. With an
16
- * explicit `scale.domain`, every datum past `domain[1]` renders at max radius,
17
- * so a circle labeled with the largest *datum* would imply a correspondence that
18
- * doesn't hold. The domain is what the scale actually promises.
19
- *
20
- * **A key you can't see keys nothing.** The one place the drawn circles are
21
- * allowed to depart from the marks: when the mark's radius range is too small to
22
- * read (a beeswarm's [2, 10] is a 20px smudge), the circles are redrawn across
23
- * `MIN_LEGIBLE_RANGE`. The labels still carry the true values -- only the swatch
24
- * is scaled up, and only when the literal version would be illegible.
25
- */
26
-
27
- import type {
28
- DataRow,
29
- EncodingChannel,
30
- LegendLayout,
31
- Rect,
32
- ResolvedTheme,
33
- SizeLegendCircle,
34
- SizeLegendLayout,
35
- } from '@opendata-ai/openchart-core';
36
- import {
37
- abbreviateNumber,
38
- buildD3Formatter,
39
- estimateTextWidth,
40
- formatNumber,
41
- } from '@opendata-ai/openchart-core';
42
-
43
- import { scaleLinear, scaleSqrt } from 'd3-scale';
44
-
45
- import { buildSizeScale, type SizeCurve, sizeScaleDefaultsFor } from '../compile/size-scale';
46
-
47
- // ---------------------------------------------------------------------------
48
- // Constants
49
- // ---------------------------------------------------------------------------
50
-
51
- /** Gap between the circle stack and its labels. */
52
- const LABEL_GAP = 6;
53
-
54
- /** Padding below the baseline so the largest circle's stroke isn't clipped. */
55
- const BASELINE_PAD = 2;
56
-
57
- /** How many circles to key. Three reads as a scale; two reads as a comparison. */
58
- const CIRCLE_COUNT = 3;
59
-
60
- /**
61
- * Smallest radius range the drawn circles are allowed to occupy.
62
- *
63
- * A beeswarm sizes its dots [2, 10]: rendering the key at those radii puts three
64
- * labels next to a 20px smudge that reads as no key at all. When the mark's own
65
- * range is smaller than this, the circles are redrawn across [4, 18] -- still
66
- * ordered and still proportional to each other, but big enough to see. The key
67
- * becomes *indicative* rather than a 1:1 swatch of the marks, which is the right
68
- * trade: a key you can't see keys nothing. Marks that already exceed this (a
69
- * scatter's [3, 30]) are drawn at their true radii and match the bubbles exactly.
70
- */
71
- const MIN_LEGIBLE_RANGE: readonly [number, number] = [4, 18];
72
-
73
- /** Gap between the plot's right edge and the size legend column. */
74
- export const SIZE_LEGEND_GAP = 12;
75
-
76
- /**
77
- * Vertical gap between the color legend and the size legend stacked beneath it.
78
- *
79
- * Bigger than the row gap *inside* either block, so the two read as two keys in
80
- * one column rather than one long key whose last few rows happen to be circles.
81
- */
82
- export const SIZE_LEGEND_STACK_GAP = 16;
83
-
84
- // ---------------------------------------------------------------------------
85
- // Types
86
- // ---------------------------------------------------------------------------
87
-
88
- /** Pre-placement size legend content (origin-relative coordinates). */
89
- export interface SizeLegendContent {
90
- circles: SizeLegendCircle[];
91
- /** Total width of the legend block. */
92
- width: number;
93
- /** Total height of the legend block. */
94
- height: number;
95
- }
96
-
97
- // ---------------------------------------------------------------------------
98
- // Helpers
99
- // ---------------------------------------------------------------------------
100
-
101
- /**
102
- * A radius scale over an explicit range, bypassing the encoding's own
103
- * `scale.range` (which `buildSizeScale` honours, and which is exactly the
104
- * too-small range the legible floor exists to escape). Same curve and clamping
105
- * as the mark scale, so the circles stay proportional to each other.
106
- */
107
- function buildRadiusScale(
108
- curve: SizeCurve,
109
- domain: [number, number],
110
- range: readonly [number, number],
111
- ): (value: number) => number {
112
- const build = curve === 'linear' ? scaleLinear : scaleSqrt;
113
- const scale = build().domain(domain).range([range[0], range[1]]).clamp(true);
114
- return (value: number) => scale(value);
115
- }
116
-
117
- /** Format a legend value: channel format wins, then the house number style. */
118
- function formatValue(value: number, formatStr?: string): string {
119
- if (formatStr) {
120
- const fmt = buildD3Formatter(formatStr);
121
- if (fmt) return fmt(value);
122
- }
123
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
124
- return formatNumber(value);
125
- }
126
-
127
- /**
128
- * Round a value down to one significant-ish figure (1, 2, 5 x 10^n).
129
- *
130
- * Real graduated-circle keys show round numbers -- "500M", not "487.3M". A
131
- * naive min/median/max would also cluster badly on the skewed (log-normal)
132
- * data that bubble charts are usually made of: population min/median/max might
133
- * be 300k / 9M / 1.4B, and under a sqrt scale the first two are visually
134
- * identical. Nice-rounding off the domain's top spreads the circles out.
135
- */
136
- function niceFloor(value: number): number {
137
- if (!Number.isFinite(value) || value <= 0) return value;
138
- const magnitude = 10 ** Math.floor(Math.log10(value));
139
- const normalized = value / magnitude;
140
- const step = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
141
- return step * magnitude;
142
- }
143
-
144
- /**
145
- * Pick the values to key, largest first. At most `CIRCLE_COUNT` of them.
146
- *
147
- * **Spaced evenly in radius, not in value.** Radius is what the reader actually
148
- * sees, and the mapping to it is non-linear (sqrt, so circle *area* tracks the
149
- * value). Stepping the value by halves or by a constant ratio therefore bunches
150
- * the small circles together: on `[300k, 1.4B]` the geometric midpoint is 20M,
151
- * whose radius sits a couple of pixels off the floor. Walking the *radius* range
152
- * in equal steps and inverting back to a value spreads the circles out the way a
153
- * published graduated-circle key does.
154
- *
155
- * The domain top is always keyed first: it is the one value the maximum radius
156
- * corresponds to, round number or not.
157
- *
158
- * Interior values are then nice-rounded, but only when the rounded number still
159
- * lands inside the domain and doesn't collapse onto the neighbour above --
160
- * `niceFloor(95)` is 50, which on `[90, 100]` is a value the scale never renders.
161
- * Prettiness loses to being true.
162
- */
163
- function pickValues(domain: [number, number], scale: (v: number) => number): number[] {
164
- const [lo, hi] = domain;
165
- const rHi = scale(hi);
166
- const rLo = scale(lo);
167
- const out: number[] = [hi];
168
-
169
- // Invert the scale numerically: it is monotone on [lo, hi], so bisection finds
170
- // the value for a target radius without needing the scale's own `.invert`
171
- // (which `buildSizeScale` doesn't surface, and which wouldn't exist for a
172
- // clamped custom curve anyway).
173
- const valueAtRadius = (target: number): number => {
174
- let a = lo;
175
- let b = hi;
176
- for (let i = 0; i < 40; i++) {
177
- const mid = (a + b) / 2;
178
- if (scale(mid) < target) a = mid;
179
- else b = mid;
180
- }
181
- return (a + b) / 2;
182
- };
183
-
184
- for (let i = 1; i < CIRCLE_COUNT; i++) {
185
- const targetRadius = rHi - ((rHi - rLo) * i) / (CIRCLE_COUNT - 1);
186
- // Bisection lands on float dust (89.99999...), which formats as "90.00".
187
- // Snap to 3 significant figures so the fallback label is readable even when
188
- // it isn't a round number.
189
- const raw = toPrecision(valueAtRadius(targetRadius), 3);
190
- const nice = niceFloor(raw);
191
- const prev = out[out.length - 1];
192
- // Take the round number only when it's in-domain and still distinct.
193
- const value = nice >= lo && nice <= hi && nice < prev ? nice : raw;
194
- if (!Number.isFinite(value) || value <= 0) continue;
195
- if (value < lo || value >= prev) continue;
196
- // A zero-anchored domain bisects toward the floor and lands on a value that
197
- // is positive only in the float sense (1e-9 of the domain). A bubble of ~zero
198
- // magnitude has no area to key, so drop it rather than label a dot "0".
199
- if (value < hi * 1e-6) continue;
200
- out.push(value);
201
- }
202
- return out;
203
- }
204
-
205
- /** Round to `digits` significant figures. */
206
- function toPrecision(value: number, digits: number): number {
207
- if (!Number.isFinite(value) || value === 0) return value;
208
- const magnitude = 10 ** (Math.floor(Math.log10(Math.abs(value))) - digits + 1);
209
- return Math.round(value / magnitude) * magnitude;
210
- }
211
-
212
- // ---------------------------------------------------------------------------
213
- // Public API
214
- // ---------------------------------------------------------------------------
215
-
216
- /** True when the spec encodes a quantitative `size` channel worth keying. */
217
- export function hasSizeChannel(encoding: { size?: unknown } | undefined): boolean {
218
- const size = encoding?.size as EncodingChannel | undefined;
219
- return !!size && 'field' in size && !!size.field && size.type === 'quantitative';
220
- }
221
-
222
- /**
223
- * Marks whose `size` channel is a *keyed* encoding -- one a reader is meant to
224
- * decode back into a value, and which therefore earns a legend.
225
- *
226
- * `point` and `beeswarm` map size to circle area: keyable, and the graduated
227
- * circles below key it.
228
- *
229
- * `text` is deliberately absent. It maps size to *font size*, and no publication
230
- * legends font size -- a stack of type at 12/24/48px reads as a font-sample
231
- * sheet, not a key. Size on a text mark is emphasis, not a channel a reader
232
- * decodes. (Vega-Lite emits one only because its legend generation is
233
- * mechanical, not because anyone wants it.)
234
- */
235
- const KEYED_SIZE_MARKS = new Set(['point', 'beeswarm']);
236
-
237
- /** Size-scale options for a mark's legend, or null when its size channel gets none. */
238
- export function sizeLegendScaleFor(
239
- markType: string,
240
- ): { curve: SizeCurve; range: [number, number] } | null {
241
- if (!KEYED_SIZE_MARKS.has(markType)) return null;
242
- return sizeScaleDefaultsFor(markType);
243
- }
244
-
245
- /**
246
- * Compute size legend content from a quantitative `size` encoding.
247
- *
248
- * Circles are nested: concentric, sharing a bottom edge, so the ratio between
249
- * magnitudes is legible rather than just their order.
250
- *
251
- * @returns null when there is no size channel, or when the scale is degenerate
252
- * (a zero-width domain encodes no magnitude, so there is nothing to key).
253
- */
254
- export function computeSizeLegendContent(
255
- sizeEncoding: EncodingChannel | undefined,
256
- data: readonly DataRow[],
257
- theme: ResolvedTheme,
258
- options: { curve: SizeCurve; range: [number, number] },
259
- ): SizeLegendContent | null {
260
- if (!hasSizeChannel({ size: sizeEncoding })) return null;
261
-
262
- const resolved = buildSizeScale(sizeEncoding, data, options);
263
- if (!resolved) return null;
264
-
265
- // Circle area encodes magnitude, and a negative magnitude has no area to
266
- // encode -- a key for it would be nonsense (and `sqrt` of a negative is NaN).
267
- // The marks still render; the channel just isn't keyable, so we say nothing
268
- // rather than key it wrongly.
269
- if (!(resolved.domain[0] >= 0) || !(resolved.domain[1] > 0)) return null;
270
-
271
- // A lone circle is not a graduated key: it tells the reader only that the
272
- // biggest bubble is the biggest datum, which they already assumed. Two is the
273
- // minimum that establishes a scale.
274
- const values = pickValues(resolved.domain, resolved.scale);
275
- if (values.length < 2) return null;
276
-
277
- // Draw at the mark's own radii, unless they're too small to see -- then redraw
278
- // across MIN_LEGIBLE_RANGE. The *values* always come from the real scale, so
279
- // the labels stay true either way; only the circle sizes are rescaled.
280
- //
281
- // Test the RESOLVED range, not `options.range`: an explicit
282
- // `encoding.size.scale.range` overrides the mark default, and the beeswarm
283
- // fixture does exactly that ([2, 8]). Checking the default would miss it.
284
- //
285
- // The redraw is built straight from the domain rather than through
286
- // `buildSizeScale`, which reads the range off the encoding and would hand back
287
- // the same too-small range we're trying to escape.
288
- const [markMin, markMax] = resolved.range;
289
- const needsFloor = markMax - markMin < MIN_LEGIBLE_RANGE[1] - MIN_LEGIBLE_RANGE[0];
290
- const drawScale = needsFloor
291
- ? buildRadiusScale(options.curve, resolved.domain, MIN_LEGIBLE_RANGE)
292
- : resolved.scale;
293
-
294
- const maxRadius = drawScale(resolved.domain[1]);
295
- if (!Number.isFinite(maxRadius) || maxRadius <= 0) return null;
296
-
297
- const fontSize = theme.fonts.sizes.small;
298
-
299
- // Nested: every circle shares the baseline, so cy = baseline - r.
300
- const baseline = maxRadius * 2;
301
- const cx = maxRadius;
302
-
303
- // Labels ride each circle's top edge -- but only if the edges are far enough
304
- // apart to hold them. A beeswarm's size range is [2, 10], so the whole stack is
305
- // 20px tall and three labels pinned to those edges land on top of each other.
306
- // Walk down (largest first) and hold each label at least a line below the last;
307
- // the leader line still points at the true edge, so a nudged label stays
308
- // unambiguous.
309
- const lineHeight = fontSize * 1.25;
310
- let prevLabelY = Number.NEGATIVE_INFINITY;
311
-
312
- const circles: SizeLegendCircle[] = values.map((value) => {
313
- const radius = drawScale(value);
314
- const edgeY = baseline - radius * 2 + fontSize * 0.35;
315
- const labelY = Math.max(edgeY, prevLabelY + lineHeight);
316
- prevLabelY = labelY;
317
- return {
318
- value,
319
- label: formatValue(value, (sizeEncoding as EncodingChannel).format),
320
- radius,
321
- cx,
322
- cy: baseline - radius,
323
- labelY,
324
- };
325
- });
326
-
327
- // Widest label decides the block width. Measured with the same estimator the
328
- // renderer uses -- a char-count heuristic would disagree with what actually
329
- // gets drawn, and the sign of that disagreement flips with the font and the
330
- // number format (so "it over-reserves today" is not a guarantee).
331
- const widestLabel = Math.max(
332
- ...circles.map((c) => estimateTextWidth(c.label, fontSize, theme.fonts.weights.normal)),
333
- );
334
-
335
- // The label column can now run past the circle stack (see the nudge above), so
336
- // the block is as tall as whichever finishes lower.
337
- const lastLabelBottom = (circles[circles.length - 1]?.labelY ?? 0) + fontSize * 0.5;
338
-
339
- return {
340
- circles,
341
- width: maxRadius * 2 + LABEL_GAP + widestLabel,
342
- height: Math.max(baseline, lastLabelBottom) + BASELINE_PAD,
343
- };
344
- }
345
-
346
- /**
347
- * Place size legend content into chart coordinates.
348
- *
349
- * Sits in the right column that `dimensions.ts` reserved, top-aligned with the
350
- * plot. Circle/label coordinates in `content` are bounds-relative; the renderer
351
- * adds `bounds.x/y` to each.
352
- *
353
- * `colorLegend` is the ALREADY-PLACED color legend. When it also lives in the
354
- * right column, the two share ONE column and the size legend stacks BELOW it.
355
- *
356
- * Side-by-side is the obvious placement and it is the wrong one. Two right-hand
357
- * legends cost `colorWidth + sizeWidth + 2*GAP` of gutter -- on a 934px bubble
358
- * chart, 213px, nearly a quarter of the canvas -- and every pixel comes out of
359
- * the plot. Yet each block is only ~70px tall, so the column below them is dead
360
- * space: the chart pays for a 213px-wide gutter to render 70px of key. Stacking
361
- * costs `max(colorWidth, sizeWidth)` instead, and spends the empty vertical room
362
- * that was already reserved and already paid for.
363
- */
364
- export function placeSizeLegend(
365
- content: SizeLegendContent,
366
- chartArea: Rect,
367
- theme: ResolvedTheme,
368
- colorLegend?: LegendLayout | null,
369
- ): SizeLegendLayout {
370
- const gutterX = chartArea.x + chartArea.width + SIZE_LEGEND_GAP;
371
- const sharesGutter =
372
- colorLegend != null &&
373
- (colorLegend.position === 'right' || colorLegend.position === 'bottom-right') &&
374
- colorLegend.bounds.width > 0;
375
-
376
- // Left-align with the color legend rather than the gutter: `dimensions.ts`
377
- // reserves the column at `max(colorWidth, sizeWidth)`, so whichever block is
378
- // narrower sits inside a column sized by the other. Sharing the left edge
379
- // keys them as one column; anchoring to `gutterX` would leave the narrower
380
- // one floating away from its partner.
381
- const x = sharesGutter ? colorLegend.bounds.x : gutterX;
382
- const y = sharesGutter
383
- ? colorLegend.bounds.y + colorLegend.bounds.height + SIZE_LEGEND_STACK_GAP
384
- : chartArea.y;
385
-
386
- return {
387
- type: 'size',
388
- channel: 'size',
389
- position: 'right',
390
- bounds: {
391
- x,
392
- y,
393
- width: content.width,
394
- height: content.height,
395
- },
396
- circles: content.circles,
397
- stroke: theme.colors.axis,
398
- labelStyle: {
399
- fontFamily: theme.fonts.family,
400
- fontSize: theme.fonts.sizes.small,
401
- fontWeight: theme.fonts.weights.normal,
402
- fill: theme.colors.text,
403
- lineHeight: 1.3,
404
- fontVariant: 'tabular-nums',
405
- },
406
- };
407
- }
@@ -1,226 +0,0 @@
1
- /**
2
- * Single source of truth for legend / endpoint-column / end-of-line label
3
- * suppression on multi-series line/area charts.
4
- *
5
- * Three independent toggles drive the chart's series identification surface:
6
- *
7
- * - `legend.show` — the traditional swatch legend (top/bottom/right).
8
- * - `endpointLabels` — the right-side per-series column with last value.
9
- * - end-of-line labels — the legacy series-name label glued to the last point.
10
- *
11
- * For ≥2-series line/area charts the resolution table is:
12
- *
13
- * | `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels |
14
- * |--|--|--|--|--|
15
- * | unset | unset | hidden (auto-suppressed) | shown | hidden |
16
- * | true | unset | shown | shown | hidden |
17
- * | unset | false | shown (auto-revoked) | hidden | hidden |
18
- * | false | false | hidden | hidden | shown |
19
- * | true | false | shown | hidden | hidden |
20
- * | false | true | hidden | shown | hidden |
21
- * | true | true | shown | shown | hidden |
22
- *
23
- * Single-series and non-line/area charts: column always hidden, legend hidden
24
- * (single-series rule), end-of-line labels never render.
25
- *
26
- * Rule of thumb: end-of-line labels are the fallback identifier of last resort.
27
- * They render only when neither the traditional legend nor the endpoint column
28
- * is showing. The endpoint column "owns" the right zone and absorbs the
29
- * suppression role the traditional legend used to have for end-of-line labels.
30
- */
31
-
32
- import type { Encoding } from '@opendata-ai/openchart-core';
33
-
34
- import type { NormalizedChartSpec } from '../compiler/types';
35
-
36
- /** Result of the suppression resolution. */
37
- export interface SuppressionResult {
38
- /** Whether the traditional swatch legend should render. */
39
- showTraditionalLegend: boolean;
40
- /** Whether the right-side endpoint column should render. */
41
- showEndpointLabels: boolean;
42
- /** Whether the legacy end-of-line labels should render. */
43
- showEndOfLineLabels: boolean;
44
- }
45
-
46
- /** Inputs needed by the resolution helper. */
47
- export interface SuppressionContext {
48
- /** Number of distinct series (color-encoding domain values). 0 if no color encoding. */
49
- seriesCount: number;
50
- /** True when responsive strategy strips inline labels (compact breakpoint). */
51
- labelsHiddenByStrategy: boolean;
52
- /** True when spec.labels.density === 'none'. */
53
- labelsDensityNone: boolean;
54
- /** When true, endpoint labels are demoted because they don't fit at the current width. */
55
- endpointLabelsDemoted?: boolean;
56
- }
57
-
58
- /**
59
- * Did the user write `legend.show: true` or any non-`show` legend config?
60
- * (Used by the truth table to decide between the "unset" and "true" rows.)
61
- */
62
- function legendShownExplicitly(spec: NormalizedChartSpec): boolean {
63
- if (spec.legend == null) return false;
64
- if (spec.legend.show === true) return true;
65
- // Any non-`show` legend property counts as opt-in to render the legend.
66
- return Object.keys(spec.legend).some((k) => k !== 'show');
67
- }
68
-
69
- /** Did the user write `legend.show: false` explicitly? */
70
- function legendHiddenExplicitly(spec: NormalizedChartSpec): boolean {
71
- return spec.legend?.show === false;
72
- }
73
-
74
- /** Keys on EndpointLabelsConfig that configure behavior but don't signal show intent. */
75
- const EP_CONFIG_ONLY_KEYS = new Set(['show', 'maxSeries']);
76
-
77
- /**
78
- * Did the user explicitly set endpointLabels: true?
79
- * Either the bare boolean or `{ show: true }` or any display-affecting config keys count.
80
- * Config-only keys like `maxSeries` don't count as show intent.
81
- */
82
- export function endpointLabelsExplicitlyOn(spec: NormalizedChartSpec): boolean {
83
- const ep = spec.endpointLabels;
84
- if (ep === true) return true;
85
- if (ep === false || ep == null) return false;
86
- if (ep.show === true) return true;
87
- if (ep.show === false) return false;
88
- return Object.keys(ep).some((k) => !EP_CONFIG_ONLY_KEYS.has(k));
89
- }
90
-
91
- /** Did the user explicitly set endpointLabels: false (or { show: false })? */
92
- function endpointLabelsExplicitlyOff(spec: NormalizedChartSpec): boolean {
93
- const ep = spec.endpointLabels;
94
- if (ep === false) return true;
95
- if (typeof ep === 'object' && ep != null && ep.show === false) return true;
96
- return false;
97
- }
98
-
99
- const DEFAULT_MAX_SERIES = 8;
100
-
101
- /** Read the maxSeries cutoff from the spec's endpointLabels config. */
102
- function getMaxSeries(spec: NormalizedChartSpec): number {
103
- const ep = spec.endpointLabels;
104
- if (typeof ep === 'object' && ep != null && ep.maxSeries != null) return ep.maxSeries;
105
- return DEFAULT_MAX_SERIES;
106
- }
107
-
108
- /**
109
- * Resolve the three booleans from spec + context.
110
- *
111
- * Pure: no theme, no chart area, no scales. Both `computeLegend` and
112
- * `computeLineLabels` and `computeEndpointLabels` share this exact logic.
113
- */
114
- export function resolveSuppression(
115
- spec: NormalizedChartSpec,
116
- ctx: SuppressionContext,
117
- ): SuppressionResult {
118
- const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
119
- const hasColorEncoding = spec.encoding.color != null;
120
- const isMultiSeries = isLineOrArea && hasColorEncoding && ctx.seriesCount >= 2;
121
-
122
- // Single-series, non-line/area, or compact strategy: never show endpoint
123
- // column or end-of-line labels. Legend follows its own rules elsewhere.
124
- // Explicit user opt-in (endpointLabels config) overrides the compact
125
- // strip: slope/bump recipes carry their values on the labels, so an
126
- // author who wrote endpointLabels config keeps the column at 320px.
127
- //
128
- // Note: `labelsDensityNone` is intentionally NOT in this short-circuit.
129
- // `labels.density: 'none'` is the legacy switch for end-of-line labels and
130
- // must not affect the endpoint column or the traditional legend (those
131
- // are independent concerns governed by the truth table below).
132
- const strategyHidesEndpoint = ctx.labelsHiddenByStrategy && !endpointLabelsExplicitlyOn(spec);
133
- if (!isMultiSeries || strategyHidesEndpoint) {
134
- return {
135
- // Defer the legend's own show/hide rules to computeLegend; this helper
136
- // doesn't override the existing legend behavior for non-multi-series.
137
- showTraditionalLegend: !legendHiddenExplicitly(spec),
138
- showEndpointLabels: false,
139
- showEndOfLineLabels: false,
140
- };
141
- }
142
-
143
- // Stacked-area branch: gradient fills overlap, so endpoint labels at the
144
- // last data point would land on top of each other. Don't auto-enable the
145
- // column for stacked areas. Users can still force it with `endpointLabels: true`.
146
- const isArea = spec.markType === 'area';
147
- const quantChannel = (
148
- spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x
149
- ) as Encoding[keyof Encoding] | undefined;
150
- const stackValue = quantChannel && 'stack' in quantChannel ? quantChannel.stack : undefined;
151
- // v8 VL-aligned default: area stacks unless stack is explicitly null/false.
152
- const isStacked = isArea ? stackValue !== null && stackValue !== false : false;
153
-
154
- // Stacked area: revert to the pre-v6 behavior — show legend, no endpoint column.
155
- if (isStacked) {
156
- // Allow explicit user opt-in via endpointLabels: true even on stacked areas.
157
- const explicitOn = endpointLabelsExplicitlyOn(spec);
158
- return {
159
- showTraditionalLegend: !legendHiddenExplicitly(spec),
160
- showEndpointLabels: explicitOn,
161
- showEndOfLineLabels: false,
162
- };
163
- }
164
-
165
- // Series-count cutoff: above maxSeries (default 8), treat as if endpoint
166
- // labels are off so a legend shows instead. Explicit user opt-in overrides.
167
- const exceedsCutoff = !endpointLabelsExplicitlyOn(spec) && ctx.seriesCount > getMaxSeries(spec);
168
-
169
- // Auto-thinning demotion: endpoint labels don't fit at the current width.
170
- const demoted = !endpointLabelsExplicitlyOn(spec) && !!ctx.endpointLabelsDemoted;
171
-
172
- // The eight-cell truth table for ≥2-series overlap line/area charts.
173
- const epExplicitOff = endpointLabelsExplicitlyOff(spec) || exceedsCutoff || demoted;
174
- const legShown = legendShownExplicitly(spec);
175
- const legHidden = legendHiddenExplicitly(spec);
176
-
177
- // Endpoint column: on by default; only `endpointLabels: false` (or
178
- // `{ show: false }`) or series-count cutoff turns it off.
179
- const showEndpointLabels = !epExplicitOff;
180
-
181
- // Traditional legend:
182
- // - explicit hide -> hidden
183
- // - explicit show -> shown
184
- // - unset -> hidden when endpoint column is on, shown otherwise
185
- let showTraditionalLegend: boolean;
186
- if (legHidden) {
187
- showTraditionalLegend = false;
188
- } else if (legShown) {
189
- showTraditionalLegend = true;
190
- } else {
191
- showTraditionalLegend = !showEndpointLabels;
192
- }
193
-
194
- // End-of-line labels are the last-resort series identifier: they render
195
- // only when both the traditional legend and the endpoint column are off.
196
- // `labels.density: 'none'` is the legacy switch that explicitly disables
197
- // end-of-line labels regardless of the other toggles.
198
- const showEndOfLineLabels =
199
- !showTraditionalLegend && !showEndpointLabels && !ctx.labelsDensityNone;
200
-
201
- return {
202
- showTraditionalLegend,
203
- showEndpointLabels,
204
- showEndOfLineLabels,
205
- };
206
- }
207
-
208
- /**
209
- * Count distinct series for the color encoding (for use in
210
- * SuppressionContext.seriesCount). Returns 0 when no color encoding is set
211
- * or when the encoding is conditional/quantitative.
212
- */
213
- export function countColorSeries(spec: NormalizedChartSpec): number {
214
- const colorEnc = spec.encoding.color;
215
- if (!colorEnc) return 0;
216
- if ('condition' in colorEnc) return 0;
217
- if (!('field' in colorEnc)) return 0;
218
- if (colorEnc.type === 'quantitative') return 0;
219
- const field = colorEnc.field;
220
- if (!field) return 0;
221
- const seen = new Set<string>();
222
- for (const row of spec.data) {
223
- seen.add(String(row[field]));
224
- }
225
- return seen.size;
226
- }