@opendata-ai/openchart-engine 8.0.0-rc.9 → 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 +804 -155
  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 -2401
  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 -630
  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 -1503
  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 -1128
  185. package/src/geo/compile-map.ts +0 -1376
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -65
  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 -748
  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,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
- });