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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/index.d.ts +0 -7
  2. package/dist/index.js +161 -579
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/__test-fixtures__/specs.ts +182 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +2339 -0
  7. package/src/__tests__/annotation-behavior.test.ts +721 -0
  8. package/src/__tests__/axes.test.ts +1579 -0
  9. package/src/__tests__/compile-animation.test.ts +161 -0
  10. package/src/__tests__/compile-chart.test.ts +1303 -0
  11. package/src/__tests__/compile-footnotes.test.ts +277 -0
  12. package/src/__tests__/compile-layer.test.ts +969 -0
  13. package/src/__tests__/compile-snapshot.test.ts +156 -0
  14. package/src/__tests__/compound-labels.test.ts +147 -0
  15. package/src/__tests__/dimensions.test.ts +918 -0
  16. package/src/__tests__/encoding-sugar.test.ts +390 -0
  17. package/src/__tests__/facet.test.ts +215 -0
  18. package/src/__tests__/helpers/invariants.ts +299 -0
  19. package/src/__tests__/known-bugs.test.ts +566 -0
  20. package/src/__tests__/layout-fuzz.test.ts +358 -0
  21. package/src/__tests__/layout-invariants.test.ts +535 -0
  22. package/src/__tests__/legend.test.ts +721 -0
  23. package/src/__tests__/mark-keys.test.ts +294 -0
  24. package/src/__tests__/ranking-recipes.test.ts +260 -0
  25. package/src/__tests__/rotation.test.ts +83 -0
  26. package/src/__tests__/scales.test.ts +323 -0
  27. package/src/__tests__/series-search.test.ts +92 -0
  28. package/src/__tests__/spec-sugar.test.ts +801 -0
  29. package/src/__tests__/you-draw-it.test.ts +160 -0
  30. package/src/annotations/__tests__/compute.test.ts +1672 -0
  31. package/src/annotations/__tests__/geometry.test.ts +66 -0
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  33. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  34. package/src/annotations/__tests__/placement.test.ts +316 -0
  35. package/src/annotations/__tests__/resolve-text.test.ts +788 -0
  36. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  37. package/src/annotations/__tests__/thinning.test.ts +285 -0
  38. package/src/annotations/collisions.ts +326 -0
  39. package/src/annotations/compute.ts +333 -0
  40. package/src/annotations/constants.ts +162 -0
  41. package/src/annotations/geometry.ts +370 -0
  42. package/src/annotations/move.ts +135 -0
  43. package/src/annotations/placement.ts +636 -0
  44. package/src/annotations/position.ts +131 -0
  45. package/src/annotations/resolve-range.ts +117 -0
  46. package/src/annotations/resolve-refline.ts +157 -0
  47. package/src/annotations/resolve-text.ts +539 -0
  48. package/src/annotations/rich-text.ts +65 -0
  49. package/src/annotations/thinning.ts +190 -0
  50. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  51. package/src/barlist/compile-barlist.ts +391 -0
  52. package/src/barlist/types.ts +28 -0
  53. package/src/charts/__tests__/post-process.test.ts +237 -0
  54. package/src/charts/__tests__/registry.test.ts +116 -0
  55. package/src/charts/__tests__/utils.test.ts +195 -0
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  57. package/src/charts/_shared/density-filter.ts +26 -0
  58. package/src/charts/_shared/format-label-value.ts +16 -0
  59. package/src/charts/bar/__tests__/compute.test.ts +934 -0
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  61. package/src/charts/bar/__tests__/labels.test.ts +292 -0
  62. package/src/charts/bar/compute.ts +539 -0
  63. package/src/charts/bar/index.ts +60 -0
  64. package/src/charts/bar/labels.ts +311 -0
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  67. package/src/charts/beeswarm/compute.ts +203 -0
  68. package/src/charts/beeswarm/dodge.ts +155 -0
  69. package/src/charts/beeswarm/index.ts +32 -0
  70. package/src/charts/builtin.ts +75 -0
  71. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  72. package/src/charts/calendar/compute.ts +319 -0
  73. package/src/charts/calendar/index.ts +32 -0
  74. package/src/charts/column/__tests__/compute.test.ts +540 -0
  75. package/src/charts/column/__tests__/labels.test.ts +179 -0
  76. package/src/charts/column/compute.ts +493 -0
  77. package/src/charts/column/index.ts +53 -0
  78. package/src/charts/column/labels.ts +229 -0
  79. package/src/charts/dot/__tests__/compute.test.ts +380 -0
  80. package/src/charts/dot/__tests__/labels.test.ts +98 -0
  81. package/src/charts/dot/compute.ts +285 -0
  82. package/src/charts/dot/index.ts +62 -0
  83. package/src/charts/dot/labels.ts +121 -0
  84. package/src/charts/line/__tests__/compute.test.ts +1194 -0
  85. package/src/charts/line/__tests__/curves.test.ts +51 -0
  86. package/src/charts/line/__tests__/labels.test.ts +94 -0
  87. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  88. package/src/charts/line/area.ts +484 -0
  89. package/src/charts/line/compute.ts +356 -0
  90. package/src/charts/line/curves.ts +85 -0
  91. package/src/charts/line/index.ts +176 -0
  92. package/src/charts/line/labels.ts +177 -0
  93. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  94. package/src/charts/parliament/compute.ts +313 -0
  95. package/src/charts/parliament/index.ts +32 -0
  96. package/src/charts/pie/__tests__/compute.test.ts +384 -0
  97. package/src/charts/pie/__tests__/labels.test.ts +132 -0
  98. package/src/charts/pie/compute.ts +324 -0
  99. package/src/charts/pie/index.ts +49 -0
  100. package/src/charts/pie/labels.ts +143 -0
  101. package/src/charts/post-process.ts +229 -0
  102. package/src/charts/range/__tests__/compute.test.ts +486 -0
  103. package/src/charts/range/compute.ts +273 -0
  104. package/src/charts/range/index.ts +82 -0
  105. package/src/charts/range/labels.ts +169 -0
  106. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  107. package/src/charts/rect/index.ts +127 -0
  108. package/src/charts/registry.ts +72 -0
  109. package/src/charts/rule/index.ts +139 -0
  110. package/src/charts/scatter/__tests__/compute.test.ts +488 -0
  111. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  112. package/src/charts/scatter/compute.ts +192 -0
  113. package/src/charts/scatter/index.ts +46 -0
  114. package/src/charts/scatter/trendline.ts +100 -0
  115. package/src/charts/text/__tests__/compute.test.ts +307 -0
  116. package/src/charts/text/index.ts +141 -0
  117. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  118. package/src/charts/tick/index.ts +99 -0
  119. package/src/charts/utils.ts +170 -0
  120. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  121. package/src/charts/waffle/compute.ts +197 -0
  122. package/src/charts/waffle/index.ts +31 -0
  123. package/src/compile/__tests__/color-scale-range.test.ts +158 -0
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  125. package/src/compile/__tests__/data-clip.test.ts +127 -0
  126. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  127. package/src/compile/__tests__/size-scale.test.ts +108 -0
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  129. package/src/compile/color-scale-range.ts +101 -0
  130. package/src/compile/data-clip.ts +48 -0
  131. package/src/compile/fill-patterns.ts +74 -0
  132. package/src/compile/layer.ts +626 -0
  133. package/src/compile/size-scale.ts +141 -0
  134. package/src/compile/spec-sugar.ts +758 -0
  135. package/src/compile/watermark-obstacle.ts +54 -0
  136. package/src/compile/you-draw-it.ts +123 -0
  137. package/src/compile.ts +1450 -0
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  139. package/src/compiler/__tests__/animation.test.ts +177 -0
  140. package/src/compiler/__tests__/compile.test.ts +87 -0
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  160. package/src/compiler/__tests__/keys.test.ts +83 -0
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  162. package/src/compiler/__tests__/normalize.test.ts +399 -0
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  165. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  167. package/src/compiler/__tests__/validate.test.ts +629 -0
  168. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  169. package/src/compiler/a11y-warnings.ts +110 -0
  170. package/src/compiler/animation.ts +142 -0
  171. package/src/compiler/index.ts +47 -0
  172. package/src/compiler/keys.ts +55 -0
  173. package/src/compiler/normalize.ts +734 -0
  174. package/src/compiler/sparkline-defaults.ts +138 -0
  175. package/src/compiler/types.ts +241 -0
  176. package/src/compiler/validate.ts +1871 -0
  177. package/src/endpoint-labels/__tests__/compute.test.ts +587 -0
  178. package/src/endpoint-labels/compute.ts +621 -0
  179. package/src/endpoint-labels/constants.ts +54 -0
  180. package/src/endpoint-labels/format.ts +60 -0
  181. package/src/endpoint-labels/predict.ts +139 -0
  182. package/src/format/__tests__/field-format.test.ts +164 -0
  183. package/src/format/field-format.ts +27 -0
  184. package/src/geo/__tests__/compile-map.test.ts +832 -0
  185. package/src/geo/compile-map.ts +1243 -0
  186. package/src/geo/join.ts +115 -0
  187. package/src/geo/projections.ts +45 -0
  188. package/src/geo/types.ts +26 -0
  189. package/src/graphs/__tests__/community.test.ts +228 -0
  190. package/src/graphs/__tests__/compile-graph.test.ts +396 -0
  191. package/src/graphs/__tests__/encoding.test.ts +415 -0
  192. package/src/graphs/community.ts +92 -0
  193. package/src/graphs/compile-graph.ts +329 -0
  194. package/src/graphs/encoding.ts +356 -0
  195. package/src/graphs/types.ts +106 -0
  196. package/src/index.ts +177 -0
  197. package/src/layout/__tests__/metrics.test.ts +97 -0
  198. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  199. package/src/layout/axes/rotation.ts +152 -0
  200. package/src/layout/axes/thinning.ts +98 -0
  201. package/src/layout/axes/ticks.ts +409 -0
  202. package/src/layout/axes.ts +757 -0
  203. package/src/layout/dimensions.ts +912 -0
  204. package/src/layout/facet.ts +91 -0
  205. package/src/layout/gridlines.ts +84 -0
  206. package/src/layout/metrics.ts +132 -0
  207. package/src/layout/plan.ts +631 -0
  208. package/src/layout/scales.ts +1053 -0
  209. package/src/layout/shared.ts +44 -0
  210. package/src/legend/__tests__/continuous.test.ts +318 -0
  211. package/src/legend/__tests__/redundancy.test.ts +108 -0
  212. package/src/legend/__tests__/size.test.ts +348 -0
  213. package/src/legend/__tests__/suppression.test.ts +382 -0
  214. package/src/legend/compute.ts +747 -0
  215. package/src/legend/continuous.ts +327 -0
  216. package/src/legend/size.ts +407 -0
  217. package/src/legend/suppression.ts +226 -0
  218. package/src/legend/wrap.ts +117 -0
  219. package/src/sankey/__tests__/compile-sankey.test.ts +458 -0
  220. package/src/sankey/__tests__/layout.test.ts +165 -0
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  222. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  223. package/src/sankey/compile-sankey.ts +706 -0
  224. package/src/sankey/layout.ts +184 -0
  225. package/src/sankey/types.ts +41 -0
  226. package/src/tables/__tests__/bar-column.test.ts +147 -0
  227. package/src/tables/__tests__/category-colors.test.ts +230 -0
  228. package/src/tables/__tests__/compile-table.test.ts +236 -0
  229. package/src/tables/__tests__/format-cells.test.ts +143 -0
  230. package/src/tables/__tests__/heatmap.test.ts +148 -0
  231. package/src/tables/__tests__/pagination.test.ts +78 -0
  232. package/src/tables/__tests__/search.test.ts +94 -0
  233. package/src/tables/__tests__/sort.test.ts +107 -0
  234. package/src/tables/__tests__/sparkline.test.ts +122 -0
  235. package/src/tables/bar-column.ts +94 -0
  236. package/src/tables/category-colors.ts +78 -0
  237. package/src/tables/compile-table.ts +427 -0
  238. package/src/tables/format-cells.ts +115 -0
  239. package/src/tables/heatmap.ts +141 -0
  240. package/src/tables/pagination.ts +46 -0
  241. package/src/tables/search.ts +66 -0
  242. package/src/tables/sort.ts +69 -0
  243. package/src/tables/sparkline.ts +113 -0
  244. package/src/tables/utils.ts +16 -0
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +576 -0
  246. package/src/tilemap/compile-tilemap.ts +755 -0
  247. package/src/tilemap/layout.ts +172 -0
  248. package/src/tilemap/types.ts +33 -0
  249. package/src/tooltips/__tests__/compute.test.ts +582 -0
  250. package/src/tooltips/compute.ts +700 -0
  251. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  252. package/src/transforms/__tests__/bin.test.ts +88 -0
  253. package/src/transforms/__tests__/calculate.test.ts +146 -0
  254. package/src/transforms/__tests__/conditional.test.ts +127 -0
  255. package/src/transforms/__tests__/filter-relative.test.ts +202 -0
  256. package/src/transforms/__tests__/filter.test.ts +59 -0
  257. package/src/transforms/__tests__/fold.test.ts +79 -0
  258. package/src/transforms/__tests__/index.test.ts +93 -0
  259. package/src/transforms/__tests__/predicates.test.ts +193 -0
  260. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  261. package/src/transforms/__tests__/window.test.ts +286 -0
  262. package/src/transforms/aggregate.ts +131 -0
  263. package/src/transforms/bin.ts +87 -0
  264. package/src/transforms/calculate.ts +60 -0
  265. package/src/transforms/conditional.ts +46 -0
  266. package/src/transforms/filter.ts +122 -0
  267. package/src/transforms/fold.ts +49 -0
  268. package/src/transforms/index.ts +60 -0
  269. package/src/transforms/predicates.ts +123 -0
  270. package/src/transforms/timeunit.ts +88 -0
  271. package/src/transforms/window.ts +185 -0
@@ -0,0 +1,192 @@
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
+ }
@@ -0,0 +1,46 @@
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';
@@ -0,0 +1,100 @@
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
+ }
@@ -0,0 +1,307 @@
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
+ });