@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
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.
- package/dist/index.d.ts +142 -7
- package/dist/index.js +16182 -10032
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +315 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
package/src/tooltips/compute.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
42
|
-
|
|
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 (
|
|
48
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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) {
|