@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.2
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 +123 -14
- package/dist/index.js +16166 -10027
- 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__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -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 +310 -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__/keys.test.ts +3 -9
- 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/keys.ts +5 -4
- 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 +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- 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 +119 -13
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- 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
|
@@ -265,10 +265,15 @@ describe('resolveSuppression - special cases', () => {
|
|
|
265
265
|
expect(result.showEndpointLabels).toBe(true);
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
-
it('overlap area (
|
|
268
|
+
it('overlap area (stack: null): runs through 8-cell table', () => {
|
|
269
269
|
const spec = makeMultiSeriesLineSpec({
|
|
270
270
|
markType: 'area',
|
|
271
271
|
markDef: { type: 'area' },
|
|
272
|
+
encoding: {
|
|
273
|
+
x: { field: 'date', type: 'temporal' },
|
|
274
|
+
y: { field: 'value', type: 'quantitative', stack: null },
|
|
275
|
+
color: { field: 'country', type: 'nominal' },
|
|
276
|
+
},
|
|
272
277
|
});
|
|
273
278
|
const result = resolveSuppression(spec, baseCtx);
|
|
274
279
|
// Cell 1 behavior: legend hidden, column on
|
|
@@ -276,6 +281,17 @@ describe('resolveSuppression - special cases', () => {
|
|
|
276
281
|
expect(result.showEndpointLabels).toBe(true);
|
|
277
282
|
});
|
|
278
283
|
|
|
284
|
+
it('area with no explicit stack: stacks by default (v8), legend shown, column off', () => {
|
|
285
|
+
const spec = makeMultiSeriesLineSpec({
|
|
286
|
+
markType: 'area',
|
|
287
|
+
markDef: { type: 'area' },
|
|
288
|
+
});
|
|
289
|
+
const result = resolveSuppression(spec, baseCtx);
|
|
290
|
+
expect(result.showTraditionalLegend).toBe(true);
|
|
291
|
+
expect(result.showEndpointLabels).toBe(false);
|
|
292
|
+
expect(result.showEndOfLineLabels).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
279
295
|
it('endpointLabels: { show: false } counts as explicit off', () => {
|
|
280
296
|
const spec = makeMultiSeriesLineSpec({ endpointLabels: { show: false } });
|
|
281
297
|
const result = resolveSuppression(spec, baseCtx);
|
package/src/legend/compute.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import type {
|
|
23
|
+
ContinuousLegendLayout,
|
|
23
24
|
LayoutStrategy,
|
|
24
25
|
LegendEntry,
|
|
25
26
|
LegendEntryPosition,
|
|
@@ -30,8 +31,16 @@ import type {
|
|
|
30
31
|
} from '@opendata-ai/openchart-core';
|
|
31
32
|
import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
32
33
|
|
|
34
|
+
import { categoricalColorsForDomain } from '../compile/color-scale-range';
|
|
33
35
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
34
36
|
import type { MeasureFn } from '../layout/plan';
|
|
37
|
+
import { applyCategoricalSort } from '../layout/scales';
|
|
38
|
+
import {
|
|
39
|
+
CONTINUOUS_LABEL_GAP,
|
|
40
|
+
type ContinuousLegendContent,
|
|
41
|
+
computeContinuousLegendContent,
|
|
42
|
+
hasContinuousColorScale,
|
|
43
|
+
} from './continuous';
|
|
35
44
|
import { countColorSeries, resolveSuppression } from './suppression';
|
|
36
45
|
import {
|
|
37
46
|
ENTRY_GAP,
|
|
@@ -79,6 +88,20 @@ export interface LegendContent {
|
|
|
79
88
|
relativePositions?: LegendEntryPosition[];
|
|
80
89
|
/** Row advance used to build entry positions. */
|
|
81
90
|
rowHeight?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Continuous legend payload (gradient bar / binned swatches). When set,
|
|
93
|
+
* `entries` is empty and `height`/`legendWidth` describe the bar block.
|
|
94
|
+
*/
|
|
95
|
+
continuous?: ContinuousLegendContent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when the legend has something to render (categorical entries or a
|
|
100
|
+
* continuous bar). Space-reservation predicates must use this instead of
|
|
101
|
+
* checking `entries.length` directly.
|
|
102
|
+
*/
|
|
103
|
+
export function hasLegendContent(content: LegendContent): boolean {
|
|
104
|
+
return content.entries.length > 0 || content.continuous != null;
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
// ---------------------------------------------------------------------------
|
|
@@ -127,6 +150,8 @@ function swatchShapeForType(markType: string): LegendEntry['shape'] {
|
|
|
127
150
|
case 'point':
|
|
128
151
|
case 'circle':
|
|
129
152
|
case 'lollipop':
|
|
153
|
+
case 'beeswarm':
|
|
154
|
+
case 'range':
|
|
130
155
|
return 'circle';
|
|
131
156
|
default:
|
|
132
157
|
return 'square';
|
|
@@ -141,6 +166,9 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
141
166
|
// Conditional color definitions don't produce legend entries
|
|
142
167
|
if ('condition' in colorEnc) return [];
|
|
143
168
|
|
|
169
|
+
// Bare value defs (constant colors) don't produce legend entries either
|
|
170
|
+
if (!('field' in colorEnc)) return [];
|
|
171
|
+
|
|
144
172
|
// Sequential (quantitative) color doesn't produce discrete legend entries
|
|
145
173
|
if (colorEnc.type === 'quantitative') return [];
|
|
146
174
|
|
|
@@ -155,9 +183,21 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
155
183
|
];
|
|
156
184
|
const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
|
|
157
185
|
const explicitRange = colorEnc.scale?.range as string[] | undefined;
|
|
158
|
-
const palette = explicitRange ?? theme.colors.categorical;
|
|
159
186
|
const shape = swatchShapeForType(spec.markType);
|
|
160
187
|
|
|
188
|
+
// Reproduce the ordinal color scale's domain so swatch colors index the
|
|
189
|
+
// palette exactly like the mark colors do. `buildOrdinalColorScale` takes an
|
|
190
|
+
// explicit domain verbatim, otherwise sorts the data values by `sort`.
|
|
191
|
+
const scaleDomain = explicitDomain
|
|
192
|
+
? explicitDomain.map(String)
|
|
193
|
+
: applyCategoricalSort(dataValues, colorEnc.sort);
|
|
194
|
+
|
|
195
|
+
// Colors come from the same resolver the scale uses, so `highlight` muting
|
|
196
|
+
// and the accent-neutral series strategy show up in the legend too. An
|
|
197
|
+
// explicit range always wins (the scale honours it verbatim).
|
|
198
|
+
const domainColors =
|
|
199
|
+
explicitRange ?? categoricalColorsForDomain(scaleDomain, theme, spec.highlight);
|
|
200
|
+
|
|
161
201
|
// Order legend entries by explicit domain when provided so the author
|
|
162
202
|
// controls which entries render first (and which get truncated last when
|
|
163
203
|
// symbolLimit applies). Without explicit domain, preserve data order.
|
|
@@ -176,16 +216,14 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
176
216
|
|
|
177
217
|
return uniqueValues
|
|
178
218
|
.map((value, i) => {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (domainIdx >= 0) colorIndex = domainIdx;
|
|
185
|
-
}
|
|
219
|
+
// Look the value up in the scale domain so the color matches the mark
|
|
220
|
+
// even when legend order differs from domain order. Values outside the
|
|
221
|
+
// domain (a data value not in an explicit domain) fall back to position.
|
|
222
|
+
const domainIdx = scaleDomain.indexOf(value);
|
|
223
|
+
const colorIndex = domainIdx >= 0 ? domainIdx : i;
|
|
186
224
|
return {
|
|
187
225
|
label: value,
|
|
188
|
-
color:
|
|
226
|
+
color: domainColors[colorIndex % domainColors.length],
|
|
189
227
|
shape,
|
|
190
228
|
active: !hiddenSet.has(value),
|
|
191
229
|
};
|
|
@@ -274,6 +312,40 @@ export function computeLegendContent(
|
|
|
274
312
|
return emptyContent('top', theme);
|
|
275
313
|
}
|
|
276
314
|
|
|
315
|
+
// Continuous color scale (quantitative color encoding): gradient bar or
|
|
316
|
+
// binned swatch legend, on by default. Horizontal-bar geometry only, so the
|
|
317
|
+
// position resolves to top (newsroom map-key convention) unless the user
|
|
318
|
+
// asks for bottom; right-column positions can't fit the bar.
|
|
319
|
+
if (hasContinuousColorScale(spec)) {
|
|
320
|
+
const continuous = computeContinuousLegendContent(spec, theme, availableWidth);
|
|
321
|
+
const continuousPosition = spec.legend?.position === 'bottom' ? 'bottom' : 'top';
|
|
322
|
+
if (!continuous) {
|
|
323
|
+
return emptyContent(continuousPosition, theme);
|
|
324
|
+
}
|
|
325
|
+
const labelStyle: TextStyle = {
|
|
326
|
+
fontFamily: theme.fonts.family,
|
|
327
|
+
fontSize: theme.fonts.sizes.small,
|
|
328
|
+
fontWeight: theme.fonts.weights.normal,
|
|
329
|
+
fill: theme.colors.text,
|
|
330
|
+
lineHeight: 1.3,
|
|
331
|
+
fontVariant: 'tabular-nums',
|
|
332
|
+
};
|
|
333
|
+
const labelRowHeight = Math.ceil(labelStyle.fontSize * labelStyle.lineHeight);
|
|
334
|
+
const height = continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
|
|
335
|
+
return {
|
|
336
|
+
entries: [],
|
|
337
|
+
position: continuousPosition,
|
|
338
|
+
labelStyle,
|
|
339
|
+
rowCount: 1,
|
|
340
|
+
totalWidth: continuous.barWidth,
|
|
341
|
+
height,
|
|
342
|
+
legendWidth: continuous.barWidth,
|
|
343
|
+
offset: spec.legend?.offset,
|
|
344
|
+
...categoricalDefaults(theme),
|
|
345
|
+
continuous,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
277
349
|
let entries = extractColorEntries(spec, theme);
|
|
278
350
|
|
|
279
351
|
// Consult the shared suppression truth table so the legend, endpoint column,
|
|
@@ -291,10 +363,11 @@ export function computeLegendContent(
|
|
|
291
363
|
entries = [];
|
|
292
364
|
}
|
|
293
365
|
|
|
294
|
-
// Bar/column/lollipop redundancy rule: when color.field matches the
|
|
295
|
-
// axis field, the legend just repeats the axis labels. Hide it
|
|
296
|
-
// user explicitly configured the legend.
|
|
297
|
-
const hasRedundantLegend =
|
|
366
|
+
// Bar/column/lollipop/beeswarm redundancy rule: when color.field matches the
|
|
367
|
+
// category axis field, the legend just repeats the axis labels. Hide it
|
|
368
|
+
// unless the user explicitly configured the legend.
|
|
369
|
+
const hasRedundantLegend =
|
|
370
|
+
spec.markType === 'bar' || spec.markType === 'lollipop' || spec.markType === 'beeswarm';
|
|
298
371
|
if (hasRedundantLegend && entries.length > 0 && !spec.userExplicit?.legend) {
|
|
299
372
|
const colorEnc = spec.encoding.color;
|
|
300
373
|
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
|
|
@@ -511,6 +584,35 @@ export function placeLegend(
|
|
|
511
584
|
): LegendLayout {
|
|
512
585
|
const { position, entries, labelStyle, legendWidth, height, offset } = content;
|
|
513
586
|
|
|
587
|
+
// Continuous legend: gradient bar / binned swatches. Placement mirrors the
|
|
588
|
+
// categorical top/bottom math; the bar-relative geometry from the content
|
|
589
|
+
// phase is offset into chart coordinates here.
|
|
590
|
+
if (content.continuous) {
|
|
591
|
+
const c = content.continuous;
|
|
592
|
+
const cDx = offset?.dx ?? 0;
|
|
593
|
+
const cDy = offset?.dy ?? 0;
|
|
594
|
+
const cGap = legendGap(containerWidth);
|
|
595
|
+
const boundsX = chartArea.x + cDx;
|
|
596
|
+
const boundsY =
|
|
597
|
+
position === 'bottom'
|
|
598
|
+
? chartArea.y + chartArea.height + xAxisHeight + cGap + cDy
|
|
599
|
+
: chartArea.y - axisGapBelowLegend - cGap - height + cDy;
|
|
600
|
+
const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
|
|
601
|
+
const bar = { x: bounds.x, y: bounds.y, width: c.barWidth, height: c.barHeight };
|
|
602
|
+
return {
|
|
603
|
+
type: 'continuous' as const,
|
|
604
|
+
mode: c.mode,
|
|
605
|
+
position,
|
|
606
|
+
bounds,
|
|
607
|
+
labelStyle,
|
|
608
|
+
bar,
|
|
609
|
+
colorStops: c.colorStops,
|
|
610
|
+
bins: c.bins.map((b) => ({ ...b, x: b.x + bar.x })),
|
|
611
|
+
ticks: c.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
|
|
612
|
+
labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
|
|
613
|
+
} satisfies ContinuousLegendLayout;
|
|
614
|
+
}
|
|
615
|
+
|
|
514
616
|
// Empty legend = no bounds
|
|
515
617
|
if (entries.length === 0) {
|
|
516
618
|
return {
|
|
@@ -593,6 +695,10 @@ export function computeLegend(
|
|
|
593
695
|
chartArea.height,
|
|
594
696
|
watermark,
|
|
595
697
|
);
|
|
698
|
+
// Continuous legends share placeLegend's placement math.
|
|
699
|
+
if (content.continuous) {
|
|
700
|
+
return placeLegend(content, chartArea, chartArea.width, theme, 0);
|
|
701
|
+
}
|
|
596
702
|
const { position, entries, labelStyle, legendWidth, height, offset: off } = content;
|
|
597
703
|
const offsetDx = off?.dx ?? 0;
|
|
598
704
|
const offsetDy = off?.dy ?? 0;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuous / binned color legend content computation.
|
|
3
|
+
*
|
|
4
|
+
* Resolves a quantitative color encoding into the geometry a gradient-bar
|
|
5
|
+
* or binned-swatch legend needs: bar size, gradient stops or class swatches,
|
|
6
|
+
* and formatted value labels. Everything here is bar-relative (origin at the
|
|
7
|
+
* bar's left edge); `placeLegend` offsets into chart coordinates.
|
|
8
|
+
*
|
|
9
|
+
* Color resolution deliberately mirrors the mark path (`buildSequentialColorScale`
|
|
10
|
+
* / `buildBinnedColorScale` in layout/scales.ts plus `applyColorScaleRange`):
|
|
11
|
+
* an explicit `scale.range` wins, otherwise the theme's first sequential
|
|
12
|
+
* palette applies. Because the sequential/diverging palettes are identical in
|
|
13
|
+
* light and dark themes (only text/axis colors adapt), the legend matches the
|
|
14
|
+
* dark-adapted mark ramp exactly with no extra work.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
ContinuousLegendBin,
|
|
19
|
+
ContinuousLegendTick,
|
|
20
|
+
EncodingChannel,
|
|
21
|
+
FieldFormatContext,
|
|
22
|
+
GradientColorStop,
|
|
23
|
+
ResolvedTheme,
|
|
24
|
+
} from '@opendata-ai/openchart-core';
|
|
25
|
+
import {
|
|
26
|
+
computeFieldFormatContext,
|
|
27
|
+
defaultNumberFormatter,
|
|
28
|
+
resolveNumberFormatter,
|
|
29
|
+
} from '@opendata-ai/openchart-core';
|
|
30
|
+
import { scaleQuantile } from 'd3-scale';
|
|
31
|
+
|
|
32
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Min/max over a numeric array via reduce, not `Math.min(...values)`. The spread
|
|
40
|
+
* form passes every element as a function argument and throws a call-stack
|
|
41
|
+
* RangeError once the array is large enough (a continuous-color field over a big
|
|
42
|
+
* dataset), so the reduce keeps large color domains from crashing the legend.
|
|
43
|
+
* Empty-array result matches the old `Math.min()`/`Math.max()` identity so the
|
|
44
|
+
* `?? Math.min(...)` fallback callers keep their prior semantics.
|
|
45
|
+
*/
|
|
46
|
+
function extent(values: number[]): [number, number] {
|
|
47
|
+
let min = Number.POSITIVE_INFINITY;
|
|
48
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
49
|
+
for (const v of values) {
|
|
50
|
+
if (v < min) min = v;
|
|
51
|
+
if (v > max) max = v;
|
|
52
|
+
}
|
|
53
|
+
return [min, max];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Height of the gradient bar / swatch row in pixels. */
|
|
57
|
+
export const CONTINUOUS_BAR_HEIGHT = 10;
|
|
58
|
+
|
|
59
|
+
/** Gap between the bar and the value label row. */
|
|
60
|
+
export const CONTINUOUS_LABEL_GAP = 4;
|
|
61
|
+
|
|
62
|
+
/** Preferred bar width bounds (FT/Datawrapper map-key convention). */
|
|
63
|
+
const BAR_WIDTH_MIN = 160;
|
|
64
|
+
const BAR_WIDTH_MAX = 220;
|
|
65
|
+
|
|
66
|
+
/** Default class count for quantile/quantize scales without an explicit range. */
|
|
67
|
+
export const DEFAULT_BIN_COUNT = 5;
|
|
68
|
+
|
|
69
|
+
/** Scale types that produce a binned swatch legend. */
|
|
70
|
+
const BINNED_SCALE_TYPES = new Set(['quantile', 'quantize', 'threshold']);
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Types
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Pre-placement continuous legend content (bar-relative coordinates). */
|
|
77
|
+
export interface ContinuousLegendContent {
|
|
78
|
+
/** Rendering mode, resolved from the color channel's scale type. */
|
|
79
|
+
mode: 'gradient' | 'binned';
|
|
80
|
+
/** Bar width in pixels. */
|
|
81
|
+
barWidth: number;
|
|
82
|
+
/** Bar height in pixels. */
|
|
83
|
+
barHeight: number;
|
|
84
|
+
/** Gradient color stops (empty in binned mode). */
|
|
85
|
+
colorStops: GradientColorStop[];
|
|
86
|
+
/** Class swatches with x relative to the bar's left edge (empty in gradient mode). */
|
|
87
|
+
bins: ContinuousLegendBin[];
|
|
88
|
+
/** Value labels with x relative to the bar's left edge. */
|
|
89
|
+
ticks: ContinuousLegendTick[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sample `k` colors from a ramp, evenly spread by index. Returns the ramp
|
|
98
|
+
* unchanged when it already has `k` stops. Shared by the mark color path
|
|
99
|
+
* (applyColorScaleRange, buildBinnedColorScale) and the legend so class
|
|
100
|
+
* colors can never drift between swatches and cells.
|
|
101
|
+
*/
|
|
102
|
+
export function sampleRampColors(colors: string[], k: number): string[] {
|
|
103
|
+
if (k <= 0 || colors.length === 0) return [];
|
|
104
|
+
if (colors.length === k) return [...colors];
|
|
105
|
+
if (k === 1) return [colors[colors.length - 1]];
|
|
106
|
+
const out: string[] = [];
|
|
107
|
+
for (let i = 0; i < k; i++) {
|
|
108
|
+
out.push(colors[Math.round((i * (colors.length - 1)) / (k - 1))]);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Numeric values of the color field, in data order. */
|
|
114
|
+
function numericFieldValues(spec: NormalizedChartSpec, field: string): number[] {
|
|
115
|
+
const out: number[] = [];
|
|
116
|
+
for (const row of spec.data) {
|
|
117
|
+
const v = row[field];
|
|
118
|
+
if (v == null) continue;
|
|
119
|
+
const num = typeof v === 'number' ? v : Number(v);
|
|
120
|
+
if (Number.isFinite(num)) out.push(num);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Format a legend value: channel format wins, then the house number style. */
|
|
126
|
+
function formatLegendValue(value: number, formatStr?: string, ctx?: FieldFormatContext): string {
|
|
127
|
+
if (formatStr) {
|
|
128
|
+
const fmt = resolveNumberFormatter(formatStr, ctx);
|
|
129
|
+
if (fmt) return fmt(value);
|
|
130
|
+
}
|
|
131
|
+
return defaultNumberFormatter(ctx)(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* True when the resolved color list is one of the theme's diverging palettes
|
|
136
|
+
* (e.g. `scale: { scheme: 'redBlue' }` after sugar expansion). Diverging ramps
|
|
137
|
+
* get a midpoint label at the scale's center value.
|
|
138
|
+
*/
|
|
139
|
+
function isDivergingRamp(colors: string[], theme: ResolvedTheme): boolean {
|
|
140
|
+
const norm = (stops: readonly string[]) => stops.map((c) => c.toLowerCase()).join('|');
|
|
141
|
+
const key = norm(colors);
|
|
142
|
+
// Match either direction: `scale: { reverse: true }` flips the stops, and a
|
|
143
|
+
// reversed diverging ramp is still diverging -- it must keep its midpoint label.
|
|
144
|
+
const flipped = norm([...colors].reverse());
|
|
145
|
+
return Object.values(theme.colors.diverging).some((stops) => {
|
|
146
|
+
const themeKey = norm(stops);
|
|
147
|
+
return themeKey === key || themeKey === flipped;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the color list the marks will use, mirroring
|
|
153
|
+
* `applyColorScaleRange`: explicit range wins, otherwise the theme's first
|
|
154
|
+
* sequential palette (categorical endpoints as a last resort).
|
|
155
|
+
*/
|
|
156
|
+
function resolveRampColors(channel: EncodingChannel, theme: ResolvedTheme): string[] {
|
|
157
|
+
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
158
|
+
if (explicitRange?.length) return explicitRange;
|
|
159
|
+
return Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Clamp the bar width into the preferred band without exceeding available space. */
|
|
163
|
+
function resolveBarWidth(availableWidth: number): number {
|
|
164
|
+
const preferred = Math.min(
|
|
165
|
+
BAR_WIDTH_MAX,
|
|
166
|
+
Math.max(BAR_WIDTH_MIN, Math.round(availableWidth * 0.4)),
|
|
167
|
+
);
|
|
168
|
+
return Math.max(0, Math.min(preferred, Math.floor(availableWidth)));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Class break values for a binned color scale, mirroring the mark scale. */
|
|
172
|
+
function resolveBinBreaks(channel: EncodingChannel, values: number[], binCount: number): number[] {
|
|
173
|
+
const scaleType = channel.scale?.type;
|
|
174
|
+
if (scaleType === 'threshold') {
|
|
175
|
+
return (channel.scale?.domain as number[] | undefined) ?? [0.5];
|
|
176
|
+
}
|
|
177
|
+
if (scaleType === 'quantile') {
|
|
178
|
+
const scale = scaleQuantile<number>()
|
|
179
|
+
.domain(values)
|
|
180
|
+
.range(Array.from({ length: binCount }, (_, i) => i));
|
|
181
|
+
return scale.quantiles();
|
|
182
|
+
}
|
|
183
|
+
// quantize: evenly spaced breaks across the (possibly explicit) domain
|
|
184
|
+
const explicitDomain = channel.scale?.domain as [number, number] | undefined;
|
|
185
|
+
const [valuesMin, valuesMax] = extent(values);
|
|
186
|
+
const domainMin = explicitDomain?.[0] ?? valuesMin;
|
|
187
|
+
const domainMax = explicitDomain?.[1] ?? valuesMax;
|
|
188
|
+
const breaks: number[] = [];
|
|
189
|
+
for (let i = 1; i < binCount; i++) {
|
|
190
|
+
breaks.push(domainMin + ((domainMax - domainMin) * i) / binCount);
|
|
191
|
+
}
|
|
192
|
+
return breaks;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Public API
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** True when the spec's color channel resolves to a continuous/binned color scale. */
|
|
200
|
+
export function hasContinuousColorScale(spec: NormalizedChartSpec): boolean {
|
|
201
|
+
const colorEnc = spec.encoding.color;
|
|
202
|
+
if (!colorEnc || 'condition' in colorEnc || !('field' in colorEnc)) return false;
|
|
203
|
+
return colorEnc.type === 'quantitative';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Compute continuous legend content from raw numeric values and a color
|
|
208
|
+
* channel config. Reusable by both chart specs and map specs.
|
|
209
|
+
*
|
|
210
|
+
* Label policy: min/max only for sequential ramps, min/neutral/max for
|
|
211
|
+
* diverging ramps; binned scales get one boundary label per class break.
|
|
212
|
+
*
|
|
213
|
+
* @returns null when values is empty or bar width resolves to 0.
|
|
214
|
+
*/
|
|
215
|
+
export function computeContinuousLegendContentForChannel(
|
|
216
|
+
values: number[],
|
|
217
|
+
channel: EncodingChannel,
|
|
218
|
+
theme: ResolvedTheme,
|
|
219
|
+
availableWidth: number,
|
|
220
|
+
): ContinuousLegendContent | null {
|
|
221
|
+
if (values.length === 0) return null;
|
|
222
|
+
|
|
223
|
+
const barWidth = resolveBarWidth(availableWidth);
|
|
224
|
+
if (barWidth <= 0) return null;
|
|
225
|
+
|
|
226
|
+
const scaleType = channel.scale?.type;
|
|
227
|
+
const rampColors = resolveRampColors(channel, theme);
|
|
228
|
+
const formatStr = channel.format;
|
|
229
|
+
const ctx = computeFieldFormatContext(values);
|
|
230
|
+
|
|
231
|
+
if (scaleType && BINNED_SCALE_TYPES.has(scaleType)) {
|
|
232
|
+
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
233
|
+
const binCount =
|
|
234
|
+
scaleType === 'threshold'
|
|
235
|
+
? ((channel.scale?.domain as number[] | undefined) ?? [0.5]).length + 1
|
|
236
|
+
: (explicitRange?.length ?? DEFAULT_BIN_COUNT);
|
|
237
|
+
const breaks = resolveBinBreaks(channel, values, binCount);
|
|
238
|
+
const colors = sampleRampColors(rampColors, binCount);
|
|
239
|
+
const binWidth = barWidth / binCount;
|
|
240
|
+
|
|
241
|
+
const bins: ContinuousLegendBin[] = colors.map((color, i) => ({
|
|
242
|
+
x: i * binWidth,
|
|
243
|
+
width: binWidth,
|
|
244
|
+
color,
|
|
245
|
+
}));
|
|
246
|
+
const ticks: ContinuousLegendTick[] = breaks.map((value, i) => ({
|
|
247
|
+
value,
|
|
248
|
+
label: formatLegendValue(value, formatStr, ctx),
|
|
249
|
+
x: (i + 1) * binWidth,
|
|
250
|
+
anchor: 'middle' as const,
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
mode: 'binned',
|
|
255
|
+
barWidth,
|
|
256
|
+
barHeight: CONTINUOUS_BAR_HEIGHT,
|
|
257
|
+
colorStops: [],
|
|
258
|
+
bins,
|
|
259
|
+
ticks,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Gradient mode: sequential or diverging ramp.
|
|
264
|
+
const [domainMin, domainMax] = extent(values);
|
|
265
|
+
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
266
|
+
|
|
267
|
+
const stops =
|
|
268
|
+
explicitRange && explicitRange.length > 2
|
|
269
|
+
? explicitRange
|
|
270
|
+
: [rampColors[0], rampColors[rampColors.length - 1]];
|
|
271
|
+
const colorStops: GradientColorStop[] = stops.map((color, i) => ({
|
|
272
|
+
offset: stops.length > 1 ? i / (stops.length - 1) : 0,
|
|
273
|
+
color,
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
const ticks: ContinuousLegendTick[] = [
|
|
277
|
+
{
|
|
278
|
+
value: domainMin,
|
|
279
|
+
label: formatLegendValue(domainMin, formatStr, ctx),
|
|
280
|
+
x: 0,
|
|
281
|
+
anchor: 'start' as const,
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
if (domainMax > domainMin) {
|
|
285
|
+
if (isDivergingRamp(stops, theme)) {
|
|
286
|
+
const neutral = (domainMin + domainMax) / 2;
|
|
287
|
+
ticks.push({
|
|
288
|
+
value: neutral,
|
|
289
|
+
label: formatLegendValue(neutral, formatStr, ctx),
|
|
290
|
+
x: barWidth / 2,
|
|
291
|
+
anchor: 'middle' as const,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
ticks.push({
|
|
295
|
+
value: domainMax,
|
|
296
|
+
label: formatLegendValue(domainMax, formatStr, ctx),
|
|
297
|
+
x: barWidth,
|
|
298
|
+
anchor: 'end' as const,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
mode: 'gradient',
|
|
304
|
+
barWidth,
|
|
305
|
+
barHeight: CONTINUOUS_BAR_HEIGHT,
|
|
306
|
+
colorStops,
|
|
307
|
+
bins: [],
|
|
308
|
+
ticks,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Compute continuous legend content from a spec's quantitative color encoding.
|
|
314
|
+
* Thin wrapper around computeContinuousLegendContentForChannel.
|
|
315
|
+
*
|
|
316
|
+
* @returns null when the color encoding is not continuous or has no numeric data.
|
|
317
|
+
*/
|
|
318
|
+
export function computeContinuousLegendContent(
|
|
319
|
+
spec: NormalizedChartSpec,
|
|
320
|
+
theme: ResolvedTheme,
|
|
321
|
+
availableWidth: number,
|
|
322
|
+
): ContinuousLegendContent | null {
|
|
323
|
+
if (!hasContinuousColorScale(spec)) return null;
|
|
324
|
+
const colorEnc = spec.encoding.color as EncodingChannel;
|
|
325
|
+
const values = numericFieldValues(spec, colorEnc.field);
|
|
326
|
+
return computeContinuousLegendContentForChannel(values, colorEnc, theme, availableWidth);
|
|
327
|
+
}
|