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

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,273 @@
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
+ }
@@ -0,0 +1,82 @@
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';
@@ -0,0 +1,169 @@
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
+ }
@@ -0,0 +1,120 @@
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
+ });