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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/index.d.ts +140 -2
  2. package/dist/index.js +1403 -210
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2339
  7. package/src/__tests__/annotation-behavior.test.ts +0 -721
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -370
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -539
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -626
  133. package/src/compile/size-scale.ts +0 -139
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1450
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -747
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. package/src/transforms/window.ts +0 -185
@@ -1,273 +0,0 @@
1
- /**
2
- * Range chart mark computation (dumbbell / arrow / range bar).
3
- *
4
- * A band scale on the nominal axis and two quantitative positions per row
5
- * (x + x2 horizontal, or y + y2 vertical). Three styles via markDef.style:
6
- * - 'dumbbell' (default): connector rule + a dot at each end. The start dot
7
- * is muted, the end dot carries the accent color.
8
- * - 'arrow': shaft rule + chevron arrowhead at the x2/y2 end, built from the
9
- * shared computeArrowheadPoints geometry.
10
- * - 'bar': plain floating RectMark spanning start to end.
11
- *
12
- * Coloring precedence for the accent (connector/shaft/bar + end dot):
13
- * field-based encoding.color > markDef.colorByDirection (positive/negative
14
- * semantic tokens) > scales.defaultColor (markDef.fill or first palette color).
15
- */
16
-
17
- import type {
18
- Encoding,
19
- LayoutStrategy,
20
- Mark,
21
- MarkAria,
22
- Rect,
23
- ResolvedTheme,
24
- } from '@opendata-ai/openchart-core';
25
- import { getRepresentativeColor } from '@opendata-ai/openchart-core';
26
- import type { ScaleBand, ScaleLinear } from 'd3-scale';
27
-
28
- import { computeArrowheadPoints } from '../../annotations/geometry';
29
- import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
30
- import type { NormalizedChartSpec } from '../../compiler/types';
31
- import type { ResolvedScales } from '../../layout/scales';
32
- import { getColor } from '../utils';
33
-
34
- // ---------------------------------------------------------------------------
35
- // Constants
36
- // ---------------------------------------------------------------------------
37
-
38
- const DOT_RADIUS = 6;
39
- const CONNECTOR_WIDTH = 2;
40
- const CONNECTOR_COLOR = '#cccccc';
41
- const ARROW_STROKE_WIDTH = 2;
42
- const ARROWHEAD_LENGTH = 8;
43
- const ARROWHEAD_HALF_WIDTH = 4;
44
-
45
- /** Range mark style variants. */
46
- export type RangeStyle = 'dumbbell' | 'arrow' | 'bar';
47
-
48
- // ---------------------------------------------------------------------------
49
- // Orientation helpers
50
- // ---------------------------------------------------------------------------
51
-
52
- /**
53
- * Resolve the range orientation from resolved scales.
54
- * Horizontal (nominal y, the common editorial form) when y is a band scale;
55
- * vertical when x is a band scale.
56
- */
57
- export function resolveRangeOrientation(scales: ResolvedScales): 'horizontal' | 'vertical' | null {
58
- if (
59
- scales.y?.type === 'band' &&
60
- typeof (scales.y.scale as ScaleBand<string>).bandwidth === 'function'
61
- ) {
62
- return 'horizontal';
63
- }
64
- if (
65
- scales.x?.type === 'band' &&
66
- typeof (scales.x.scale as ScaleBand<string>).bandwidth === 'function'
67
- ) {
68
- return 'vertical';
69
- }
70
- return null;
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Public API
75
- // ---------------------------------------------------------------------------
76
-
77
- /**
78
- * Compute range marks from a normalized chart spec.
79
- *
80
- * Emission order per data row is stable and load-bearing for the label pass:
81
- * connector/shaft/bar first (so dots layer on top), then the start dot, then
82
- * the end dot (dumbbell style only).
83
- */
84
- export function computeRangeMarks(
85
- spec: NormalizedChartSpec,
86
- scales: ResolvedScales,
87
- _chartArea: Rect,
88
- _strategy: LayoutStrategy,
89
- theme: ResolvedTheme,
90
- ): Mark[] {
91
- const encoding = spec.encoding as Encoding;
92
- const orientation = resolveRangeOrientation(scales);
93
- if (!orientation || !scales.x || !scales.y) return [];
94
-
95
- const horizontal = orientation === 'horizontal';
96
- const catChannel = horizontal ? encoding.y : encoding.x;
97
- const startChannel = horizontal ? encoding.x : encoding.y;
98
- const endChannel = horizontal ? encoding.x2 : encoding.y2;
99
- if (!catChannel || !startChannel || !endChannel) return [];
100
-
101
- const bandScale = (horizontal ? scales.y.scale : scales.x.scale) as ScaleBand<string>;
102
- const valueScale = (horizontal ? scales.x.scale : scales.y.scale) as ScaleLinear<number, number>;
103
- const bandwidth = bandScale.bandwidth();
104
-
105
- const style: RangeStyle = spec.markDef.style ?? 'dumbbell';
106
- const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
107
- const colorByDirection = spec.markDef.colorByDirection === true && !colorEnc;
108
-
109
- // Stable identity keys per category row (deduped for repeated categories).
110
- const catKeys = dedupeKeys(spec.data.map((row) => serializeKeyValue(row[catChannel.field])));
111
-
112
- const marks: Mark[] = [];
113
-
114
- for (let i = 0; i < spec.data.length; i++) {
115
- const row = spec.data[i];
116
- const category = String(row[catChannel.field] ?? '');
117
- const bandPos = bandScale(category);
118
- if (bandPos === undefined) continue;
119
-
120
- const startRaw = row[startChannel.field];
121
- const endRaw = row[endChannel.field];
122
- if (startRaw == null || endRaw == null) continue;
123
- const startVal = Number(startRaw);
124
- const endVal = Number(endRaw);
125
- if (!Number.isFinite(startVal) || !Number.isFinite(endVal)) continue;
126
-
127
- const center = bandPos + bandwidth / 2;
128
- const pStart = valueScale(startVal);
129
- const pEnd = valueScale(endVal);
130
- const delta = endVal - startVal;
131
- const catKey = catKeys[i];
132
-
133
- // Accent: field color > direction color > default (markDef.fill / palette).
134
- const accent = colorEnc
135
- ? getColor(scales, String(row[colorEnc.field] ?? ''))
136
- : colorByDirection
137
- ? delta > 0
138
- ? theme.colors.positive
139
- : delta < 0
140
- ? theme.colors.negative
141
- : theme.colors.axis
142
- : getColor(scales, '__default__');
143
- const accentStroke = getRepresentativeColor(accent);
144
-
145
- const spanAria: MarkAria = {
146
- label: `${category}: ${startVal} to ${endVal}`,
147
- };
148
-
149
- // Pixel endpoints in x/y space
150
- const x1 = horizontal ? pStart : center;
151
- const y1 = horizontal ? center : pStart;
152
- const x2 = horizontal ? pEnd : center;
153
- const y2 = horizontal ? center : pEnd;
154
- const spanLength = Math.abs(pEnd - pStart);
155
-
156
- if (style === 'bar') {
157
- marks.push({
158
- type: 'rect',
159
- x: horizontal ? Math.min(pStart, pEnd) : bandPos,
160
- y: horizontal ? bandPos : Math.min(pStart, pEnd),
161
- width: horizontal ? spanLength : bandwidth,
162
- height: horizontal ? bandwidth : spanLength,
163
- fill: accent,
164
- data: row as Record<string, unknown>,
165
- aria: spanAria,
166
- key: catKey,
167
- orient: horizontal ? 'horizontal' : 'vertical',
168
- });
169
- continue;
170
- }
171
-
172
- if (style === 'arrow') {
173
- if (spanLength > 0) {
174
- marks.push({
175
- type: 'rule',
176
- x1,
177
- y1,
178
- x2,
179
- y2,
180
- stroke: accentStroke,
181
- strokeWidth: ARROW_STROKE_WIDTH,
182
- data: row as Record<string, unknown>,
183
- aria: spanAria,
184
- key: `${catKey}|range`,
185
- });
186
-
187
- // Chevron arrowhead at the end value, from the shared geometry helper.
188
- const head = computeArrowheadPoints(
189
- x2,
190
- y2,
191
- x2 - x1,
192
- y2 - y1,
193
- ARROWHEAD_LENGTH,
194
- ARROWHEAD_HALF_WIDTH,
195
- );
196
- const headAria: MarkAria = { label: spanAria.label, decorative: true };
197
- marks.push(
198
- {
199
- type: 'rule',
200
- x1: head.baseLeft.x,
201
- y1: head.baseLeft.y,
202
- x2: head.tip.x,
203
- y2: head.tip.y,
204
- stroke: accentStroke,
205
- strokeWidth: ARROW_STROKE_WIDTH,
206
- data: row as Record<string, unknown>,
207
- aria: headAria,
208
- key: `${catKey}|head-left`,
209
- },
210
- {
211
- type: 'rule',
212
- x1: head.baseRight.x,
213
- y1: head.baseRight.y,
214
- x2: head.tip.x,
215
- y2: head.tip.y,
216
- stroke: accentStroke,
217
- strokeWidth: ARROW_STROKE_WIDTH,
218
- data: row as Record<string, unknown>,
219
- aria: headAria,
220
- key: `${catKey}|head-right`,
221
- },
222
- );
223
- }
224
- continue;
225
- }
226
-
227
- // Dumbbell (default): connector + muted start dot + accent end dot.
228
- if (spanLength > 0) {
229
- const connectorColor = colorEnc || colorByDirection ? accentStroke : CONNECTOR_COLOR;
230
- marks.push({
231
- type: 'rule',
232
- x1,
233
- y1,
234
- x2,
235
- y2,
236
- stroke: connectorColor,
237
- strokeWidth: CONNECTOR_WIDTH,
238
- data: row as Record<string, unknown>,
239
- aria: spanAria,
240
- key: `${catKey}|range`,
241
- });
242
- }
243
-
244
- marks.push(
245
- {
246
- type: 'point',
247
- cx: x1,
248
- cy: y1,
249
- r: DOT_RADIUS,
250
- fill: theme.colors.axis,
251
- stroke: '#ffffff',
252
- strokeWidth: 2,
253
- data: row as Record<string, unknown>,
254
- aria: { label: `${category}, start: ${startVal}` },
255
- key: `${catKey}|start`,
256
- },
257
- {
258
- type: 'point',
259
- cx: x2,
260
- cy: y2,
261
- r: DOT_RADIUS,
262
- fill: accent,
263
- stroke: '#ffffff',
264
- strokeWidth: 2,
265
- data: row as Record<string, unknown>,
266
- aria: { label: `${category}, end: ${endVal}` },
267
- key: `${catKey}|end`,
268
- },
269
- );
270
- }
271
-
272
- return marks;
273
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * Range chart module (dumbbell / arrow / range bar).
3
- *
4
- * Exports the range chart renderer and computation functions.
5
- */
6
-
7
- import type { PointMark } from '@opendata-ai/openchart-core';
8
- import { resolveFieldFormatter } from '../../format/field-format';
9
- import type { ChartRenderer } from '../registry';
10
- import { computeRangeMarks, resolveRangeOrientation } from './compute';
11
- import { computeRangeLabels, type RangeDotPair } from './labels';
12
-
13
- // ---------------------------------------------------------------------------
14
- // Range chart renderer
15
- // ---------------------------------------------------------------------------
16
-
17
- /**
18
- * Range chart renderer.
19
- *
20
- * Produces connector/shaft/bar spans plus dot pairs (dumbbell style).
21
- * Both-end value labels are attached to the dumbbell dot marks; arrow and
22
- * bar styles have no dot anchors and render without value labels.
23
- */
24
- export const rangeRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
25
- const marks = computeRangeMarks(spec, scales, chartArea, strategy, theme);
26
-
27
- const style = spec.markDef.style ?? 'dumbbell';
28
- if (style !== 'dumbbell') return marks;
29
-
30
- // Dumbbell dots are emitted in (start, end) order per row; pair them up
31
- // for the both-end label pass.
32
- const dots = marks.filter((m): m is PointMark => m.type === 'point');
33
- const pairs: RangeDotPair[] = [];
34
- for (let i = 0; i + 1 < dots.length; i += 2) {
35
- pairs.push({ start: dots[i], end: dots[i + 1] });
36
- }
37
-
38
- const orientation = resolveRangeOrientation(scales);
39
- const horizontal = orientation !== 'vertical';
40
- const encoding = spec.encoding;
41
- const startChannel = horizontal ? encoding.x : encoding.y;
42
- const endChannel = horizontal ? encoding.x2 : encoding.y2;
43
-
44
- const startField = startChannel && 'field' in startChannel ? startChannel.field : undefined;
45
- const endField = endChannel && 'field' in endChannel ? endChannel.field : undefined;
46
- const startValues = startField ? spec.data.map((r) => r[startField]) : [];
47
- const endValues = endField ? spec.data.map((r) => r[endField]) : [];
48
- const labelFormatter = resolveFieldFormatter({
49
- surfaceFormat: spec.labels.format,
50
- channelFormat: startChannel && 'format' in startChannel ? startChannel.format : undefined,
51
- values: [...startValues, ...endValues],
52
- });
53
- const labels = computeRangeLabels(
54
- pairs,
55
- horizontal,
56
- spec.labels.density,
57
- spec.labels.prefix,
58
- labelFormatter,
59
- startField,
60
- endField,
61
- );
62
-
63
- // Attach labels back onto their dots via the index carried through the
64
- // label pass (pairIndex * 2 = start dot, pairIndex * 2 + 1 = end dot).
65
- for (const label of labels) {
66
- if (label.index === undefined) continue;
67
- const pair = pairs[Math.floor(label.index / 2)];
68
- if (!pair) continue;
69
- const dot = label.index % 2 === 0 ? pair.start : pair.end;
70
- dot.label = label;
71
- }
72
-
73
- return marks;
74
- };
75
-
76
- // ---------------------------------------------------------------------------
77
- // Public exports
78
- // ---------------------------------------------------------------------------
79
-
80
- export { computeRangeMarks, resolveRangeOrientation } from './compute';
81
- export type { RangeDotPair } from './labels';
82
- export { computeRangeLabels } from './labels';
@@ -1,169 +0,0 @@
1
- /**
2
- * Range chart label computation (dumbbell style).
3
- *
4
- * Produces value labels at both ends of each dumbbell: outside the range span
5
- * so labels never sit on the connector. Horizontal ranges label left of the
6
- * lower-value dot and right of the higher-value dot; vertical ranges label
7
- * below the lower dot and above the upper dot.
8
- *
9
- * Respects the spec's label density setting the same way dot labels do:
10
- * - 'all': show every label, skip collision detection
11
- * - 'auto': collision detection drops overlapping labels
12
- * - 'endpoints': first and last rows only
13
- * - 'none': return empty array
14
- */
15
-
16
- import type {
17
- LabelCandidate,
18
- LabelDensity,
19
- NumberFormatter,
20
- PointMark,
21
- ResolvedLabel,
22
- } from '@opendata-ai/openchart-core';
23
- import {
24
- estimateTextWidth,
25
- getRepresentativeColor,
26
- resolveCollisions,
27
- } from '@opendata-ai/openchart-core';
28
- import { filterByDensity } from '../_shared/density-filter';
29
- import { formatLabelValue } from '../_shared/format-label-value';
30
-
31
- // ---------------------------------------------------------------------------
32
- // Constants
33
- // ---------------------------------------------------------------------------
34
-
35
- const LABEL_FONT_SIZE = 11;
36
- const LABEL_FONT_WEIGHT = 600;
37
- const LABEL_OFFSET = 10;
38
-
39
- /** A start/end dot pair for one data row. */
40
- export interface RangeDotPair {
41
- start: PointMark;
42
- end: PointMark;
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Helpers
47
- // ---------------------------------------------------------------------------
48
-
49
- /** Format one endpoint value using the spec's label format/prefix. */
50
- function formatEndpoint(
51
- value: unknown,
52
- formatter: ((n: number) => string) | null,
53
- prefix: string | undefined,
54
- ): string | null {
55
- const num = Number(value);
56
- if (!Number.isFinite(num)) return null;
57
- const text = formatter ? formatter(num) : formatLabelValue(num);
58
- return prefix ? prefix + text : text;
59
- }
60
-
61
- /** Build one label candidate placed outside the range span. */
62
- function buildCandidate(
63
- mark: PointMark,
64
- text: string,
65
- horizontal: boolean,
66
- outerSide: 'min' | 'max',
67
- index: number,
68
- ): LabelCandidate {
69
- const textWidth = estimateTextWidth(text, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
70
- const textHeight = LABEL_FONT_SIZE * 1.2;
71
-
72
- let anchorX: number;
73
- let anchorY: number;
74
- if (horizontal) {
75
- // 'min' dot: label to the left; 'max' dot: label to the right.
76
- anchorX =
77
- outerSide === 'min'
78
- ? mark.cx - mark.r - LABEL_OFFSET - textWidth
79
- : mark.cx + mark.r + LABEL_OFFSET;
80
- anchorY = mark.cy - textHeight / 2;
81
- } else {
82
- // SVG y grows downward: the 'min' value dot sits lower, label below it.
83
- anchorX = mark.cx - textWidth / 2;
84
- anchorY =
85
- outerSide === 'min'
86
- ? mark.cy + mark.r + LABEL_OFFSET - textHeight / 2
87
- : mark.cy - mark.r - LABEL_OFFSET - textHeight / 2;
88
- }
89
-
90
- return {
91
- text,
92
- anchorX,
93
- anchorY,
94
- width: textWidth,
95
- height: textHeight,
96
- priority: 'data',
97
- index,
98
- style: {
99
- fontFamily: 'system-ui, -apple-system, sans-serif',
100
- fontSize: LABEL_FONT_SIZE,
101
- fontWeight: LABEL_FONT_WEIGHT,
102
- fill: getRepresentativeColor(mark.fill),
103
- lineHeight: 1.2,
104
- textAnchor: 'start',
105
- dominantBaseline: 'central',
106
- fontVariant: 'tabular-nums',
107
- },
108
- };
109
- }
110
-
111
- // ---------------------------------------------------------------------------
112
- // Public API
113
- // ---------------------------------------------------------------------------
114
-
115
- /**
116
- * Compute both-end value labels for dumbbell range marks.
117
- *
118
- * Each returned label carries `index` = pairIndex * 2 (start dot) or
119
- * pairIndex * 2 + 1 (end dot) so the caller can attach labels back onto the
120
- * right dot marks after density filtering. Labels dropped by collision
121
- * resolution come back with `visible: false`.
122
- */
123
- export function computeRangeLabels(
124
- pairs: RangeDotPair[],
125
- horizontal: boolean,
126
- density: LabelDensity = 'auto',
127
- labelPrefix?: string,
128
- labelFormatter?: NumberFormatter | null,
129
- startField?: string,
130
- endField?: string,
131
- ): ResolvedLabel[] {
132
- if (density === 'none' || !startField || !endField) return [];
133
-
134
- const indexed = pairs.map((pair, pairIndex) => ({ pair, pairIndex }));
135
- const targetPairs = filterByDensity(indexed, density);
136
- const formatter = labelFormatter ?? null;
137
- const candidates: LabelCandidate[] = [];
138
-
139
- for (const { pair, pairIndex } of targetPairs) {
140
- const startText = formatEndpoint(pair.start.data[startField], formatter, labelPrefix);
141
- const endText = formatEndpoint(pair.end.data[endField], formatter, labelPrefix);
142
- if (startText == null || endText == null) continue;
143
-
144
- // Place each label on the outer side of its dot. For horizontal ranges
145
- // the lower pixel position is the left dot; vertical is inverted (SVG y
146
- // grows downward), so the larger cy is the lower-value dot.
147
- const startIsMin = horizontal ? pair.start.cx <= pair.end.cx : pair.start.cy >= pair.end.cy;
148
- candidates.push(
149
- buildCandidate(pair.start, startText, horizontal, startIsMin ? 'min' : 'max', pairIndex * 2),
150
- buildCandidate(pair.end, endText, horizontal, startIsMin ? 'max' : 'min', pairIndex * 2 + 1),
151
- );
152
- }
153
-
154
- if (candidates.length === 0) return [];
155
-
156
- // 'all': skip collision detection, mark everything visible
157
- if (density === 'all') {
158
- return candidates.map((c) => ({
159
- text: c.text,
160
- x: c.anchorX,
161
- y: c.anchorY,
162
- style: c.style,
163
- visible: true,
164
- index: c.index,
165
- }));
166
- }
167
-
168
- return resolveCollisions(candidates);
169
- }
@@ -1,120 +0,0 @@
1
- import type { RectMark } from '@opendata-ai/openchart-core';
2
- import { describe, expect, it } from 'vitest';
3
- import { compileChart } from '../../../compile';
4
-
5
- /** A 3x2 day/hour grid with a quantitative value: the canonical heatmap shape. */
6
- const grid = [
7
- { day: 'Mon', hour: '9am', v: 3 },
8
- { day: 'Mon', hour: '10am', v: 7 },
9
- { day: 'Tue', hour: '9am', v: 5 },
10
- { day: 'Tue', hour: '10am', v: 9 },
11
- { day: 'Wed', hour: '9am', v: 1 },
12
- { day: 'Wed', hour: '10am', v: 4 },
13
- ];
14
-
15
- function heatmap(data = grid) {
16
- return {
17
- mark: { type: 'rect' as const },
18
- data: [...data],
19
- encoding: {
20
- x: { field: 'day', type: 'nominal' as const },
21
- y: { field: 'hour', type: 'nominal' as const },
22
- color: { field: 'v', type: 'quantitative' as const },
23
- },
24
- };
25
- }
26
-
27
- function cellsOf(spec: Parameters<typeof compileChart>[0]): RectMark[] {
28
- const layout = compileChart(spec, { width: 600, height: 400 });
29
- return layout.marks.filter((m): m is RectMark => m.type === 'rect');
30
- }
31
-
32
- describe('rect mark (heatmap)', () => {
33
- /**
34
- * Regression: `rect` used to alias `columnRenderer`, which requires a *linear*
35
- * y-scale (it anchors cells to `yScale(0)`). A heatmap bands both axes, so the
36
- * renderer hit its bandwidth guard and returned []. `mark: 'rect'` rendered a
37
- * blank chart with no error.
38
- */
39
- it('emits one cell per row instead of a blank chart', () => {
40
- const cells = cellsOf(heatmap());
41
- expect(cells).toHaveLength(grid.length);
42
- });
43
-
44
- it('gives every cell a positive width and height', () => {
45
- for (const cell of cellsOf(heatmap())) {
46
- expect(cell.width).toBeGreaterThan(0);
47
- expect(cell.height).toBeGreaterThan(0);
48
- }
49
- });
50
-
51
- it('tiles cells into a grid: one distinct x per day, one distinct y per hour', () => {
52
- const cells = cellsOf(heatmap());
53
- expect(new Set(cells.map((c) => c.x)).size).toBe(3); // Mon/Tue/Wed
54
- expect(new Set(cells.map((c) => c.y)).size).toBe(2); // 9am/10am
55
- });
56
-
57
- it('colors cells from the quantitative color scale, not a flat default', () => {
58
- const fills = new Set(cellsOf(heatmap()).map((c) => String(c.fill)));
59
- // Six distinct values -> a sequential ramp must produce more than one color.
60
- expect(fills.size).toBeGreaterThan(1);
61
- });
62
-
63
- it('keys each cell on its x/y pair so updates match by cell', () => {
64
- const keys = cellsOf(heatmap()).map((c) => c.key);
65
- expect(new Set(keys).size).toBe(grid.length);
66
- });
67
-
68
- it('skips rows whose color value is not finite', () => {
69
- const cells = cellsOf(
70
- heatmap([...grid, { day: 'Thu', hour: '9am', v: Number.NaN as unknown as number }]),
71
- );
72
- expect(cells).toHaveLength(grid.length);
73
- });
74
-
75
- /**
76
- * Cells tile. The band default (0.35) is a *bar* value -- bars need air
77
- * between them to read as separate quantities -- and it left the heatmap
78
- * looking like a scatter of floating rectangles. A cell gap should be a
79
- * hairline gutter, so each cell claims nearly its whole step.
80
- */
81
- it('tiles cells with only a hairline gutter, not a bar-sized gap', () => {
82
- const cells = cellsOf(heatmap());
83
- const xs = [...new Set(cells.map((c) => c.x))].sort((a, b) => a - b);
84
- const step = xs[1] - xs[0];
85
- const cellWidth = cells[0].width;
86
- // Cell fills at least 90% of its step (0.35 padding would give ~65%).
87
- expect(cellWidth / step).toBeGreaterThan(0.9);
88
- });
89
-
90
- it('draws no gridlines: the cells are the grid', () => {
91
- const layout = compileChart(heatmap(), { width: 600, height: 400 });
92
- expect(layout.axes.y?.gridlines ?? []).toHaveLength(0);
93
- });
94
-
95
- /**
96
- * A quantitative axis has no bandwidth, so a cell has no width. Emitting
97
- * zero-area marks would render an invisible chart -- the exact failure this
98
- * renderer exists to end. Bail instead of pretending.
99
- */
100
- it('emits nothing when an axis is quantitative (no bandwidth to size a cell)', () => {
101
- const cells = cellsOf({
102
- mark: { type: 'rect' as const },
103
- data: [...grid],
104
- encoding: {
105
- x: { field: 'v', type: 'quantitative' as const },
106
- y: { field: 'hour', type: 'nominal' as const },
107
- color: { field: 'v', type: 'quantitative' as const },
108
- },
109
- });
110
- expect(cells).toHaveLength(0);
111
- });
112
-
113
- it('labels each cell with its x, y, and value for screen readers', () => {
114
- const cells = cellsOf(heatmap());
115
- const mon9 = cells.find((c) => c.data?.day === 'Mon' && c.data?.hour === '9am');
116
- expect(mon9?.aria.label).toContain('Mon');
117
- expect(mon9?.aria.label).toContain('9am');
118
- expect(mon9?.aria.label).toContain('3');
119
- });
120
- });