@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
@@ -14,6 +14,7 @@ import type {
14
14
  EncodingChannel,
15
15
  LineMark,
16
16
  Mark,
17
+ NumberFormatter,
17
18
  PointMark,
18
19
  RectMark,
19
20
  TooltipContent,
@@ -21,37 +22,36 @@ import type {
21
22
  } from '@opendata-ai/openchart-core';
22
23
  import {
23
24
  buildTemporalFormatter,
25
+ defaultNumberFormatter,
24
26
  formatDate,
25
- formatNumber,
27
+ formatPercent,
26
28
  getRepresentativeColor,
27
29
  } from '@opendata-ai/openchart-core';
28
- import { format as d3Format } from 'd3-format';
29
30
 
30
31
  import type { NormalizedChartSpec } from '../compiler/types';
32
+ import { resolveFieldFormatter } from '../format/field-format';
31
33
 
32
34
  // ---------------------------------------------------------------------------
33
35
  // Helpers
34
36
  // ---------------------------------------------------------------------------
35
37
 
36
38
  /** Format a raw data value for tooltip display. */
37
- function formatValue(value: unknown, fieldType?: string, format?: string): string {
39
+ function formatValue(
40
+ value: unknown,
41
+ fieldType?: string,
42
+ formatter?: NumberFormatter | ((v: Date | string | number) => string) | null,
43
+ ): string {
38
44
  if (value == null) return '';
39
45
 
40
46
  if (fieldType === 'temporal' || value instanceof Date) {
41
- const temporalFmt = buildTemporalFormatter(format);
42
- if (temporalFmt) return temporalFmt(value as Date | string | number);
47
+ if (formatter)
48
+ return (formatter as (v: Date | string | number) => string)(value as Date | string | number);
43
49
  return formatDate(value as Date | string | number);
44
50
  }
45
51
 
46
52
  if (typeof value === 'number') {
47
- if (format) {
48
- try {
49
- return d3Format(format)(value);
50
- } catch {
51
- return formatNumber(value);
52
- }
53
- }
54
- return formatNumber(value);
53
+ if (formatter) return (formatter as NumberFormatter)(value);
54
+ return defaultNumberFormatter()(value);
55
55
  }
56
56
 
57
57
  return String(value);
@@ -69,61 +69,116 @@ function resolveFormat(ch: EncodingChannel): string | undefined {
69
69
  return ch.format ?? ax?.format;
70
70
  }
71
71
 
72
+ type ChannelFormatter = NumberFormatter | ((v: Date | string | number) => string) | null;
73
+
74
+ function cacheKey(ch: EncodingChannel): string {
75
+ const fmt = resolveFormat(ch);
76
+ return fmt ? `${ch.field}::${ch.type}::${fmt}` : `${ch.field}::${ch.type}`;
77
+ }
78
+
79
+ /** Build a per-channel formatter cache for tooltip display. */
80
+ function buildFormatterCache(
81
+ data: DataRow[],
82
+ channels: EncodingChannel[],
83
+ ): Map<string, ChannelFormatter> {
84
+ const cache = new Map<string, ChannelFormatter>();
85
+ for (const ch of channels) {
86
+ const key = cacheKey(ch);
87
+ if (cache.has(key)) continue;
88
+ const fmt = resolveFormat(ch);
89
+ if (ch.type === 'temporal') {
90
+ const temporalFmt = buildTemporalFormatter(fmt);
91
+ cache.set(key, temporalFmt ?? null);
92
+ } else if (ch.type === 'quantitative') {
93
+ cache.set(
94
+ key,
95
+ resolveFieldFormatter({
96
+ surfaceFormat: fmt,
97
+ values: data.map((r) => r[ch.field]),
98
+ }),
99
+ );
100
+ } else {
101
+ cache.set(key, null);
102
+ }
103
+ }
104
+ return cache;
105
+ }
106
+
107
+ function getFormatter(
108
+ cache: Map<string, ChannelFormatter>,
109
+ ch: EncodingChannel,
110
+ ): ChannelFormatter | undefined {
111
+ return cache.get(cacheKey(ch));
112
+ }
113
+
72
114
  /** Build tooltip fields from explicit tooltip encoding channels. */
73
- function buildExplicitTooltipFields(row: DataRow, channels: EncodingChannel[]): TooltipField[] {
115
+ function buildExplicitTooltipFields(
116
+ row: DataRow,
117
+ channels: EncodingChannel[],
118
+ formatters: Map<string, ChannelFormatter>,
119
+ ): TooltipField[] {
74
120
  return channels.map((ch) => ({
75
121
  label: resolveLabel(ch),
76
- value: formatValue(row[ch.field], ch.type, resolveFormat(ch)),
122
+ value: formatValue(row[ch.field], ch.type, getFormatter(formatters, ch)),
77
123
  }));
78
124
  }
79
125
 
80
126
  /** Build tooltip fields from a data row based on the spec encoding. */
81
- function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipField[] {
127
+ function buildFields(
128
+ row: DataRow,
129
+ encoding: Encoding,
130
+ formatters: Map<string, ChannelFormatter>,
131
+ color?: string,
132
+ ): TooltipField[] {
82
133
  if (encoding.tooltip) {
83
134
  const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
84
- return buildExplicitTooltipFields(row, channels);
135
+ return buildExplicitTooltipFields(row, channels, formatters);
85
136
  }
86
137
 
87
138
  const fields: TooltipField[] = [];
88
139
 
89
- // Color/series field (e.g. "Source: Coal") so the user knows which series
90
140
  if (encoding.color && 'field' in encoding.color) {
91
141
  fields.push({
92
142
  label: resolveLabel(encoding.color),
93
143
  value: formatValue(
94
144
  row[encoding.color.field],
95
145
  encoding.color.type,
96
- resolveFormat(encoding.color),
146
+ getFormatter(formatters, encoding.color),
97
147
  ),
98
148
  color,
99
149
  });
100
150
  }
101
151
 
102
- // Y-axis value (the "main" value in most charts)
103
152
  if (encoding.y) {
104
153
  fields.push({
105
154
  label: resolveLabel(encoding.y),
106
- value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
155
+ value: formatValue(
156
+ row[encoding.y.field],
157
+ encoding.y.type,
158
+ getFormatter(formatters, encoding.y),
159
+ ),
107
160
  color: encoding.color ? undefined : color,
108
161
  });
109
162
  }
110
163
 
111
- // X-axis value (often the category or date)
112
164
  if (encoding.x) {
113
165
  fields.push({
114
166
  label: resolveLabel(encoding.x),
115
- value: formatValue(row[encoding.x.field], encoding.x.type, resolveFormat(encoding.x)),
167
+ value: formatValue(
168
+ row[encoding.x.field],
169
+ encoding.x.type,
170
+ getFormatter(formatters, encoding.x),
171
+ ),
116
172
  });
117
173
  }
118
174
 
119
- // Size (for scatter/bubble) - skip conditional size definitions
120
175
  if (encoding.size && 'field' in encoding.size) {
121
176
  fields.push({
122
177
  label: resolveLabel(encoding.size),
123
178
  value: formatValue(
124
179
  row[encoding.size.field],
125
180
  encoding.size.type,
126
- resolveFormat(encoding.size),
181
+ getFormatter(formatters, encoding.size),
127
182
  ),
128
183
  });
129
184
  }
@@ -183,15 +238,14 @@ function getTooltipTitle(row: DataRow, encoding: Encoding): string | undefined {
183
238
  function tooltipsForLine(
184
239
  mark: LineMark,
185
240
  encoding: Encoding,
241
+ formatters: Map<string, ChannelFormatter>,
186
242
  _markIndex: number,
187
243
  ): Array<[string, TooltipContent]> {
188
- // Populate tooltip content on each dataPoint for voronoi overlay lookup.
189
- // Line marks don't emit per-mark tooltip descriptors (the overlay handles it).
190
244
  if (mark.dataPoints) {
191
245
  for (const dp of mark.dataPoints) {
192
246
  dp.tooltip = {
193
247
  title: getTooltipTitle(dp.datum, encoding),
194
- fields: buildFields(dp.datum, encoding, mark.stroke),
248
+ fields: buildFields(dp.datum, encoding, formatters, mark.stroke),
195
249
  };
196
250
  }
197
251
  }
@@ -201,10 +255,11 @@ function tooltipsForLine(
201
255
  function tooltipsForPoint(
202
256
  mark: PointMark,
203
257
  encoding: Encoding,
258
+ formatters: Map<string, ChannelFormatter>,
204
259
  markIndex: number,
205
260
  ): Array<[string, TooltipContent]> {
206
261
  const title = getTooltipTitle(mark.data, encoding);
207
- const fields = buildFields(mark.data, encoding, getRepresentativeColor(mark.fill));
262
+ const fields = buildFields(mark.data, encoding, formatters, getRepresentativeColor(mark.fill));
208
263
 
209
264
  return [[`point-${markIndex}`, { title, fields }]];
210
265
  }
@@ -212,10 +267,11 @@ function tooltipsForPoint(
212
267
  function tooltipsForRect(
213
268
  mark: RectMark,
214
269
  encoding: Encoding,
270
+ formatters: Map<string, ChannelFormatter>,
215
271
  markIndex: number,
216
272
  ): Array<[string, TooltipContent]> {
217
273
  const title = getTooltipTitle(mark.data, encoding);
218
- const fields = buildFields(mark.data, encoding, getRepresentativeColor(mark.fill));
274
+ const fields = buildFields(mark.data, encoding, formatters, getRepresentativeColor(mark.fill));
219
275
 
220
276
  return [[`rect-${markIndex}`, { title, fields }]];
221
277
  }
@@ -223,26 +279,34 @@ function tooltipsForRect(
223
279
  function tooltipsForArc(
224
280
  mark: ArcMark,
225
281
  encoding: Encoding,
282
+ formatters: Map<string, ChannelFormatter>,
226
283
  markIndex: number,
227
284
  ): Array<[string, TooltipContent]> {
228
285
  const row = mark.data;
229
286
  const fields: TooltipField[] = [];
230
287
 
231
- // For pie/donut, show the category and its value
232
288
  const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
233
289
  if (colorEnc) {
234
290
  const categoryName = String(row[colorEnc.field] ?? '');
235
291
  if (encoding.y) {
236
292
  fields.push({
237
293
  label: categoryName,
238
- value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
294
+ value: formatValue(
295
+ row[encoding.y.field],
296
+ encoding.y.type,
297
+ getFormatter(formatters, encoding.y),
298
+ ),
239
299
  color: getRepresentativeColor(mark.fill),
240
300
  });
241
301
  }
242
302
  } else if (encoding.y) {
243
303
  fields.push({
244
304
  label: resolveLabel(encoding.y),
245
- value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
305
+ value: formatValue(
306
+ row[encoding.y.field],
307
+ encoding.y.type,
308
+ getFormatter(formatters, encoding.y),
309
+ ),
246
310
  color: getRepresentativeColor(mark.fill),
247
311
  });
248
312
  }
@@ -255,21 +319,304 @@ function tooltipsForArc(
255
319
  function tooltipsForArea(
256
320
  mark: AreaMark,
257
321
  encoding: Encoding,
322
+ formatters: Map<string, ChannelFormatter>,
258
323
  _markIndex: number,
259
324
  ): Array<[string, TooltipContent]> {
260
- // Populate tooltip content on each dataPoint for voronoi overlay lookup.
261
- // Area marks don't emit per-mark tooltip descriptors (the overlay handles it).
262
325
  if (mark.dataPoints) {
263
326
  for (const dp of mark.dataPoints) {
264
327
  dp.tooltip = {
265
328
  title: getTooltipTitle(dp.datum, encoding),
266
- fields: buildFields(dp.datum, encoding, getRepresentativeColor(mark.fill)),
329
+ fields: buildFields(dp.datum, encoding, formatters, getRepresentativeColor(mark.fill)),
267
330
  };
268
331
  }
269
332
  }
270
333
  return [];
271
334
  }
272
335
 
336
+ // ---------------------------------------------------------------------------
337
+ // Range mark tooltips (start / end / delta)
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * Build tooltip descriptors for range marks (dumbbell / arrow / range bar).
342
+ *
343
+ * Every mark of a range row (dots, connector rule, bar rect) shares one
344
+ * tooltip showing the start value, the end value, and the signed delta.
345
+ * An explicit `encoding.tooltip` overrides the default field set.
346
+ */
347
+ function computeRangeTooltips(
348
+ spec: NormalizedChartSpec,
349
+ marks: Mark[],
350
+ ): Map<string, TooltipContent> {
351
+ const encoding = spec.encoding as Encoding;
352
+ const descriptors = new Map<string, TooltipContent>();
353
+
354
+ const horizontal = encoding.y?.type === 'nominal' || encoding.y?.type === 'ordinal';
355
+ const startCh = horizontal ? encoding.x : encoding.y;
356
+ const endCh = horizontal ? encoding.x2 : encoding.y2;
357
+ if (!startCh || !endCh) return descriptors;
358
+
359
+ const allChannels = [startCh, endCh, encoding.color, encoding.tooltip]
360
+ .flat()
361
+ .filter((ch): ch is EncodingChannel => !!ch && 'field' in ch);
362
+ const fmtCache = buildFormatterCache(spec.data, allChannels);
363
+ const startFmt = getFormatter(fmtCache, startCh);
364
+
365
+ const contentFor = (row: DataRow): TooltipContent => {
366
+ const title = getTooltipTitle(row, encoding);
367
+ if (encoding.tooltip) {
368
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
369
+ return { title, fields: buildExplicitTooltipFields(row, channels, fmtCache) };
370
+ }
371
+
372
+ const fields: TooltipField[] = [
373
+ {
374
+ label: resolveLabel(startCh),
375
+ value: formatValue(row[startCh.field], startCh.type, getFormatter(fmtCache, startCh)),
376
+ },
377
+ {
378
+ label: resolveLabel(endCh),
379
+ value: formatValue(row[endCh.field], endCh.type, getFormatter(fmtCache, endCh)),
380
+ },
381
+ ];
382
+
383
+ const startVal = Number(row[startCh.field]);
384
+ const endVal = Number(row[endCh.field]);
385
+ if (Number.isFinite(startVal) && Number.isFinite(endVal)) {
386
+ const delta = endVal - startVal;
387
+ const formatted = startFmt
388
+ ? (startFmt as NumberFormatter)(delta)
389
+ : defaultNumberFormatter()(delta);
390
+ fields.push({ label: 'Change', value: delta > 0 ? `+${formatted}` : formatted });
391
+ }
392
+
393
+ return { title, fields };
394
+ };
395
+
396
+ for (let i = 0; i < marks.length; i++) {
397
+ const mark = marks[i];
398
+ if (mark.type !== 'point' && mark.type !== 'rect' && mark.type !== 'rule') continue;
399
+ descriptors.set(`${mark.type}-${i}`, contentFor(mark.data as DataRow));
400
+ }
401
+
402
+ return descriptors;
403
+ }
404
+
405
+ /**
406
+ * Compute per-day calendar heatmap tooltips.
407
+ *
408
+ * Data cells get a formatted date title plus the color value; empty cells
409
+ * (missing days, marked decorative by the calendar renderer) get no
410
+ * descriptor at all so no tooltip fires on them. An explicit
411
+ * `encoding.tooltip` overrides the default field set.
412
+ */
413
+ function computeCalendarTooltips(
414
+ spec: NormalizedChartSpec,
415
+ marks: Mark[],
416
+ ): Map<string, TooltipContent> {
417
+ const encoding = spec.encoding as Encoding;
418
+ const descriptors = new Map<string, TooltipContent>();
419
+
420
+ const xEnc = encoding.x;
421
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
422
+ if (!xEnc || !colorEnc) return descriptors;
423
+
424
+ const allChannels = [
425
+ xEnc,
426
+ colorEnc,
427
+ ...(encoding.tooltip
428
+ ? Array.isArray(encoding.tooltip)
429
+ ? encoding.tooltip
430
+ : [encoding.tooltip]
431
+ : []),
432
+ ].filter((ch): ch is EncodingChannel => !!ch && 'field' in ch);
433
+ const fmtCache = buildFormatterCache(spec.data, allChannels);
434
+
435
+ for (let i = 0; i < marks.length; i++) {
436
+ const mark = marks[i];
437
+ if (mark.type !== 'rect' || mark.aria.decorative) continue;
438
+ const row = mark.data as DataRow;
439
+
440
+ const title = formatValue(row[xEnc.field], 'temporal', getFormatter(fmtCache, xEnc));
441
+ if (encoding.tooltip) {
442
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
443
+ descriptors.set(`rect-${i}`, {
444
+ title,
445
+ fields: buildExplicitTooltipFields(row, channels, fmtCache),
446
+ });
447
+ continue;
448
+ }
449
+
450
+ descriptors.set(`rect-${i}`, {
451
+ title,
452
+ fields: [
453
+ {
454
+ label: resolveLabel(colorEnc),
455
+ value: formatValue(row[colorEnc.field], colorEnc.type, getFormatter(fmtCache, colorEnc)),
456
+ color: getRepresentativeColor(mark.fill),
457
+ },
458
+ ],
459
+ });
460
+ }
461
+
462
+ return descriptors;
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Waffle mark tooltips (one shared tooltip per category)
467
+ // ---------------------------------------------------------------------------
468
+
469
+ /**
470
+ * Build tooltip descriptors for waffle marks.
471
+ *
472
+ * Every cell of a category shares literally one TooltipContent object, so
473
+ * the cells act as a single hover target: moving across a category's cells
474
+ * keeps showing the same tooltip. Content: the category's value plus its
475
+ * "x of N units" cell count. An explicit `encoding.tooltip` overrides the
476
+ * default field set.
477
+ */
478
+ function computeWaffleTooltips(
479
+ spec: NormalizedChartSpec,
480
+ marks: Mark[],
481
+ ): Map<string, TooltipContent> {
482
+ const encoding = spec.encoding as Encoding;
483
+ const descriptors = new Map<string, TooltipContent>();
484
+
485
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
486
+ const valueCh = encoding.y ?? encoding.x;
487
+ if (!colorEnc || !valueCh) return descriptors;
488
+
489
+ const allChannels = [
490
+ valueCh,
491
+ colorEnc,
492
+ ...(encoding.tooltip
493
+ ? Array.isArray(encoding.tooltip)
494
+ ? encoding.tooltip
495
+ : [encoding.tooltip]
496
+ : []),
497
+ ].filter((ch): ch is EncodingChannel => !!ch && 'field' in ch);
498
+ const fmtCache = buildFormatterCache(spec.data, allChannels);
499
+
500
+ const units = Math.max(1, Math.round(spec.markDef.units ?? 100));
501
+
502
+ const cellCounts = new Map<string, number>();
503
+ for (const mark of marks) {
504
+ if (mark.type !== 'rect') continue;
505
+ const category = String((mark.data as DataRow)[colorEnc.field] ?? '');
506
+ cellCounts.set(category, (cellCounts.get(category) ?? 0) + 1);
507
+ }
508
+
509
+ const contentByCategory = new Map<string, TooltipContent>();
510
+ for (let i = 0; i < marks.length; i++) {
511
+ const mark = marks[i];
512
+ if (mark.type !== 'rect') continue;
513
+
514
+ const row = mark.data as DataRow;
515
+ const category = String(row[colorEnc.field] ?? '');
516
+ let content = contentByCategory.get(category);
517
+ if (!content) {
518
+ if (encoding.tooltip) {
519
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
520
+ content = { title: category, fields: buildExplicitTooltipFields(row, channels, fmtCache) };
521
+ } else {
522
+ content = {
523
+ title: category,
524
+ fields: [
525
+ {
526
+ label: resolveLabel(valueCh),
527
+ value: formatValue(row[valueCh.field], valueCh.type, getFormatter(fmtCache, valueCh)),
528
+ color: getRepresentativeColor(mark.fill),
529
+ },
530
+ { label: 'Share', value: `${cellCounts.get(category) ?? 0} of ${units} units` },
531
+ ],
532
+ };
533
+ }
534
+ contentByCategory.set(category, content);
535
+ }
536
+ descriptors.set(`rect-${i}`, content);
537
+ }
538
+
539
+ return descriptors;
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Parliament mark tooltips (one shared tooltip per party)
544
+ // ---------------------------------------------------------------------------
545
+
546
+ /**
547
+ * Build tooltip descriptors for parliament (hemicycle) marks.
548
+ *
549
+ * Every seat dot of a party shares one TooltipContent object, so hovering any
550
+ * of a party's seats shows the same tooltip: the seat count and its share of
551
+ * the chamber. Keys match the renderer's `point-${index}` mark ids. The
552
+ * majority line/label (rule/textMark) get no descriptor. An explicit
553
+ * `encoding.tooltip` overrides the default field set.
554
+ */
555
+ function computeParliamentTooltips(
556
+ spec: NormalizedChartSpec,
557
+ marks: Mark[],
558
+ ): Map<string, TooltipContent> {
559
+ const encoding = spec.encoding as Encoding;
560
+ const descriptors = new Map<string, TooltipContent>();
561
+
562
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
563
+ const valueCh = encoding.y ?? encoding.x;
564
+ if (!colorEnc || !valueCh) return descriptors;
565
+
566
+ const allChannels = [
567
+ valueCh,
568
+ colorEnc,
569
+ ...(encoding.tooltip
570
+ ? Array.isArray(encoding.tooltip)
571
+ ? encoding.tooltip
572
+ : [encoding.tooltip]
573
+ : []),
574
+ ].filter((ch): ch is EncodingChannel => !!ch && 'field' in ch);
575
+ const fmtCache = buildFormatterCache(spec.data, allChannels);
576
+
577
+ const seatCounts = new Map<string, number>();
578
+ for (const mark of marks) {
579
+ if (mark.type !== 'point') continue;
580
+ const party = String((mark.data as DataRow)[colorEnc.field] ?? '');
581
+ seatCounts.set(party, (seatCounts.get(party) ?? 0) + 1);
582
+ }
583
+ const totalSeats = [...seatCounts.values()].reduce((s, c) => s + c, 0);
584
+
585
+ const contentByParty = new Map<string, TooltipContent>();
586
+ for (let i = 0; i < marks.length; i++) {
587
+ const mark = marks[i];
588
+ if (mark.type !== 'point') continue;
589
+
590
+ const row = mark.data as DataRow;
591
+ const party = String(row[colorEnc.field] ?? '');
592
+ let content = contentByParty.get(party);
593
+ if (!content) {
594
+ if (encoding.tooltip) {
595
+ const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
596
+ content = { title: party, fields: buildExplicitTooltipFields(row, channels, fmtCache) };
597
+ } else {
598
+ const count = seatCounts.get(party) ?? 0;
599
+ const share = totalSeats > 0 ? formatPercent(count / totalSeats) : '';
600
+ content = {
601
+ title: party,
602
+ fields: [
603
+ {
604
+ label: resolveLabel(valueCh),
605
+ value: formatValue(row[valueCh.field], valueCh.type, getFormatter(fmtCache, valueCh)),
606
+ color: getRepresentativeColor(mark.fill),
607
+ },
608
+ { label: 'Share', value: share },
609
+ ],
610
+ };
611
+ }
612
+ contentByParty.set(party, content);
613
+ }
614
+ descriptors.set(`point-${i}`, content);
615
+ }
616
+
617
+ return descriptors;
618
+ }
619
+
273
620
  // ---------------------------------------------------------------------------
274
621
  // Public API
275
622
  // ---------------------------------------------------------------------------
@@ -285,28 +632,62 @@ export function computeTooltipDescriptors(
285
632
  spec: NormalizedChartSpec,
286
633
  marks: Mark[],
287
634
  ): Map<string, TooltipContent> {
635
+ // Range marks share one start/end/delta tooltip across every mark of a row.
636
+ if (spec.markType === 'range') {
637
+ return computeRangeTooltips(spec, marks);
638
+ }
639
+ // Calendar heatmaps get one date-titled tooltip per data cell; empty
640
+ // (missing-day) cells get none.
641
+ if (spec.markType === 'calendar') {
642
+ return computeCalendarTooltips(spec, marks);
643
+ }
644
+
645
+ // Waffle cells share one tooltip per category (the cells act as one target).
646
+ if (spec.markType === 'waffle') {
647
+ return computeWaffleTooltips(spec, marks);
648
+ }
649
+
650
+ // Parliament seats share one tooltip per party (all of a party's seats act
651
+ // as one target).
652
+ if (spec.markType === 'parliament') {
653
+ return computeParliamentTooltips(spec, marks);
654
+ }
655
+
288
656
  const encoding = spec.encoding as Encoding;
289
657
  const descriptors = new Map<string, TooltipContent>();
290
658
 
659
+ const allChannels = [
660
+ encoding.x,
661
+ encoding.y,
662
+ encoding.color && 'field' in encoding.color ? encoding.color : undefined,
663
+ encoding.size && 'field' in encoding.size ? encoding.size : undefined,
664
+ ...(encoding.tooltip
665
+ ? Array.isArray(encoding.tooltip)
666
+ ? encoding.tooltip
667
+ : [encoding.tooltip]
668
+ : []),
669
+ ].filter((ch): ch is EncodingChannel => !!ch && 'field' in ch);
670
+ const formatters = buildFormatterCache(spec.data, allChannels);
671
+
291
672
  for (let i = 0; i < marks.length; i++) {
292
673
  const mark = marks[i];
293
674
  let entries: Array<[string, TooltipContent]> = [];
294
675
 
295
676
  switch (mark.type) {
296
677
  case 'line':
297
- entries = tooltipsForLine(mark, encoding, i);
678
+ entries = tooltipsForLine(mark, encoding, formatters, i);
298
679
  break;
299
680
  case 'area':
300
- entries = tooltipsForArea(mark, encoding, i);
681
+ entries = tooltipsForArea(mark, encoding, formatters, i);
301
682
  break;
302
683
  case 'point':
303
- entries = tooltipsForPoint(mark, encoding, i);
684
+ entries = tooltipsForPoint(mark, encoding, formatters, i);
304
685
  break;
305
686
  case 'rect':
306
- entries = tooltipsForRect(mark, encoding, i);
687
+ entries = tooltipsForRect(mark, encoding, formatters, i);
307
688
  break;
308
689
  case 'arc':
309
- entries = tooltipsForArc(mark, encoding, i);
690
+ entries = tooltipsForArc(mark, encoding, formatters, i);
310
691
  break;
311
692
  }
312
693
 
@@ -54,6 +54,24 @@ describe('resolveConditionalValue', () => {
54
54
  expect(result).toBe('Category A');
55
55
  });
56
56
 
57
+ it('returns a gradient value intact when the test passes (color channels)', () => {
58
+ const gradient = {
59
+ gradient: 'radial' as const,
60
+ stops: [
61
+ { offset: 0, color: '#4ade80' },
62
+ { offset: 1, color: '#15803d' },
63
+ ],
64
+ };
65
+ const result = resolveConditionalValue(
66
+ { source: 'Renewables', share: 30 },
67
+ {
68
+ condition: { test: { field: 'source', equal: 'Renewables' }, value: gradient },
69
+ value: '#e2e8f0',
70
+ },
71
+ );
72
+ expect(result).toEqual(gradient);
73
+ });
74
+
57
75
  it('returns undefined when no condition matches and no default', () => {
58
76
  const result = resolveConditionalValue(
59
77
  { v: 5 },
@@ -10,8 +10,9 @@ import type { AggregateOp, AggregateTransform, DataRow } from '@opendata-ai/open
10
10
 
11
11
  /**
12
12
  * Compute a single aggregate operation over an array of numeric values.
13
+ * Also used by the sort-by-value sugar expansion in compile/spec-sugar.ts.
13
14
  */
14
- function computeAggregate(op: AggregateOp, values: number[]): number {
15
+ export function computeAggregate(op: AggregateOp, values: number[]): number {
15
16
  if (values.length === 0) return 0;
16
17
 
17
18
  switch (op) {