@opendata-ai/openchart-engine 7.11.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 +123 -7
- package/dist/index.js +16159 -10024
- 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__/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 +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AxisTick, Encoding, LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { maxRotatedLabelWidth, resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { scaleLinear, scaleLog } from 'd3-scale';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
@@ -600,11 +600,58 @@ describe('text-aware tick density', () => {
|
|
|
600
600
|
const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
|
|
601
601
|
expect(axes.x!.tickAngle).toBe(-45);
|
|
602
602
|
|
|
603
|
-
// Every category keeps
|
|
604
|
-
|
|
603
|
+
// Every category keeps a tick — the diagonals never touch, so no label is
|
|
604
|
+
// DROPPED. (The long one is ellipsized to fit the capped rotated-label
|
|
605
|
+
// band, which is a separate concern from decimation; its full text is
|
|
606
|
+
// preserved on `fullLabel`.)
|
|
607
|
+
expect(axes.x!.ticks).toHaveLength(categories.length);
|
|
608
|
+
const kept = axes.x!.ticks.map((t) => t.fullLabel ?? t.label);
|
|
605
609
|
expect(kept).toEqual(categories);
|
|
606
610
|
});
|
|
607
611
|
|
|
612
|
+
it('truncates rotated labels that would overflow the capped reservation', () => {
|
|
613
|
+
// Regression: the layout clamps the reserved rotated-label band to
|
|
614
|
+
// X_AXIS_ROTATED_EXTENT_CAP (120px) but nothing shortened the drawn string,
|
|
615
|
+
// so a long label rendered straight past the axis and through the source
|
|
616
|
+
// line. Long labels must now be ellipsized to fit that band, with the full
|
|
617
|
+
// text retained on `fullLabel` for tooltips and the a11y table.
|
|
618
|
+
const categories = ['Short', 'An Extremely Long Category Label That Cannot Possibly Fit'];
|
|
619
|
+
const barSpec: NormalizedChartSpec = {
|
|
620
|
+
...lineSpec,
|
|
621
|
+
markType: 'bar',
|
|
622
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
623
|
+
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
624
|
+
encoding: {
|
|
625
|
+
x: { field: 'cat', type: 'nominal' },
|
|
626
|
+
y: { field: 'val', type: 'quantitative' },
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
const measure = (text: string) => ({ width: text.length * 8, height: 12 });
|
|
630
|
+
const area = { x: 50, y: 50, width: 260, height: 300 };
|
|
631
|
+
const scales = computeScales(barSpec, area, barSpec.data);
|
|
632
|
+
const axes = computeAxes(scales, area, fullStrategy, theme, measure);
|
|
633
|
+
|
|
634
|
+
const ticks = axes.x!.ticks;
|
|
635
|
+
const angle = axes.x!.tickAngle!;
|
|
636
|
+
expect(Math.abs(angle)).toBeGreaterThan(10);
|
|
637
|
+
|
|
638
|
+
// The short label is untouched and carries no fullLabel.
|
|
639
|
+
const short = ticks.find((t) => t.value === 'Short')!;
|
|
640
|
+
expect(short.label).toBe('Short');
|
|
641
|
+
expect(short.fullLabel).toBeUndefined();
|
|
642
|
+
|
|
643
|
+
// The long one is ellipsized, but its full text survives on fullLabel.
|
|
644
|
+
const long = ticks.find((t) => t.value === categories[1])!;
|
|
645
|
+
expect(long.label).not.toBe(categories[1]);
|
|
646
|
+
expect(long.label.endsWith('…')).toBe(true);
|
|
647
|
+
expect(long.fullLabel).toBe(categories[1]);
|
|
648
|
+
|
|
649
|
+
// And the drawn label now fits the budget the reservation is computed from,
|
|
650
|
+
// so it cannot overflow the band.
|
|
651
|
+
const budget = maxRotatedLabelWidth(angle, theme.fonts.sizes.axisTick);
|
|
652
|
+
expect(measure(long.label).width).toBeLessThanOrEqual(budget);
|
|
653
|
+
});
|
|
654
|
+
|
|
608
655
|
it('measures collision at the drawn tick spacing when an explicit tickCount thins upstream', () => {
|
|
609
656
|
// 40 categories with tickCount: 8 — categoricalTicks retains every 5th
|
|
610
657
|
// category, so the drawn ticks sit 5 band steps apart. The angle and
|
|
@@ -272,6 +272,36 @@ describe('compileChart', () => {
|
|
|
272
272
|
expect(layout.a11y.dataTableFallback.length).toBeGreaterThan(0);
|
|
273
273
|
// With marks present, keyboard navigation should be enabled
|
|
274
274
|
expect(layout.a11y.keyboardNavigable).toBe(true);
|
|
275
|
+
// No author opt-out by default
|
|
276
|
+
expect(layout.a11y.hidden).toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a11y.description replaces the auto-generated alt text', () => {
|
|
280
|
+
const spec = { ...lineSpec, a11y: { description: 'GDP growth for US and UK, 2020 to 2021.' } };
|
|
281
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
282
|
+
expect(layout.a11y.altText).toBe('GDP growth for US and UK, 2020 to 2021.');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('top-level description (VL alias) replaces the auto-generated alt text', () => {
|
|
286
|
+
const spec = { ...lineSpec, description: 'Two-country GDP comparison.' };
|
|
287
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
288
|
+
expect(layout.a11y.altText).toBe('Two-country GDP comparison.');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('a11y.description wins over the top-level description', () => {
|
|
292
|
+
const spec = {
|
|
293
|
+
...lineSpec,
|
|
294
|
+
description: 'Alias text',
|
|
295
|
+
a11y: { description: 'Canonical text' },
|
|
296
|
+
};
|
|
297
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
298
|
+
expect(layout.a11y.altText).toBe('Canonical text');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('a11y.hidden flows into the layout metadata', () => {
|
|
302
|
+
const spec = { ...lineSpec, a11y: { hidden: true } };
|
|
303
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
304
|
+
expect(layout.a11y.hidden).toBe(true);
|
|
275
305
|
});
|
|
276
306
|
|
|
277
307
|
it('produces different strategies at different widths', () => {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-thinning footnote reserve.
|
|
3
|
+
*
|
|
4
|
+
* The footnote list renders below the plot, but thinning only discovers how many
|
|
5
|
+
* lines there are after the layout has already been computed against a plot that
|
|
6
|
+
* reserved no room for them. compileChart resolves that circularity by recompiling
|
|
7
|
+
* with the band reserved. These tests pin the two things that used to break:
|
|
8
|
+
* the list overrunning the footer row, and the reserve failing to converge.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { compileChart } from '../compile';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Six callouts in a narrow plot. At this width the labels are far wider than the
|
|
17
|
+
* plot, so most of them must demote — but they sit at different y positions and
|
|
18
|
+
* never pairwise-collide, which is precisely the case collision-only thinning
|
|
19
|
+
* missed.
|
|
20
|
+
*/
|
|
21
|
+
const crowdedSpec: ChartSpec = {
|
|
22
|
+
mark: 'line',
|
|
23
|
+
data: [
|
|
24
|
+
{ date: '2019-01-01', value: 10 },
|
|
25
|
+
{ date: '2019-06-01', value: 18 },
|
|
26
|
+
{ date: '2020-01-01', value: 15 },
|
|
27
|
+
{ date: '2020-06-01', value: 8 },
|
|
28
|
+
{ date: '2021-01-01', value: 22 },
|
|
29
|
+
{ date: '2021-06-01', value: 28 },
|
|
30
|
+
{ date: '2022-01-01', value: 35 },
|
|
31
|
+
{ date: '2023-01-01', value: 42 },
|
|
32
|
+
],
|
|
33
|
+
encoding: {
|
|
34
|
+
x: { field: 'date', type: 'temporal' },
|
|
35
|
+
y: { field: 'value', type: 'quantitative' },
|
|
36
|
+
},
|
|
37
|
+
annotations: [
|
|
38
|
+
{ type: 'text', x: '2021-01-01', y: 22, text: 'Recovery begins' },
|
|
39
|
+
{ type: 'text', x: '2019-06-01', y: 18, text: 'Early peak' },
|
|
40
|
+
{ type: 'text', x: '2021-06-01', y: 28, text: 'Strong rebound' },
|
|
41
|
+
{ type: 'text', x: '2022-01-01', y: 35, text: 'New high' },
|
|
42
|
+
{ type: 'text', x: '2023-01-01', y: 42, text: 'Record territory' },
|
|
43
|
+
],
|
|
44
|
+
chrome: { title: 'Crowded', source: 'Illustrative' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('auto-thinning footnote reserve', () => {
|
|
48
|
+
it('demotes labels that overflow a narrow plot, even though none collide', () => {
|
|
49
|
+
const layout = compileChart(crowdedSpec, { width: 200, height: 340 });
|
|
50
|
+
|
|
51
|
+
// Collision-only thinning left these inline, overflowing the plot. They
|
|
52
|
+
// should now be footnotes instead.
|
|
53
|
+
expect(layout.chrome.footnotes?.length ?? 0).toBeGreaterThan(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reserves bottom space so the footnote list clears the footer row', () => {
|
|
57
|
+
const withFootnotes = compileChart(crowdedSpec, { width: 200, height: 340 });
|
|
58
|
+
const footnotes = withFootnotes.chrome.footnotes ?? [];
|
|
59
|
+
expect(footnotes.length).toBeGreaterThan(0);
|
|
60
|
+
|
|
61
|
+
// Same chart, same box, no annotations to demote — so no band to reserve.
|
|
62
|
+
// Comparing against it isolates the reserve from the bottom chrome margin
|
|
63
|
+
// that both layouts already pay for. Asserting only "there is space below
|
|
64
|
+
// the plot" would pass with the reserve ripped out; this does not.
|
|
65
|
+
const { annotations: _dropped, ...noAnnotations } = crowdedSpec;
|
|
66
|
+
const baseline = compileChart(noAnnotations, { width: 200, height: 340 });
|
|
67
|
+
|
|
68
|
+
const plotBottom = (l: typeof withFootnotes) => l.area.y + l.area.height;
|
|
69
|
+
const pulledUpBy = plotBottom(baseline) - plotBottom(withFootnotes);
|
|
70
|
+
|
|
71
|
+
// The band the renderer will draw, measured the way core measures it.
|
|
72
|
+
const band = footnotes.length * withFootnotes.theme.fonts.sizes.small * 1.3 + 4;
|
|
73
|
+
|
|
74
|
+
// Epsilon, not a loosened bound: the engine and this line reach the same
|
|
75
|
+
// band through different float op orders, so an exact >= trips on ~1e-14 of
|
|
76
|
+
// accumulated error. A reserve that actually went missing would be short by
|
|
77
|
+
// a whole line (~14px), which this still catches.
|
|
78
|
+
expect(pulledUpBy).toBeGreaterThanOrEqual(band - 1e-9);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves the plot alone when nothing demotes', () => {
|
|
82
|
+
const roomy = compileChart(crowdedSpec, { width: 900, height: 500 });
|
|
83
|
+
// Wide enough that the labels fit; no footnotes, so no reserve.
|
|
84
|
+
expect(roomy.chrome.footnotes).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('converges instead of recursing forever', () => {
|
|
88
|
+
// Reserving space shrinks the plot, which can demote one more label and grow
|
|
89
|
+
// the band again. This must settle, not spin.
|
|
90
|
+
const layout = compileChart(crowdedSpec, { width: 160, height: 260 });
|
|
91
|
+
expect(layout.chrome.footnotes?.length ?? 0).toBeLessThanOrEqual(
|
|
92
|
+
crowdedSpec.annotations?.length ?? 0,
|
|
93
|
+
);
|
|
94
|
+
expect(layout.area.height).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Faceted charts take a separate compile path that used to skip thinning
|
|
100
|
+
* entirely: every panel kept every label inline, and labels routinely sprawled
|
|
101
|
+
* out of their panel and across the neighbouring one. A panel is its own little
|
|
102
|
+
* chart, so it thins against its own rect, not the figure's.
|
|
103
|
+
*/
|
|
104
|
+
describe('auto-thinning in faceted charts', () => {
|
|
105
|
+
// Deliberately long labels: at 640px each panel is only ~280px wide, so a label
|
|
106
|
+
// wider than that cannot fit inline no matter where it's placed. Short labels
|
|
107
|
+
// would leave the containment assertion below vacuous.
|
|
108
|
+
const facetedSpec: ChartSpec = {
|
|
109
|
+
...crowdedSpec,
|
|
110
|
+
data: ['North', 'South'].flatMap((region) =>
|
|
111
|
+
(crowdedSpec.data as Array<Record<string, unknown>>).map((row) => ({ ...row, region })),
|
|
112
|
+
),
|
|
113
|
+
encoding: {
|
|
114
|
+
...crowdedSpec.encoding,
|
|
115
|
+
facet: { field: 'region', type: 'nominal', columns: 2 },
|
|
116
|
+
},
|
|
117
|
+
annotations: [
|
|
118
|
+
{ type: 'text', x: '2021-01-01', y: 22, text: 'Recovery begins in earnest here' },
|
|
119
|
+
{ type: 'text', x: '2019-06-01', y: 18, text: 'An unusually early peak arrives' },
|
|
120
|
+
{ type: 'text', x: '2021-06-01', y: 28, text: 'A strong rebound takes hold' },
|
|
121
|
+
{ type: 'text', x: '2022-01-01', y: 35, text: 'The series posts a new high' },
|
|
122
|
+
{ type: 'text', x: '2023-01-01', y: 42, text: 'Firmly in record territory now' },
|
|
123
|
+
],
|
|
124
|
+
} as ChartSpec;
|
|
125
|
+
|
|
126
|
+
it('demotes labels that escape their panel', () => {
|
|
127
|
+
const layout = compileChart(facetedSpec, { width: 640, height: 400 });
|
|
128
|
+
expect(layout.facet).toBeDefined();
|
|
129
|
+
expect(layout.chrome.footnotes?.length ?? 0).toBeGreaterThan(0);
|
|
130
|
+
|
|
131
|
+
const demoted = layout.facet!.panels.flatMap((p) =>
|
|
132
|
+
p.annotations.filter((a) => a.footnoteIndex != null),
|
|
133
|
+
);
|
|
134
|
+
expect(demoted.length).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps every inline label inside its own panel', () => {
|
|
138
|
+
const layout = compileChart(facetedSpec, { width: 640, height: 400 });
|
|
139
|
+
|
|
140
|
+
for (const panel of layout.facet!.panels) {
|
|
141
|
+
for (const a of panel.annotations) {
|
|
142
|
+
if (a.footnoteIndex != null || !a.bounds) continue;
|
|
143
|
+
expect(a.bounds.x).toBeGreaterThanOrEqual(panel.area.x);
|
|
144
|
+
expect(a.bounds.x + a.bounds.width).toBeLessThanOrEqual(
|
|
145
|
+
panel.area.x + panel.area.width + 1e-9,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('numbers a demoted annotation once, not once per panel', () => {
|
|
152
|
+
const layout = compileChart(facetedSpec, { width: 640, height: 400 });
|
|
153
|
+
const footnotes = layout.chrome.footnotes ?? [];
|
|
154
|
+
|
|
155
|
+
// The spec's annotations resolve into every panel, so a naive per-panel pool
|
|
156
|
+
// would repeat each footnote once per panel.
|
|
157
|
+
const texts = footnotes.map((f) => f.text);
|
|
158
|
+
expect(new Set(texts).size).toBe(texts.length);
|
|
159
|
+
expect(footnotes.length).toBeLessThanOrEqual(facetedSpec.annotations?.length ?? 0);
|
|
160
|
+
|
|
161
|
+
// The same spec annotation gets the same number in every panel it demoted in.
|
|
162
|
+
//
|
|
163
|
+
// Pair on `specIndex`, not on array position. A panel's annotation list is a
|
|
164
|
+
// FILTERED, zIndex-SORTED view of the spec's, so `first.annotations[i]` and
|
|
165
|
+
// `second.annotations[i]` are only the same authored annotation by luck --
|
|
166
|
+
// and the moment one panel drops one, this silently starts comparing two
|
|
167
|
+
// different callouts and asserting nothing.
|
|
168
|
+
const [first, second] = layout.facet!.panels;
|
|
169
|
+
const numbering = new Map<number, number>();
|
|
170
|
+
let pairsChecked = 0;
|
|
171
|
+
|
|
172
|
+
for (const panel of [first, second]) {
|
|
173
|
+
for (const a of panel.annotations) {
|
|
174
|
+
if (a.footnoteIndex == null || a.specIndex == null) continue;
|
|
175
|
+
const seen = numbering.get(a.specIndex);
|
|
176
|
+
if (seen === undefined) {
|
|
177
|
+
numbering.set(a.specIndex, a.footnoteIndex);
|
|
178
|
+
} else {
|
|
179
|
+
expect(a.footnoteIndex).toBe(seen);
|
|
180
|
+
pairsChecked++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Guard the guard: if nothing demoted in both panels there is no pairing to
|
|
186
|
+
// check, and every assertion above is vacuous.
|
|
187
|
+
expect(pairsChecked).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('reserves a band for the footnote list below the grid', () => {
|
|
191
|
+
const withFootnotes = compileChart(facetedSpec, { width: 640, height: 400 });
|
|
192
|
+
const footnotes = withFootnotes.chrome.footnotes ?? [];
|
|
193
|
+
expect(footnotes.length).toBeGreaterThan(0);
|
|
194
|
+
|
|
195
|
+
const { annotations: _dropped, ...noAnnotations } = facetedSpec;
|
|
196
|
+
const baseline = compileChart(noAnnotations as ChartSpec, { width: 640, height: 400 });
|
|
197
|
+
|
|
198
|
+
const plotBottom = (l: typeof withFootnotes) => l.area.y + l.area.height;
|
|
199
|
+
const pulledUpBy = plotBottom(baseline) - plotBottom(withFootnotes);
|
|
200
|
+
const band = footnotes.length * withFootnotes.theme.fonts.sizes.small * 1.3 + 4;
|
|
201
|
+
|
|
202
|
+
expect(pulledUpBy).toBeGreaterThanOrEqual(band - 1e-9);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Regression: footnote numbers were keyed on the annotation's position in the
|
|
206
|
+
// *resolved* array. With `resolve.scale.x: 'independent'` each panel derives its
|
|
207
|
+
// own domain, so an annotation on a category that only exists in one panel
|
|
208
|
+
// resolves there and is dropped from the other. Position i then means a
|
|
209
|
+
// different spec annotation in each panel, and the markers pointed at other
|
|
210
|
+
// panels' text: South's callout carried marker 1, and footnote 1 read "AAA…".
|
|
211
|
+
// Keying on the stamped `specIndex` is what fixes it.
|
|
212
|
+
const long = (s: string) => s.repeat(12);
|
|
213
|
+
|
|
214
|
+
// Each panel gets its own x domain, so the North categories don't exist in South
|
|
215
|
+
// and vice versa: every annotation resolves in exactly ONE panel and is dropped
|
|
216
|
+
// from the other. That's what makes resolved-array position stop meaning spec
|
|
217
|
+
// position, which is the whole point of these two tests.
|
|
218
|
+
const independentDomainSpec: ChartSpec = {
|
|
219
|
+
mark: 'bar',
|
|
220
|
+
autoThin: true,
|
|
221
|
+
resolve: { scale: { x: 'independent' } },
|
|
222
|
+
data: [
|
|
223
|
+
{ region: 'North', cat: 'A', v: 10 },
|
|
224
|
+
{ region: 'North', cat: 'B', v: 20 },
|
|
225
|
+
{ region: 'North', cat: 'C', v: 15 },
|
|
226
|
+
{ region: 'South', cat: 'X', v: 12 },
|
|
227
|
+
{ region: 'South', cat: 'Y', v: 22 },
|
|
228
|
+
{ region: 'South', cat: 'Z', v: 17 },
|
|
229
|
+
],
|
|
230
|
+
encoding: {
|
|
231
|
+
x: { field: 'cat', type: 'nominal' },
|
|
232
|
+
y: { field: 'v', type: 'quantitative' },
|
|
233
|
+
facet: { field: 'region', type: 'nominal', columns: 2 },
|
|
234
|
+
},
|
|
235
|
+
// Interleaved on purpose: North, South, North, South. If numbering keys on
|
|
236
|
+
// resolved position, South's first annotation takes North's first number.
|
|
237
|
+
annotations: [
|
|
238
|
+
{ type: 'text', x: 'A', y: 10, text: long('AAA ') },
|
|
239
|
+
{ type: 'text', x: 'X', y: 12, text: long('XXX ') },
|
|
240
|
+
{ type: 'text', x: 'B', y: 20, text: long('BBB ') },
|
|
241
|
+
{ type: 'text', x: 'Y', y: 22, text: long('YYY ') },
|
|
242
|
+
],
|
|
243
|
+
} as ChartSpec;
|
|
244
|
+
|
|
245
|
+
it('points every marker at its own text when panels resolve different annotations', () => {
|
|
246
|
+
const layout = compileChart(independentDomainSpec, { width: 420, height: 300 });
|
|
247
|
+
|
|
248
|
+
const footnotes = layout.chrome.footnotes ?? [];
|
|
249
|
+
expect(footnotes.length).toBeGreaterThan(0);
|
|
250
|
+
const textByNumber = new Map(footnotes.map((f) => [f.index, f.text]));
|
|
251
|
+
|
|
252
|
+
let checked = 0;
|
|
253
|
+
for (const panel of layout.facet!.panels) {
|
|
254
|
+
for (const a of panel.annotations) {
|
|
255
|
+
if (a.footnoteIndex == null) continue;
|
|
256
|
+
// The whole contract: follow the marker, land on your own words.
|
|
257
|
+
expect(textByNumber.get(a.footnoteIndex)).toBe(a.label?.text);
|
|
258
|
+
checked++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
expect(checked).toBeGreaterThan(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('lists every demoted annotation exactly once, in every panel', () => {
|
|
265
|
+
const layout = compileChart(independentDomainSpec, { width: 420, height: 300 });
|
|
266
|
+
|
|
267
|
+
const texts = (layout.chrome.footnotes ?? []).map((f) => f.text);
|
|
268
|
+
expect(texts.length).toBeGreaterThan(0);
|
|
269
|
+
// No duplicates: a panel that demotes the same spec annotation reuses its
|
|
270
|
+
// number rather than appending a second copy of the text.
|
|
271
|
+
expect(new Set(texts).size).toBe(texts.length);
|
|
272
|
+
// And both panels' annotations are represented -- the pre-fix bug listed only
|
|
273
|
+
// the first panel's, and pointed the second panel's markers at them.
|
|
274
|
+
expect(texts.some((t) => t.startsWith('AAA'))).toBe(true);
|
|
275
|
+
expect(texts.some((t) => t.startsWith('XXX'))).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { compileLayer } from '../compile';
|
|
3
|
+
import { compileChart, compileLayer } from '../compile';
|
|
4
4
|
import { flattenLayers } from '../compiler/normalize';
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -322,6 +322,79 @@ describe('compileLayer', () => {
|
|
|
322
322
|
expect(layout.area.width).toBeGreaterThan(0);
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
+
it('unions the y domain even when x is nominal', () => {
|
|
326
|
+
// The channel loop used to `return` on the first channel that couldn't take
|
|
327
|
+
// part. x is visited first, so a nominal x abandoned y as well: each leaf
|
|
328
|
+
// then re-fit y to its own rows and every bar rendered full-height,
|
|
329
|
+
// regardless of value. Bars-plus-labels is the most common layered shape
|
|
330
|
+
// there is, so the function missed its own headline case.
|
|
331
|
+
const spec: LayerSpec = {
|
|
332
|
+
layer: [
|
|
333
|
+
{
|
|
334
|
+
mark: 'bar' as const,
|
|
335
|
+
data: [{ n: 'A', v: 50 }],
|
|
336
|
+
encoding: {
|
|
337
|
+
x: { field: 'n', type: 'nominal' as const },
|
|
338
|
+
y: { field: 'v', type: 'quantitative' as const },
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
mark: 'bar' as const,
|
|
343
|
+
data: [{ n: 'B', v: 100 }],
|
|
344
|
+
encoding: {
|
|
345
|
+
x: { field: 'n', type: 'nominal' as const },
|
|
346
|
+
y: { field: 'v', type: 'quantitative' as const },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const rects = compileLayer(spec, compileOpts).marks.filter((m) => m.type === 'rect');
|
|
353
|
+
expect(rects).toHaveLength(2);
|
|
354
|
+
|
|
355
|
+
// Sharing a zero-based domain, the 50-bar is exactly half the 100-bar.
|
|
356
|
+
const [half, full] = rects.map((r) => (r as { height: number }).height);
|
|
357
|
+
expect(half).toBeCloseTo(full / 2, 1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('gives layered bars the same geometry as the equivalent single chart', () => {
|
|
361
|
+
// The shared domain is pinned onto each leaf as `scale.domain`, and an
|
|
362
|
+
// explicit domain skips the `zero !== false` baselining a normal scale
|
|
363
|
+
// applies. Without folding zero back in, the union lands as a literal
|
|
364
|
+
// [50, 100] and the 50-bar collapses to a 1px sliver against its baseline.
|
|
365
|
+
const encoding = {
|
|
366
|
+
x: { field: 'n', type: 'nominal' as const },
|
|
367
|
+
y: { field: 'v', type: 'quantitative' as const },
|
|
368
|
+
};
|
|
369
|
+
const heights = (layout: { marks: Array<{ type: string }> }) =>
|
|
370
|
+
layout.marks
|
|
371
|
+
.filter((m) => m.type === 'rect')
|
|
372
|
+
.map((r) => Number((r as unknown as { height: number }).height.toFixed(1)));
|
|
373
|
+
|
|
374
|
+
const single = compileChart(
|
|
375
|
+
{
|
|
376
|
+
mark: 'bar',
|
|
377
|
+
data: [
|
|
378
|
+
{ n: 'A', v: 50 },
|
|
379
|
+
{ n: 'B', v: 100 },
|
|
380
|
+
],
|
|
381
|
+
encoding,
|
|
382
|
+
},
|
|
383
|
+
compileOpts,
|
|
384
|
+
);
|
|
385
|
+
const layered = compileLayer(
|
|
386
|
+
{
|
|
387
|
+
layer: [
|
|
388
|
+
{ mark: 'bar' as const, data: [{ n: 'A', v: 50 }], encoding },
|
|
389
|
+
{ mark: 'bar' as const, data: [{ n: 'B', v: 100 }], encoding },
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
compileOpts,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
expect(heights(layered)).toEqual(heights(single));
|
|
396
|
+
});
|
|
397
|
+
|
|
325
398
|
it('compiles a single-layer LayerSpec identically to a ChartSpec', () => {
|
|
326
399
|
const chartData = [
|
|
327
400
|
{ name: 'A', value: 10 },
|
|
@@ -654,6 +727,52 @@ describe('compileLayer', () => {
|
|
|
654
727
|
expect(uniqueLabels.has('Y')).toBe(true);
|
|
655
728
|
});
|
|
656
729
|
|
|
730
|
+
/**
|
|
731
|
+
* `legends` is the slot the renderer iterates; `legend` is the back-compat
|
|
732
|
+
* alias. The merge used to rebuild only `legend`, leaving `legends[0]` as the
|
|
733
|
+
* primary leaf's *pre-merge* legend -- so the second layer's series silently
|
|
734
|
+
* vanished from the drawn key while `layout.legend` still listed it.
|
|
735
|
+
*
|
|
736
|
+
* The leaves must color on DIFFERENT fields to exercise this: sharing one field
|
|
737
|
+
* makes the primary spec's concatenated data yield both entries on its own, and
|
|
738
|
+
* the merge becomes a no-op that proves nothing.
|
|
739
|
+
*/
|
|
740
|
+
it('merges layer legend entries into `legends`, not just `legend`', () => {
|
|
741
|
+
const spec: LayerSpec = {
|
|
742
|
+
layer: [
|
|
743
|
+
{
|
|
744
|
+
mark: 'bar' as const,
|
|
745
|
+
data: [{ name: 'A', value: 10, cat: 'Bars' }],
|
|
746
|
+
encoding: {
|
|
747
|
+
x: { field: 'name', type: 'ordinal' as const },
|
|
748
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
749
|
+
color: { field: 'cat', type: 'nominal' as const },
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
mark: 'line' as const,
|
|
754
|
+
data: [{ name: 'A', value: 8, series: 'Trend' }],
|
|
755
|
+
encoding: {
|
|
756
|
+
x: { field: 'name', type: 'ordinal' as const },
|
|
757
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
758
|
+
color: { field: 'series', type: 'nominal' as const },
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const layout = compileLayer(spec, compileOpts);
|
|
765
|
+
const rendered = layout.legends[0];
|
|
766
|
+
expect('entries' in rendered).toBe(true);
|
|
767
|
+
if (!('entries' in rendered)) return;
|
|
768
|
+
|
|
769
|
+
const labels = rendered.entries.map((e) => e.label);
|
|
770
|
+
expect(labels).toContain('Bars');
|
|
771
|
+
expect(labels).toContain('Trend');
|
|
772
|
+
// What renders is what `legend` advertises.
|
|
773
|
+
expect(labels).toEqual(layout.legend.entries.map((e) => e.label));
|
|
774
|
+
});
|
|
775
|
+
|
|
657
776
|
it('remaps x-coordinates when area is layer 0 and bars are layer 1', () => {
|
|
658
777
|
// Inverse ordering: area/line on left axis, bars on right. The x-remapping
|
|
659
778
|
// logic must detect that layer 1 has bars and remap layer 0's area mark instead.
|
|
@@ -28,6 +28,16 @@ function isCategoricalLegend(legend: LegendLayout): legend is CategoricalLegendL
|
|
|
28
28
|
return 'entries' in legend && (!legend.type || legend.type === 'categorical');
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Every legend on the layout. Falls back to the singular `legend` slot so this
|
|
33
|
+
* still works against layouts built by older code paths (and by tests that
|
|
34
|
+
* hand-assemble a ChartLayout).
|
|
35
|
+
*/
|
|
36
|
+
function allLegends(layout: ChartLayout): LegendLayout[] {
|
|
37
|
+
if (layout.legends?.length) return layout.legends;
|
|
38
|
+
return layout.legend ? [layout.legend] : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// ---------------------------------------------------------------------------
|
|
32
42
|
// Box extraction
|
|
33
43
|
// ---------------------------------------------------------------------------
|
|
@@ -51,21 +61,27 @@ export function extractBoxes(layout: ChartLayout): NamedRect[] {
|
|
|
51
61
|
// Chart area
|
|
52
62
|
boxes.push({ ...layout.area, name: 'chartArea' });
|
|
53
63
|
|
|
54
|
-
// Legend bounds
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
// Legend bounds -- EVERY legend, not just the primary. A chart can carry both
|
|
65
|
+
// a color legend and a size legend; registering only `layout.legend` would
|
|
66
|
+
// leave the overlap invariants blind to exactly the element most likely to be
|
|
67
|
+
// mis-reserved, and they would pass a chart whose size legend sits on the plot.
|
|
68
|
+
for (const legend of allLegends(layout)) {
|
|
69
|
+
const suffix = legend.channel && legend.channel !== 'color' ? `-${legend.channel}` : '';
|
|
70
|
+
boxes.push({ ...legend.bounds, name: `legend${suffix}` });
|
|
71
|
+
|
|
72
|
+
// Legend entry positions (categorical only)
|
|
73
|
+
if (isCategoricalLegend(legend) && legend.entryPositions) {
|
|
74
|
+
const rowHeight = legend.rowHeight ?? 16;
|
|
75
|
+
for (let i = 0; i < legend.entryPositions.length; i++) {
|
|
76
|
+
const pos = legend.entryPositions[i];
|
|
77
|
+
boxes.push({
|
|
78
|
+
x: pos.x,
|
|
79
|
+
y: pos.y,
|
|
80
|
+
width: pos.width,
|
|
81
|
+
height: rowHeight,
|
|
82
|
+
name: `legend${suffix}-entry-${i}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
87
|
|
|
@@ -179,14 +195,32 @@ export function checkLayoutInvariants(
|
|
|
179
195
|
): string[] {
|
|
180
196
|
const violations: string[] = [];
|
|
181
197
|
|
|
182
|
-
// 1.
|
|
198
|
+
// 1. No legend intrudes into chartArea (top/bottom only; right/bottom-right
|
|
183
199
|
// use area.width reduction, so overlap is a horizontal concern checked by rule 4)
|
|
184
|
-
|
|
185
|
-
|
|
200
|
+
//
|
|
201
|
+
// Checked for EVERY legend and every legend type. A size legend is not
|
|
202
|
+
// categorical, and it is the one whose space is most likely to be
|
|
203
|
+
// under-reserved -- gating this on `isCategoricalLegend` would skip it.
|
|
204
|
+
for (const legend of allLegends(layout)) {
|
|
205
|
+
if (legend.bounds.width <= 0 || legend.bounds.height <= 0) continue;
|
|
206
|
+
const pos = legend.position;
|
|
186
207
|
if (pos === 'top' || pos === 'bottom') {
|
|
187
|
-
if (rectsOverlap(
|
|
208
|
+
if (rectsOverlap(legend.bounds, layout.area, 0.5)) {
|
|
209
|
+
const name = legend.channel ?? 'color';
|
|
210
|
+
violations.push(
|
|
211
|
+
`${name} legend intrudes into chartArea: legend=${formatRect(legend.bounds)} chartArea=${formatRect(layout.area)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 1b. Legends don't overlap each other.
|
|
218
|
+
const legends = allLegends(layout).filter((l) => l.bounds.width > 0 && l.bounds.height > 0);
|
|
219
|
+
for (let i = 0; i < legends.length; i++) {
|
|
220
|
+
for (let j = i + 1; j < legends.length; j++) {
|
|
221
|
+
if (rectsOverlap(legends[i].bounds, legends[j].bounds, 0.5)) {
|
|
188
222
|
violations.push(
|
|
189
|
-
`
|
|
223
|
+
`legends overlap: ${legends[i].channel ?? 'color'}=${formatRect(legends[i].bounds)} ${legends[j].channel ?? 'color'}=${formatRect(legends[j].bounds)}`,
|
|
190
224
|
);
|
|
191
225
|
}
|
|
192
226
|
}
|