@opendata-ai/openchart-engine 8.0.0-rc.10 → 8.0.0-rc.11

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 (270) hide show
  1. package/dist/index.js +2 -3
  2. package/dist/index.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/__test-fixtures__/specs.ts +0 -182
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  6. package/src/__tests__/annotation-behavior.test.ts +0 -852
  7. package/src/__tests__/axes.test.ts +0 -1579
  8. package/src/__tests__/compile-animation.test.ts +0 -161
  9. package/src/__tests__/compile-chart.test.ts +0 -1303
  10. package/src/__tests__/compile-footnotes.test.ts +0 -277
  11. package/src/__tests__/compile-layer.test.ts +0 -969
  12. package/src/__tests__/compile-snapshot.test.ts +0 -156
  13. package/src/__tests__/compound-labels.test.ts +0 -147
  14. package/src/__tests__/dimensions.test.ts +0 -918
  15. package/src/__tests__/encoding-sugar.test.ts +0 -390
  16. package/src/__tests__/facet.test.ts +0 -359
  17. package/src/__tests__/helpers/invariants.ts +0 -299
  18. package/src/__tests__/known-bugs.test.ts +0 -566
  19. package/src/__tests__/layout-fuzz.test.ts +0 -358
  20. package/src/__tests__/layout-invariants.test.ts +0 -535
  21. package/src/__tests__/legend.test.ts +0 -721
  22. package/src/__tests__/mark-keys.test.ts +0 -294
  23. package/src/__tests__/ranking-recipes.test.ts +0 -260
  24. package/src/__tests__/rotation.test.ts +0 -83
  25. package/src/__tests__/scales.test.ts +0 -323
  26. package/src/__tests__/series-search.test.ts +0 -92
  27. package/src/__tests__/spec-sugar.test.ts +0 -801
  28. package/src/__tests__/you-draw-it.test.ts +0 -160
  29. package/src/annotations/__tests__/compute.test.ts +0 -1672
  30. package/src/annotations/__tests__/geometry.test.ts +0 -66
  31. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  32. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  33. package/src/annotations/__tests__/placement.test.ts +0 -316
  34. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  35. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  36. package/src/annotations/__tests__/thinning.test.ts +0 -285
  37. package/src/annotations/collisions.ts +0 -326
  38. package/src/annotations/compute.ts +0 -333
  39. package/src/annotations/constants.ts +0 -162
  40. package/src/annotations/geometry.ts +0 -376
  41. package/src/annotations/move.ts +0 -135
  42. package/src/annotations/placement.ts +0 -636
  43. package/src/annotations/position.ts +0 -131
  44. package/src/annotations/resolve-range.ts +0 -117
  45. package/src/annotations/resolve-refline.ts +0 -157
  46. package/src/annotations/resolve-text.ts +0 -545
  47. package/src/annotations/rich-text.ts +0 -65
  48. package/src/annotations/thinning.ts +0 -190
  49. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  50. package/src/barlist/compile-barlist.ts +0 -391
  51. package/src/barlist/types.ts +0 -28
  52. package/src/charts/__tests__/post-process.test.ts +0 -237
  53. package/src/charts/__tests__/registry.test.ts +0 -116
  54. package/src/charts/__tests__/utils.test.ts +0 -195
  55. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  56. package/src/charts/_shared/density-filter.ts +0 -26
  57. package/src/charts/_shared/format-label-value.ts +0 -16
  58. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  59. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  60. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  61. package/src/charts/bar/compute.ts +0 -539
  62. package/src/charts/bar/index.ts +0 -60
  63. package/src/charts/bar/labels.ts +0 -311
  64. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  65. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  66. package/src/charts/beeswarm/compute.ts +0 -203
  67. package/src/charts/beeswarm/dodge.ts +0 -155
  68. package/src/charts/beeswarm/index.ts +0 -32
  69. package/src/charts/builtin.ts +0 -75
  70. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  71. package/src/charts/calendar/compute.ts +0 -319
  72. package/src/charts/calendar/index.ts +0 -32
  73. package/src/charts/column/__tests__/compute.test.ts +0 -540
  74. package/src/charts/column/__tests__/labels.test.ts +0 -179
  75. package/src/charts/column/compute.ts +0 -493
  76. package/src/charts/column/index.ts +0 -53
  77. package/src/charts/column/labels.ts +0 -229
  78. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  79. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  80. package/src/charts/dot/compute.ts +0 -285
  81. package/src/charts/dot/index.ts +0 -62
  82. package/src/charts/dot/labels.ts +0 -121
  83. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  84. package/src/charts/line/__tests__/curves.test.ts +0 -51
  85. package/src/charts/line/__tests__/labels.test.ts +0 -94
  86. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  87. package/src/charts/line/area.ts +0 -484
  88. package/src/charts/line/compute.ts +0 -356
  89. package/src/charts/line/curves.ts +0 -85
  90. package/src/charts/line/index.ts +0 -176
  91. package/src/charts/line/labels.ts +0 -177
  92. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  93. package/src/charts/parliament/compute.ts +0 -313
  94. package/src/charts/parliament/index.ts +0 -32
  95. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  96. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  97. package/src/charts/pie/compute.ts +0 -324
  98. package/src/charts/pie/index.ts +0 -49
  99. package/src/charts/pie/labels.ts +0 -143
  100. package/src/charts/post-process.ts +0 -229
  101. package/src/charts/range/__tests__/compute.test.ts +0 -486
  102. package/src/charts/range/compute.ts +0 -273
  103. package/src/charts/range/index.ts +0 -82
  104. package/src/charts/range/labels.ts +0 -169
  105. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  106. package/src/charts/rect/index.ts +0 -127
  107. package/src/charts/registry.ts +0 -72
  108. package/src/charts/rule/index.ts +0 -139
  109. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  110. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  111. package/src/charts/scatter/compute.ts +0 -192
  112. package/src/charts/scatter/index.ts +0 -46
  113. package/src/charts/scatter/trendline.ts +0 -100
  114. package/src/charts/text/__tests__/compute.test.ts +0 -307
  115. package/src/charts/text/index.ts +0 -141
  116. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  117. package/src/charts/tick/index.ts +0 -99
  118. package/src/charts/utils.ts +0 -170
  119. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  120. package/src/charts/waffle/compute.ts +0 -197
  121. package/src/charts/waffle/index.ts +0 -31
  122. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  123. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  124. package/src/compile/__tests__/data-clip.test.ts +0 -127
  125. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  126. package/src/compile/__tests__/size-scale.test.ts +0 -108
  127. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  128. package/src/compile/color-scale-range.ts +0 -101
  129. package/src/compile/data-clip.ts +0 -48
  130. package/src/compile/fill-patterns.ts +0 -74
  131. package/src/compile/layer.ts +0 -630
  132. package/src/compile/size-scale.ts +0 -141
  133. package/src/compile/spec-sugar.ts +0 -767
  134. package/src/compile/watermark-obstacle.ts +0 -54
  135. package/src/compile/you-draw-it.ts +0 -123
  136. package/src/compile.ts +0 -1548
  137. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  138. package/src/compiler/__tests__/animation.test.ts +0 -177
  139. package/src/compiler/__tests__/compile.test.ts +0 -87
  140. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  141. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  142. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  143. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  144. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  153. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  154. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  155. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  156. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  158. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  159. package/src/compiler/__tests__/keys.test.ts +0 -83
  160. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  161. package/src/compiler/__tests__/normalize.test.ts +0 -399
  162. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  163. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  164. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  165. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  166. package/src/compiler/__tests__/validate.test.ts +0 -731
  167. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  168. package/src/compiler/a11y-warnings.ts +0 -110
  169. package/src/compiler/animation.ts +0 -142
  170. package/src/compiler/index.ts +0 -47
  171. package/src/compiler/keys.ts +0 -55
  172. package/src/compiler/normalize.ts +0 -734
  173. package/src/compiler/sparkline-defaults.ts +0 -138
  174. package/src/compiler/types.ts +0 -241
  175. package/src/compiler/validate.ts +0 -2093
  176. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  177. package/src/endpoint-labels/compute.ts +0 -621
  178. package/src/endpoint-labels/constants.ts +0 -54
  179. package/src/endpoint-labels/format.ts +0 -60
  180. package/src/endpoint-labels/predict.ts +0 -139
  181. package/src/format/__tests__/field-format.test.ts +0 -164
  182. package/src/format/field-format.ts +0 -27
  183. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  184. package/src/geo/compile-map.ts +0 -1376
  185. package/src/geo/join.ts +0 -115
  186. package/src/geo/projections.ts +0 -65
  187. package/src/geo/types.ts +0 -26
  188. package/src/graphs/__tests__/community.test.ts +0 -228
  189. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  190. package/src/graphs/__tests__/encoding.test.ts +0 -415
  191. package/src/graphs/community.ts +0 -92
  192. package/src/graphs/compile-graph.ts +0 -329
  193. package/src/graphs/encoding.ts +0 -356
  194. package/src/graphs/types.ts +0 -106
  195. package/src/index.ts +0 -177
  196. package/src/layout/__tests__/metrics.test.ts +0 -97
  197. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  198. package/src/layout/axes/rotation.ts +0 -152
  199. package/src/layout/axes/thinning.ts +0 -98
  200. package/src/layout/axes/ticks.ts +0 -409
  201. package/src/layout/axes.ts +0 -757
  202. package/src/layout/dimensions.ts +0 -912
  203. package/src/layout/facet.ts +0 -91
  204. package/src/layout/gridlines.ts +0 -84
  205. package/src/layout/metrics.ts +0 -132
  206. package/src/layout/plan.ts +0 -631
  207. package/src/layout/scales.ts +0 -1053
  208. package/src/layout/shared.ts +0 -44
  209. package/src/legend/__tests__/continuous.test.ts +0 -318
  210. package/src/legend/__tests__/redundancy.test.ts +0 -108
  211. package/src/legend/__tests__/size.test.ts +0 -348
  212. package/src/legend/__tests__/suppression.test.ts +0 -382
  213. package/src/legend/compute.ts +0 -748
  214. package/src/legend/continuous.ts +0 -327
  215. package/src/legend/size.ts +0 -407
  216. package/src/legend/suppression.ts +0 -226
  217. package/src/legend/wrap.ts +0 -117
  218. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  219. package/src/sankey/__tests__/layout.test.ts +0 -165
  220. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  221. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  222. package/src/sankey/compile-sankey.ts +0 -706
  223. package/src/sankey/layout.ts +0 -184
  224. package/src/sankey/types.ts +0 -41
  225. package/src/tables/__tests__/bar-column.test.ts +0 -147
  226. package/src/tables/__tests__/category-colors.test.ts +0 -230
  227. package/src/tables/__tests__/compile-table.test.ts +0 -236
  228. package/src/tables/__tests__/format-cells.test.ts +0 -143
  229. package/src/tables/__tests__/heatmap.test.ts +0 -148
  230. package/src/tables/__tests__/pagination.test.ts +0 -78
  231. package/src/tables/__tests__/search.test.ts +0 -94
  232. package/src/tables/__tests__/sort.test.ts +0 -107
  233. package/src/tables/__tests__/sparkline.test.ts +0 -122
  234. package/src/tables/bar-column.ts +0 -94
  235. package/src/tables/category-colors.ts +0 -78
  236. package/src/tables/compile-table.ts +0 -427
  237. package/src/tables/format-cells.ts +0 -115
  238. package/src/tables/heatmap.ts +0 -141
  239. package/src/tables/pagination.ts +0 -46
  240. package/src/tables/search.ts +0 -66
  241. package/src/tables/sort.ts +0 -69
  242. package/src/tables/sparkline.ts +0 -113
  243. package/src/tables/utils.ts +0 -16
  244. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  245. package/src/tilemap/compile-tilemap.ts +0 -755
  246. package/src/tilemap/layout.ts +0 -172
  247. package/src/tilemap/types.ts +0 -33
  248. package/src/tooltips/__tests__/compute.test.ts +0 -582
  249. package/src/tooltips/compute.ts +0 -700
  250. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  251. package/src/transforms/__tests__/bin.test.ts +0 -88
  252. package/src/transforms/__tests__/calculate.test.ts +0 -146
  253. package/src/transforms/__tests__/conditional.test.ts +0 -127
  254. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  255. package/src/transforms/__tests__/filter.test.ts +0 -59
  256. package/src/transforms/__tests__/fold.test.ts +0 -79
  257. package/src/transforms/__tests__/index.test.ts +0 -93
  258. package/src/transforms/__tests__/predicates.test.ts +0 -193
  259. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  260. package/src/transforms/__tests__/window.test.ts +0 -286
  261. package/src/transforms/aggregate.ts +0 -131
  262. package/src/transforms/bin.ts +0 -87
  263. package/src/transforms/calculate.ts +0 -60
  264. package/src/transforms/conditional.ts +0 -46
  265. package/src/transforms/filter.ts +0 -122
  266. package/src/transforms/fold.ts +0 -49
  267. package/src/transforms/index.ts +0 -60
  268. package/src/transforms/predicates.ts +0 -123
  269. package/src/transforms/timeunit.ts +0 -88
  270. package/src/transforms/window.ts +0 -185
@@ -1,192 +0,0 @@
1
- /**
2
- * Scatter / bubble chart mark computation.
3
- *
4
- * Takes a normalized chart spec with resolved scales and produces
5
- * PointMark[] for rendering scatter plots. Axes can be any field type.
6
- * Optional size encoding produces area-proportional bubbles via sqrt
7
- * scaling, and color encoding groups points by category.
8
- */
9
-
10
- import type {
11
- Encoding,
12
- FieldType,
13
- GradientDef,
14
- LayoutStrategy,
15
- MarkAria,
16
- PointMark,
17
- Rect,
18
- } from '@opendata-ai/openchart-core';
19
- import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
20
- import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
21
- import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
22
- import type { NormalizedChartSpec } from '../../compiler/types';
23
- import type { ResolvedScales } from '../../layout/scales';
24
- import { getColor, getSequentialColor } from '../utils';
25
-
26
- // ---------------------------------------------------------------------------
27
- // Constants
28
- // ---------------------------------------------------------------------------
29
-
30
- const DEFAULT_POINT_RADIUS = 5;
31
-
32
- // ---------------------------------------------------------------------------
33
- // Helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- /** Resolve a data value to a pixel position based on channel type and scale. */
37
- function resolvePosition(
38
- value: unknown,
39
- channelType: FieldType,
40
- scale:
41
- | ScaleLinear<number, number>
42
- | ScaleTime<number, number>
43
- | ScaleBand<string>
44
- | ScalePoint<string>,
45
- ): number | undefined {
46
- switch (channelType) {
47
- case 'nominal':
48
- case 'ordinal': {
49
- const s = String(value);
50
- if ('bandwidth' in scale && typeof scale.bandwidth === 'function') {
51
- const bw = (scale as ScaleBand<string>).bandwidth();
52
- const pos = (scale as ScaleBand<string>)(s);
53
- if (pos === undefined) return undefined;
54
- // ScalePoint has bandwidth() === 0; ScaleBand has > 0.
55
- return bw > 0 ? pos + bw / 2 : pos;
56
- }
57
- return (scale as ScalePoint<string>)(s);
58
- }
59
- case 'temporal': {
60
- const px = (scale as ScaleTime<number, number>)(new Date(value as string | number));
61
- return Number.isNaN(px) ? undefined : px;
62
- }
63
- default: {
64
- const num = Number(value);
65
- if (!Number.isFinite(num)) return undefined;
66
- return (scale as ScaleLinear<number, number>)(num);
67
- }
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // Public API
73
- // ---------------------------------------------------------------------------
74
-
75
- /**
76
- * Compute scatter/bubble marks from a normalized chart spec.
77
- *
78
- * Axes accept any field type: quantitative (linear), temporal (time),
79
- * nominal/ordinal (band or point scale). Optional size encoding maps a
80
- * data field to point radius using sqrt scale (area-proportional).
81
- * Optional color encoding groups points by category with distinct colors.
82
- */
83
- export function computeScatterMarks(
84
- spec: NormalizedChartSpec,
85
- scales: ResolvedScales,
86
- _chartArea: Rect,
87
- _strategy: LayoutStrategy,
88
- ): PointMark[] {
89
- const encoding = spec.encoding as Encoding;
90
- const xChannel = encoding.x;
91
- const yChannel = encoding.y;
92
-
93
- if (!xChannel || !yChannel || !scales.x || !scales.y) {
94
- return [];
95
- }
96
-
97
- const xScale = scales.x.scale as
98
- | ScaleLinear<number, number>
99
- | ScaleTime<number, number>
100
- | ScaleBand<string>
101
- | ScalePoint<string>;
102
- const yScale = scales.y.scale as
103
- | ScaleLinear<number, number>
104
- | ScaleTime<number, number>
105
- | ScaleBand<string>
106
- | ScalePoint<string>;
107
- const xType = xChannel.type;
108
- const yType = yChannel.type;
109
-
110
- const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
111
- const isSequentialColor = colorEnc?.type === 'quantitative';
112
- const colorField = colorEnc?.field;
113
- const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
114
- const sizeField = sizeEnc?.field;
115
-
116
- // Bubbles: sqrt so perceived magnitude tracks disc *area*. Author overrides
117
- // (`encoding.size.scale.{domain,range}`) and the degenerate-domain fallback
118
- // live in the shared builder, which the size legend also calls so the key and
119
- // the marks can never resolve different scales.
120
- const resolvedSize = buildSizeScale(sizeEnc, spec.data, SIZE_SCALE_DEFAULTS.point);
121
- const sizeScale = resolvedSize?.scale;
122
-
123
- const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
124
- const keyField = keyEnc?.field;
125
- const marks: PointMark[] = [];
126
-
127
- for (const row of spec.data) {
128
- const rawX = row[xChannel.field];
129
- const rawY = row[yChannel.field];
130
-
131
- const cx = resolvePosition(rawX, xType, xScale);
132
- const cy = resolvePosition(rawY, yType, yScale);
133
-
134
- if (cx === undefined || cy === undefined) continue;
135
-
136
- const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
137
- let color: string | GradientDef;
138
- if (isSequentialColor && colorField) {
139
- const val = Number(row[colorField]);
140
- color = Number.isFinite(val)
141
- ? getSequentialColor(scales, val)
142
- : getColor(scales, '__default__');
143
- } else {
144
- color = getColor(scales, category ?? '__default__');
145
- }
146
-
147
- let radius = DEFAULT_POINT_RADIUS;
148
- if (sizeScale && sizeField) {
149
- const sizeVal = Number(row[sizeField]);
150
- if (Number.isFinite(sizeVal)) {
151
- radius = sizeScale(sizeVal);
152
- }
153
- }
154
-
155
- const labelParts = [`${xChannel.field}=${rawX}`, `${yChannel.field}=${rawY}`];
156
- if (category) labelParts.push(`${colorField}=${category}`);
157
- if (sizeField && row[sizeField] != null) {
158
- labelParts.push(`${sizeField}=${row[sizeField]}`);
159
- }
160
-
161
- const aria: MarkAria = {
162
- label: `Data point: ${labelParts.join(', ')}`,
163
- };
164
-
165
- marks.push({
166
- type: 'point',
167
- cx,
168
- cy,
169
- r: radius,
170
- fill: color,
171
- stroke: '#ffffff',
172
- strokeWidth: 1,
173
- fillOpacity: 0.7,
174
- data: row as Record<string, unknown>,
175
- aria,
176
- });
177
- }
178
-
179
- // Stamp keys: encoding.key field when set, else seriesKey|x composite
180
- const rawKeys = marks.map((m) => {
181
- if (keyField) return serializeKeyValue(m.data[keyField]);
182
- const cat = colorField ? String(m.data[colorField] ?? '') : '';
183
- const xVal = serializeKeyValue(m.data[xChannel.field]);
184
- return cat ? `${cat}|${xVal}` : xVal;
185
- });
186
- const keys = dedupeKeys(rawKeys);
187
- for (let i = 0; i < marks.length; i++) {
188
- marks[i].key = keys[i];
189
- }
190
-
191
- return marks;
192
- }
@@ -1,46 +0,0 @@
1
- /**
2
- * Scatter / bubble chart module.
3
- *
4
- * Exports the scatter chart renderer and computation functions.
5
- */
6
-
7
- import type { Mark } from '@opendata-ai/openchart-core';
8
- import type { ChartRenderer } from '../registry';
9
- import { computeScatterMarks } from './compute';
10
- import { computeTrendLine } from './trendline';
11
-
12
- // ---------------------------------------------------------------------------
13
- // Scatter chart renderer
14
- // ---------------------------------------------------------------------------
15
-
16
- /**
17
- * Scatter chart renderer.
18
- *
19
- * Produces point marks for each data point, optionally with size encoding
20
- * for bubbles and a trend line overlay.
21
- */
22
- export const scatterRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
23
- const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy);
24
- const marks: Mark[] = [...pointMarks];
25
-
26
- // Regression trend line renders by default but is opt-out via
27
- // `mark: { type: 'point', trendline: false }`. Rendering it unconditionally
28
- // competed with author-drawn reference lines (e.g. a manual x=y diagonal),
29
- // so a chart could show two diagonals.
30
- if (spec.markDef?.trendline !== false) {
31
- const trendLine = computeTrendLine(pointMarks);
32
- if (trendLine) {
33
- // Trend line goes behind points
34
- marks.unshift(trendLine);
35
- }
36
- }
37
-
38
- return marks;
39
- };
40
-
41
- // ---------------------------------------------------------------------------
42
- // Public exports
43
- // ---------------------------------------------------------------------------
44
-
45
- export { computeScatterMarks } from './compute';
46
- export { computeTrendLine } from './trendline';
@@ -1,100 +0,0 @@
1
- /**
2
- * Trend line computation for scatter plots.
3
- *
4
- * Computes a simple linear regression (least squares) over the
5
- * point marks and returns a LineMark representing the best-fit line.
6
- */
7
-
8
- import type { LineMark, MarkAria, PointMark } from '@opendata-ai/openchart-core';
9
-
10
- // ---------------------------------------------------------------------------
11
- // Constants
12
- // ---------------------------------------------------------------------------
13
-
14
- const TRENDLINE_COLOR = '#666666';
15
- const TRENDLINE_STROKE_WIDTH = 1.5;
16
- const TRENDLINE_DASH = '6 4';
17
-
18
- // ---------------------------------------------------------------------------
19
- // Linear regression
20
- // ---------------------------------------------------------------------------
21
-
22
- /**
23
- * Compute slope and intercept for a simple linear regression.
24
- * Returns null if there aren't enough points or variance is zero.
25
- */
26
- function linearRegression(
27
- points: { x: number; y: number }[],
28
- ): { slope: number; intercept: number } | null {
29
- const n = points.length;
30
- if (n < 2) return null;
31
-
32
- let sumX = 0;
33
- let sumY = 0;
34
- let sumXY = 0;
35
- let sumXX = 0;
36
-
37
- for (const p of points) {
38
- sumX += p.x;
39
- sumY += p.y;
40
- sumXY += p.x * p.y;
41
- sumXX += p.x * p.x;
42
- }
43
-
44
- const denominator = n * sumXX - sumX * sumX;
45
- if (denominator === 0) return null;
46
-
47
- const slope = (n * sumXY - sumX * sumY) / denominator;
48
- const intercept = (sumY - slope * sumX) / n;
49
-
50
- return { slope, intercept };
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // Public API
55
- // ---------------------------------------------------------------------------
56
-
57
- /**
58
- * Compute a trend line (linear regression) over scatter point marks.
59
- *
60
- * Returns a single LineMark spanning the x-range of the data points,
61
- * rendered as a dashed line. Returns null if regression can't be computed.
62
- */
63
- export function computeTrendLine(marks: PointMark[]): LineMark | null {
64
- if (marks.length < 2) return null;
65
-
66
- const points = marks.map((m) => ({ x: m.cx, y: m.cy }));
67
- const result = linearRegression(points);
68
- if (!result) return null;
69
-
70
- const { slope, intercept } = result;
71
-
72
- // Find x range from marks
73
- let minX = Infinity;
74
- let maxX = -Infinity;
75
- for (const m of marks) {
76
- if (m.cx < minX) minX = m.cx;
77
- if (m.cx > maxX) maxX = m.cx;
78
- }
79
-
80
- // Compute y values at the endpoints
81
- const y1 = slope * minX + intercept;
82
- const y2 = slope * maxX + intercept;
83
-
84
- const aria: MarkAria = {
85
- label: `Trend line: linear regression`,
86
- };
87
-
88
- return {
89
- type: 'line',
90
- points: [
91
- { x: minX, y: y1 },
92
- { x: maxX, y: y2 },
93
- ],
94
- stroke: TRENDLINE_COLOR,
95
- strokeWidth: TRENDLINE_STROKE_WIDTH,
96
- strokeDasharray: TRENDLINE_DASH,
97
- data: [],
98
- aria,
99
- };
100
- }
@@ -1,307 +0,0 @@
1
- import type { ChartSpec, LayerSpec, PointMark, TextMarkLayout } from '@opendata-ai/openchart-core';
2
- import { describe, expect, it } from 'vitest';
3
- import { compileChart, compileLayer } from '../../../compile';
4
-
5
- // ---------------------------------------------------------------------------
6
- // Shared fixtures
7
- // ---------------------------------------------------------------------------
8
-
9
- /**
10
- * Ten US states, GDP (trillions) vs population (millions). The low end clusters
11
- * tightly, which is what made the scale-domain bug so visible: the further a
12
- * point sat from the shared upper bound, the further its label drifted.
13
- */
14
- const states = [
15
- { label: 'CA', gdp: 3.9, pop: 39.0 },
16
- { label: 'TX', gdp: 2.6, pop: 30.5 },
17
- { label: 'NY', gdp: 2.1, pop: 19.6 },
18
- { label: 'FL', gdp: 1.6, pop: 22.6 },
19
- { label: 'IL', gdp: 1.1, pop: 12.5 },
20
- { label: 'PA', gdp: 1.0, pop: 12.9 },
21
- { label: 'OH', gdp: 0.9, pop: 11.8 },
22
- { label: 'GA', gdp: 0.8, pop: 11.0 },
23
- { label: 'NJ', gdp: 0.8, pop: 9.3 },
24
- { label: 'WA', gdp: 0.8, pop: 7.8 },
25
- ];
26
-
27
- /** A points layer plus a text layer, both encoding the *same* x/y fields. */
28
- function labeledScatter(textMark: ChartSpec['mark'] = { type: 'text' }): LayerSpec {
29
- return {
30
- layer: [
31
- {
32
- mark: { type: 'point' },
33
- data: [...states],
34
- encoding: {
35
- x: { field: 'gdp', type: 'quantitative' },
36
- y: { field: 'pop', type: 'quantitative' },
37
- },
38
- },
39
- {
40
- mark: textMark,
41
- data: [...states],
42
- encoding: {
43
- x: { field: 'gdp', type: 'quantitative' },
44
- y: { field: 'pop', type: 'quantitative' },
45
- text: { field: 'label', type: 'nominal' },
46
- },
47
- },
48
- ],
49
- };
50
- }
51
-
52
- function textMarksOf(spec: ChartSpec): TextMarkLayout[] {
53
- const layout = compileChart(spec, { width: 900, height: 500 });
54
- return layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
55
- }
56
-
57
- /** Standalone text chart over the states, with an overridable mark def. */
58
- function textChart(mark: ChartSpec['mark'] = { type: 'text' }): ChartSpec {
59
- return {
60
- mark,
61
- data: [...states],
62
- encoding: {
63
- x: { field: 'gdp', type: 'quantitative' },
64
- y: { field: 'pop', type: 'quantitative' },
65
- text: { field: 'label', type: 'nominal' },
66
- },
67
- };
68
- }
69
-
70
- // ---------------------------------------------------------------------------
71
- // Scale alignment
72
- // ---------------------------------------------------------------------------
73
-
74
- describe('text marks in a layer', () => {
75
- /**
76
- * Regression: text is a position-encoding mark, so it must resolve its domain
77
- * the same way `point`/`beeswarm`/`range` do (tight, not zero-anchored). When
78
- * it didn't, a text layer and a point layer over identical fields resolved
79
- * *different* domains across the same pixel range, and every label drifted off
80
- * the dot it was labeling — by up to 160px at the low end of the domain.
81
- */
82
- it('positions each label exactly on the point it labels', () => {
83
- const layout = compileLayer(labeledScatter(), { width: 900, height: 500 });
84
-
85
- const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
86
- const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
87
-
88
- expect(points).toHaveLength(states.length);
89
- expect(texts).toHaveLength(states.length);
90
-
91
- for (const text of texts) {
92
- const point = points.find((p) => p.data?.label === text.text);
93
- expect(point, `no point for label ${text.text}`).toBeDefined();
94
-
95
- expect(text.x).toBeCloseTo((point as PointMark).cx, 5);
96
- expect(text.y).toBeCloseTo((point as PointMark).cy, 5);
97
- }
98
- });
99
-
100
- it('offsets labels off their points by exactly dy, keeping x aligned', () => {
101
- const layout = compileLayer(labeledScatter({ type: 'text', dy: -10 }), {
102
- width: 900,
103
- height: 500,
104
- });
105
-
106
- const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
107
- const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
108
-
109
- for (const text of texts) {
110
- const point = points.find((p) => p.data?.label === text.text) as PointMark;
111
- expect(text.x).toBeCloseTo(point.cx, 5);
112
- expect(text.y).toBeCloseTo(point.cy - 10, 5);
113
- }
114
- });
115
- });
116
-
117
- // ---------------------------------------------------------------------------
118
- // Offsets
119
- // ---------------------------------------------------------------------------
120
-
121
- describe('dx / dy', () => {
122
- it('bakes the offset into x/y and retains the pre-offset anchor', () => {
123
- const plain = textMarksOf(textChart());
124
- const offset = textMarksOf(textChart({ type: 'text', dx: 6, dy: -10 }));
125
-
126
- for (let i = 0; i < plain.length; i++) {
127
- expect(offset[i].x).toBeCloseTo(plain[i].x + 6, 5);
128
- expect(offset[i].y).toBeCloseTo(plain[i].y - 10, 5);
129
- // The anchor is what a leader line would point back to.
130
- expect(offset[i].anchorX).toBeCloseTo(plain[i].x, 5);
131
- expect(offset[i].anchorY).toBeCloseTo(plain[i].y, 5);
132
- }
133
- });
134
-
135
- it('leaves the anchor unset when the mark is not offset', () => {
136
- for (const mark of textMarksOf(textChart())) {
137
- expect(mark.anchorX).toBeUndefined();
138
- expect(mark.anchorY).toBeUndefined();
139
- }
140
- });
141
- });
142
-
143
- // ---------------------------------------------------------------------------
144
- // Alignment / baseline
145
- // ---------------------------------------------------------------------------
146
-
147
- describe('align and baseline', () => {
148
- it('centers on the data point by default', () => {
149
- for (const mark of textMarksOf(textChart())) {
150
- expect(mark.textAnchor).toBe('middle');
151
- expect(mark.dominantBaseline).toBe('central');
152
- }
153
- });
154
-
155
- it('maps align/baseline onto SVG anchor and baseline', () => {
156
- const marks = textMarksOf(textChart({ type: 'text', align: 'left', baseline: 'top' }));
157
- for (const mark of marks) {
158
- expect(mark.textAnchor).toBe('start');
159
- expect(mark.dominantBaseline).toBe('hanging');
160
- }
161
- });
162
- });
163
-
164
- // ---------------------------------------------------------------------------
165
- // Size channel
166
- // ---------------------------------------------------------------------------
167
-
168
- describe('size channel', () => {
169
- const sized = (): ChartSpec => ({
170
- mark: { type: 'text' },
171
- data: [...states],
172
- encoding: {
173
- x: { field: 'gdp', type: 'quantitative' },
174
- y: { field: 'pop', type: 'quantitative' },
175
- text: { field: 'label', type: 'nominal' },
176
- size: { field: 'gdp', type: 'quantitative' },
177
- },
178
- });
179
-
180
- it('maps the size field linearly onto the default font range', () => {
181
- const marks = textMarksOf(sized());
182
- const byLabel = new Map(marks.map((m) => [m.text, m]));
183
-
184
- // gdp spans 0.8 (WA) to 3.9 (CA) -> the default 11..32px range.
185
- expect(byLabel.get('WA')?.fontSize).toBeCloseTo(11, 5);
186
- expect(byLabel.get('CA')?.fontSize).toBeCloseTo(32, 5);
187
-
188
- // Linear, not sqrt: interior values interpolate proportionally. A sqrt scale
189
- // would land NY well above this.
190
- const ny = byLabel.get('NY') as TextMarkLayout; // gdp 2.1
191
- const expectedNy = 11 + (32 - 11) * ((2.1 - 0.8) / (3.9 - 0.8));
192
- expect(ny.fontSize).toBeCloseTo(expectedNy, 5);
193
- });
194
-
195
- it('honors an explicit scale range', () => {
196
- const spec = sized();
197
- (spec.encoding.size as { scale?: unknown }).scale = { range: [10, 20] };
198
-
199
- const marks = textMarksOf(spec);
200
- const byLabel = new Map(marks.map((m) => [m.text, m]));
201
- expect(byLabel.get('WA')?.fontSize).toBeCloseTo(10, 5);
202
- expect(byLabel.get('CA')?.fontSize).toBeCloseTo(20, 5);
203
- });
204
-
205
- it('falls back to mark.fontSize when there is no size encoding', () => {
206
- for (const mark of textMarksOf(textChart({ type: 'text', fontSize: 14 }))) {
207
- expect(mark.fontSize).toBe(14);
208
- }
209
- });
210
-
211
- it('defaults to 12px with no size encoding and no fontSize', () => {
212
- for (const mark of textMarksOf(textChart())) {
213
- expect(mark.fontSize).toBe(12);
214
- }
215
- });
216
- });
217
-
218
- // ---------------------------------------------------------------------------
219
- // Subset label layers
220
- // ---------------------------------------------------------------------------
221
-
222
- describe('a label layer over a subset of the points', () => {
223
- /**
224
- * Direct labeling usually names only the notable points, so the text layer
225
- * carries fewer rows than the point layer. The two must still resolve the same
226
- * domain — a subset must not re-fit the scale to its own narrower extent.
227
- */
228
- it('keeps subset labels aligned with the full point layer', () => {
229
- const labeled = new Set(['CA', 'TX', 'NY', 'FL', 'IL', 'WA']);
230
- const spec: LayerSpec = {
231
- layer: [
232
- {
233
- mark: { type: 'point' },
234
- data: [...states],
235
- encoding: {
236
- x: { field: 'gdp', type: 'quantitative' },
237
- y: { field: 'pop', type: 'quantitative' },
238
- },
239
- },
240
- {
241
- mark: { type: 'text', dy: -14 },
242
- data: states.filter((d) => labeled.has(d.label)),
243
- encoding: {
244
- x: { field: 'gdp', type: 'quantitative' },
245
- y: { field: 'pop', type: 'quantitative' },
246
- text: { field: 'label', type: 'nominal' },
247
- },
248
- },
249
- ],
250
- };
251
-
252
- const layout = compileLayer(spec, { width: 900, height: 500 });
253
- const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
254
- const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
255
-
256
- expect(points).toHaveLength(states.length);
257
- expect(texts).toHaveLength(labeled.size);
258
-
259
- for (const text of texts) {
260
- const point = points.find((p) => p.data?.label === text.text) as PointMark;
261
- expect(text.x).toBeCloseTo(point.cx, 5);
262
- expect(text.y).toBeCloseTo(point.cy - 14, 5);
263
- }
264
- });
265
- });
266
-
267
- describe('a subset whose extent is narrower than the point layer', () => {
268
- /**
269
- * Dropping the min-x row from the label layer narrows *its* data extent. If
270
- * each leaf resolves its own domain, the label layer re-fits [1.1, 3.9] while
271
- * the points keep [0.8, 3.9], and every label slides left off its dot.
272
- */
273
- it('still aligns when the label subset omits the extreme point', () => {
274
- const labeled = new Set(['CA', 'TX', 'NY', 'FL', 'IL']); // no WA (the min gdp)
275
- const spec: LayerSpec = {
276
- layer: [
277
- {
278
- mark: { type: 'point' },
279
- data: [...states],
280
- encoding: {
281
- x: { field: 'gdp', type: 'quantitative' },
282
- y: { field: 'pop', type: 'quantitative' },
283
- },
284
- },
285
- {
286
- mark: { type: 'text' },
287
- data: states.filter((d) => labeled.has(d.label)),
288
- encoding: {
289
- x: { field: 'gdp', type: 'quantitative' },
290
- y: { field: 'pop', type: 'quantitative' },
291
- text: { field: 'label', type: 'nominal' },
292
- },
293
- },
294
- ],
295
- };
296
-
297
- const layout = compileLayer(spec, { width: 900, height: 500 });
298
- const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
299
- const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
300
-
301
- for (const text of texts) {
302
- const point = points.find((p) => p.data?.label === text.text) as PointMark;
303
- expect(text.x, `label ${text.text} drifted in x`).toBeCloseTo(point.cx, 5);
304
- expect(text.y, `label ${text.text} drifted in y`).toBeCloseTo(point.cy, 5);
305
- }
306
- });
307
- });