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

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