@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
|
@@ -3,6 +3,7 @@ import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { compileChart } from '../compile';
|
|
5
5
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
6
|
+
import { INLINE_TICK_OVERHANG_PAD } from '../layout/shared';
|
|
6
7
|
import { computeLegend } from '../legend/compute';
|
|
7
8
|
|
|
8
9
|
const specWithColor: NormalizedChartSpec = {
|
|
@@ -418,16 +419,16 @@ describe('computeLegend', () => {
|
|
|
418
419
|
expect(legend.entries).toHaveLength(3);
|
|
419
420
|
});
|
|
420
421
|
|
|
421
|
-
it('
|
|
422
|
-
//
|
|
423
|
-
// the legend auto-
|
|
422
|
+
it('preserves legend for default (stacked) area chart', () => {
|
|
423
|
+
// v8: area stacks by default (Vega-Lite aligned). Stacked areas keep
|
|
424
|
+
// the traditional legend rather than auto-suppressing for endpoint labels.
|
|
424
425
|
const areaSpec: NormalizedChartSpec = {
|
|
425
426
|
...lineWithLabels,
|
|
426
427
|
markType: 'area',
|
|
427
428
|
markDef: { type: 'area' },
|
|
428
429
|
};
|
|
429
430
|
const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
|
|
430
|
-
expect(legend.entries).toHaveLength(
|
|
431
|
+
expect(legend.entries).toHaveLength(3);
|
|
431
432
|
});
|
|
432
433
|
|
|
433
434
|
it('suppresses legend for explicit overlap area chart (stack: null)', () => {
|
|
@@ -510,6 +511,56 @@ describe('computeLegend', () => {
|
|
|
510
511
|
const gap = layout.area.y - legendBottom;
|
|
511
512
|
expect(gap).toBe(0);
|
|
512
513
|
});
|
|
514
|
+
|
|
515
|
+
// Inline y ticks (line/area) draw their topmost label ABOVE chartArea.y,
|
|
516
|
+
// inside the inlineTickOverhang zone the margin stack reserved. The legend
|
|
517
|
+
// must sit above that zone, not flush against chartArea.y.
|
|
518
|
+
const inlineTopLegendSpec = {
|
|
519
|
+
mark: 'line' as const,
|
|
520
|
+
data: [
|
|
521
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
522
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
523
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
524
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
525
|
+
],
|
|
526
|
+
encoding: {
|
|
527
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
528
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
529
|
+
color: { field: 'country', type: 'nominal' as const },
|
|
530
|
+
},
|
|
531
|
+
legend: { show: true, position: 'top' as const },
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
it('top legend clears the inline y-tick overhang at compact width', () => {
|
|
535
|
+
const layout = compileChart(inlineTopLegendSpec, { width: 360, height: 400 });
|
|
536
|
+
|
|
537
|
+
expect(layout.legend.position).toBe('top');
|
|
538
|
+
expect(layout.legend.entries.length).toBeGreaterThan(0);
|
|
539
|
+
|
|
540
|
+
const inlineTickOverhang = layout.theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD;
|
|
541
|
+
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
|
|
542
|
+
expect(legendBottom).toBeLessThanOrEqual(layout.area.y - inlineTickOverhang + 1e-6);
|
|
543
|
+
|
|
544
|
+
// Pinned placement. Before the fix the legend bottom sat flush at
|
|
545
|
+
// chartArea.y (legend bottom was 62 = chartArea.y), overlapping the
|
|
546
|
+
// topmost inline tick label; it now sits the 17px overhang higher,
|
|
547
|
+
// while the 6px minimum gap above the legend shifts chartArea down.
|
|
548
|
+
expect(layout.legend.bounds.y).toBe(31);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('gutter y-ticks keep the legend flush at the legendGap (no overhang shift)', () => {
|
|
552
|
+
// Bar charts use gutter y-tick labels, so inlineTickOverhang is 0 and
|
|
553
|
+
// the legend keeps its legendGap-only offset from the chart area.
|
|
554
|
+
const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
|
|
555
|
+
|
|
556
|
+
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
|
|
557
|
+
expect(layout.area.y - legendBottom).toBe(8);
|
|
558
|
+
|
|
559
|
+
// Pinned placement: 17 before the placement fix; the +6 comes from the
|
|
560
|
+
// TOP_LEGEND_GAP_ABOVE margin (not from any overhang shift — gutter
|
|
561
|
+
// ticks have no overhang, so the legend-to-chart gap above stays 8).
|
|
562
|
+
expect(layout.legend.bounds.y).toBe(23);
|
|
563
|
+
});
|
|
513
564
|
});
|
|
514
565
|
|
|
515
566
|
describe('entryPositions contract', () => {
|
|
@@ -602,4 +653,69 @@ describe('computeLegend', () => {
|
|
|
602
653
|
expect(legend.entries).toHaveLength(3);
|
|
603
654
|
});
|
|
604
655
|
});
|
|
656
|
+
|
|
657
|
+
describe('swatch colors match mark colors', () => {
|
|
658
|
+
const energySpec = {
|
|
659
|
+
mark: 'waffle' as const,
|
|
660
|
+
data: [
|
|
661
|
+
{ source: 'Fossil fuels', share: 60 },
|
|
662
|
+
{ source: 'Renewables', share: 30 },
|
|
663
|
+
{ source: 'Nuclear', share: 10 },
|
|
664
|
+
],
|
|
665
|
+
encoding: {
|
|
666
|
+
theta: { field: 'share', type: 'quantitative' as const },
|
|
667
|
+
color: { field: 'source', type: 'nominal' as const },
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Map each category to the fill its marks actually render with. Waffle
|
|
673
|
+
* cells carry the category in `data`, not `seriesKey`, so key off the
|
|
674
|
+
* color field.
|
|
675
|
+
*/
|
|
676
|
+
function markFillByLabel(layout: ReturnType<typeof compileChart>): Map<string, string> {
|
|
677
|
+
const fills = new Map<string, string>();
|
|
678
|
+
for (const mark of layout.marks) {
|
|
679
|
+
const label = mark.data?.source;
|
|
680
|
+
const fill = 'fill' in mark ? mark.fill : undefined;
|
|
681
|
+
if (typeof label === 'string' && typeof fill === 'string') fills.set(label, fill);
|
|
682
|
+
}
|
|
683
|
+
return fills;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
it('mutes the swatches of non-highlighted series alongside their marks', () => {
|
|
687
|
+
const layout = compileChart(
|
|
688
|
+
{
|
|
689
|
+
...energySpec,
|
|
690
|
+
encoding: {
|
|
691
|
+
...energySpec.encoding,
|
|
692
|
+
color: { ...energySpec.encoding.color, highlight: 'Renewables' },
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{ width: 600, height: 400 },
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
const fills = markFillByLabel(layout);
|
|
699
|
+
expect(layout.legend.entries.length).toBe(3);
|
|
700
|
+
for (const entry of layout.legend.entries) {
|
|
701
|
+
expect(entry.color).toBe(fills.get(entry.label));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// The muted pair share one gray; the highlighted series does not.
|
|
705
|
+
const highlighted = layout.legend.entries.find((e) => e.label === 'Renewables');
|
|
706
|
+
const muted = layout.legend.entries.filter((e) => e.label !== 'Renewables');
|
|
707
|
+
expect(muted.map((e) => e.color)).toEqual([muted[0].color, muted[0].color]);
|
|
708
|
+
expect(highlighted?.color).not.toBe(muted[0].color);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('matches mark colors when no highlight is active', () => {
|
|
712
|
+
const layout = compileChart(energySpec, { width: 600, height: 400 });
|
|
713
|
+
|
|
714
|
+
const fills = markFillByLabel(layout);
|
|
715
|
+
expect(layout.legend.entries.length).toBe(3);
|
|
716
|
+
for (const entry of layout.legend.entries) {
|
|
717
|
+
expect(entry.color).toBe(fills.get(entry.label));
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
});
|
|
605
721
|
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the slope + bump chart recipes (plan 14).
|
|
3
|
+
*
|
|
4
|
+
* These are the exact specs published on docs/ranking-and-change.md and
|
|
5
|
+
* pinned as visual fixtures in examples/src/testing/fixtures-ranking.stories.tsx.
|
|
6
|
+
* They use only public spec surface: if a test here needs a private flag to
|
|
7
|
+
* pass, the recipe is broken.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChartSpec, LineMark, PointMark } from '@opendata-ai/openchart-core';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { compileChart } from '../compile';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Recipe specs (keep byte-identical to docs/ranking-and-change.md)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const slopeMarketShareSpec: ChartSpec = {
|
|
20
|
+
mark: 'line',
|
|
21
|
+
data: [
|
|
22
|
+
{ year: '2019', brand: 'Samsung', share: 0.216 },
|
|
23
|
+
{ year: '2024', brand: 'Samsung', share: 0.19 },
|
|
24
|
+
{ year: '2019', brand: 'Huawei', share: 0.176 },
|
|
25
|
+
{ year: '2024', brand: 'Huawei', share: 0.043 },
|
|
26
|
+
{ year: '2019', brand: 'Apple', share: 0.139 },
|
|
27
|
+
{ year: '2024', brand: 'Apple', share: 0.187 },
|
|
28
|
+
{ year: '2019', brand: 'Xiaomi', share: 0.092 },
|
|
29
|
+
{ year: '2024', brand: 'Xiaomi', share: 0.136 },
|
|
30
|
+
{ year: '2019', brand: 'Oppo', share: 0.083 },
|
|
31
|
+
{ year: '2024', brand: 'Oppo', share: 0.087 },
|
|
32
|
+
],
|
|
33
|
+
encoding: {
|
|
34
|
+
x: { field: 'year', type: 'ordinal' },
|
|
35
|
+
y: {
|
|
36
|
+
field: 'share',
|
|
37
|
+
type: 'quantitative',
|
|
38
|
+
axis: false,
|
|
39
|
+
scale: { zero: false },
|
|
40
|
+
},
|
|
41
|
+
color: { field: 'brand', type: 'nominal' },
|
|
42
|
+
},
|
|
43
|
+
endpointLabels: {
|
|
44
|
+
ends: 'both',
|
|
45
|
+
content: 'label value',
|
|
46
|
+
format: '.0%',
|
|
47
|
+
},
|
|
48
|
+
legend: { show: false },
|
|
49
|
+
chrome: {
|
|
50
|
+
title: 'Apple and Xiaomi Split What Huawei Lost',
|
|
51
|
+
subtitle: 'Share of global smartphone shipments, 2019 vs 2024',
|
|
52
|
+
source: 'Source: IDC Worldwide Quarterly Mobile Phone Tracker',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const bumpConstructorsSpec: ChartSpec = {
|
|
57
|
+
mark: { type: 'line', interpolate: 'monotone', point: true },
|
|
58
|
+
data: [
|
|
59
|
+
{ season: '2019', team: 'Mercedes', position: 1 },
|
|
60
|
+
{ season: '2020', team: 'Mercedes', position: 1 },
|
|
61
|
+
{ season: '2021', team: 'Mercedes', position: 1 },
|
|
62
|
+
{ season: '2022', team: 'Mercedes', position: 3 },
|
|
63
|
+
{ season: '2023', team: 'Mercedes', position: 2 },
|
|
64
|
+
{ season: '2024', team: 'Mercedes', position: 4 },
|
|
65
|
+
{ season: '2019', team: 'Red Bull', position: 3 },
|
|
66
|
+
{ season: '2020', team: 'Red Bull', position: 2 },
|
|
67
|
+
{ season: '2021', team: 'Red Bull', position: 2 },
|
|
68
|
+
{ season: '2022', team: 'Red Bull', position: 1 },
|
|
69
|
+
{ season: '2023', team: 'Red Bull', position: 1 },
|
|
70
|
+
{ season: '2024', team: 'Red Bull', position: 3 },
|
|
71
|
+
{ season: '2019', team: 'Ferrari', position: 2 },
|
|
72
|
+
{ season: '2020', team: 'Ferrari', position: 6 },
|
|
73
|
+
{ season: '2021', team: 'Ferrari', position: 3 },
|
|
74
|
+
{ season: '2022', team: 'Ferrari', position: 2 },
|
|
75
|
+
{ season: '2023', team: 'Ferrari', position: 3 },
|
|
76
|
+
{ season: '2024', team: 'Ferrari', position: 2 },
|
|
77
|
+
{ season: '2019', team: 'McLaren', position: 4 },
|
|
78
|
+
{ season: '2020', team: 'McLaren', position: 3 },
|
|
79
|
+
{ season: '2021', team: 'McLaren', position: 4 },
|
|
80
|
+
{ season: '2022', team: 'McLaren', position: 5 },
|
|
81
|
+
{ season: '2023', team: 'McLaren', position: 4 },
|
|
82
|
+
{ season: '2024', team: 'McLaren', position: 1 },
|
|
83
|
+
{ season: '2019', team: 'Alpine', position: 5 },
|
|
84
|
+
{ season: '2020', team: 'Alpine', position: 5 },
|
|
85
|
+
{ season: '2021', team: 'Alpine', position: 5 },
|
|
86
|
+
{ season: '2022', team: 'Alpine', position: 4 },
|
|
87
|
+
{ season: '2023', team: 'Alpine', position: 6 },
|
|
88
|
+
{ season: '2024', team: 'Alpine', position: 6 },
|
|
89
|
+
],
|
|
90
|
+
encoding: {
|
|
91
|
+
x: { field: 'season', type: 'ordinal' },
|
|
92
|
+
y: {
|
|
93
|
+
field: 'position',
|
|
94
|
+
type: 'quantitative',
|
|
95
|
+
scale: { reverse: true, zero: false },
|
|
96
|
+
axis: { values: [1, 2, 3, 4, 5, 6], format: 'ordinal' },
|
|
97
|
+
},
|
|
98
|
+
color: { field: 'team', type: 'nominal' },
|
|
99
|
+
},
|
|
100
|
+
endpointLabels: {
|
|
101
|
+
ends: 'both',
|
|
102
|
+
content: 'label',
|
|
103
|
+
showMarker: false,
|
|
104
|
+
},
|
|
105
|
+
legend: { show: false },
|
|
106
|
+
chrome: {
|
|
107
|
+
title: 'McLaren Went From Midfield to Champions',
|
|
108
|
+
subtitle: 'Formula 1 constructors championship, final position by season',
|
|
109
|
+
source: 'Source: FIA official standings',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Slope recipe
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe('slope chart recipe', () => {
|
|
118
|
+
it('renders both-end labels for all five series', () => {
|
|
119
|
+
const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
|
|
120
|
+
|
|
121
|
+
expect(layout.endpointLabels?.entries).toHaveLength(5);
|
|
122
|
+
expect(layout.endpointLabels?.leading).toHaveLength(5);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("composes 'name value' on one line at both ends", () => {
|
|
126
|
+
const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
|
|
127
|
+
|
|
128
|
+
const left = layout.endpointLabels!.leading!.find((e) => e.seriesKey === 'Samsung');
|
|
129
|
+
expect(left!.labelLines[0]).toBe('Samsung 22%');
|
|
130
|
+
expect(left!.value).toBe('');
|
|
131
|
+
|
|
132
|
+
const right = layout.endpointLabels!.entries.find((e) => e.seriesKey === 'Samsung');
|
|
133
|
+
expect(right!.labelLines[0]).toBe('Samsung 19%');
|
|
134
|
+
expect(right!.value).toBe('');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('renders no y axis and no gridlines', () => {
|
|
138
|
+
const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
|
|
139
|
+
|
|
140
|
+
expect(layout.axes.y).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('orders labels by value at each end (Huawei falls from 2nd to last)', () => {
|
|
144
|
+
const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
|
|
145
|
+
|
|
146
|
+
const byY = (a: { labelY: number }, b: { labelY: number }) => a.labelY - b.labelY;
|
|
147
|
+
const leftOrder = [...layout.endpointLabels!.leading!].sort(byY).map((e) => e.seriesKey);
|
|
148
|
+
expect(leftOrder).toEqual(['Samsung', 'Huawei', 'Apple', 'Xiaomi', 'Oppo']);
|
|
149
|
+
|
|
150
|
+
const rightOrder = [...layout.endpointLabels!.entries].sort(byY).map((e) => e.seriesKey);
|
|
151
|
+
expect(rightOrder).toEqual(['Samsung', 'Apple', 'Xiaomi', 'Oppo', 'Huawei']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('keeps both-end labels and a positive plot area at 320px (compact breakpoint)', () => {
|
|
155
|
+
const layout = compileChart(slopeMarketShareSpec, { width: 320, height: 360 });
|
|
156
|
+
|
|
157
|
+
expect(layout.endpointLabels?.entries).toHaveLength(5);
|
|
158
|
+
expect(layout.endpointLabels?.leading).toHaveLength(5);
|
|
159
|
+
expect(layout.area.width).toBeGreaterThan(0);
|
|
160
|
+
// Both label columns fit inside the 320px canvas.
|
|
161
|
+
expect(layout.endpointLabels!.leadingBounds!.x).toBeGreaterThanOrEqual(0);
|
|
162
|
+
expect(
|
|
163
|
+
layout.endpointLabels!.bounds.x + layout.endpointLabels!.bounds.width,
|
|
164
|
+
).toBeLessThanOrEqual(320);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Bump recipe
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('bump chart recipe', () => {
|
|
173
|
+
it('puts rank 1 at the top via scale.reverse', () => {
|
|
174
|
+
const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
|
|
175
|
+
|
|
176
|
+
const ticks = layout.axes.y!.ticks;
|
|
177
|
+
const first = ticks.find((t) => t.value === 1);
|
|
178
|
+
const last = ticks.find((t) => t.value === 6);
|
|
179
|
+
expect(first).toBeDefined();
|
|
180
|
+
expect(last).toBeDefined();
|
|
181
|
+
expect(first!.position).toBeLessThan(last!.position);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('labels rank ticks as ordinals, 1st through 6th top to bottom', () => {
|
|
185
|
+
const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
|
|
186
|
+
|
|
187
|
+
const labels = [...layout.axes.y!.ticks]
|
|
188
|
+
.sort((a, b) => a.position - b.position)
|
|
189
|
+
.map((t) => t.label);
|
|
190
|
+
expect(labels).toEqual(['1st', '2nd', '3rd', '4th', '5th', '6th']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('draws a point marker at every step of every series', () => {
|
|
194
|
+
const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
|
|
195
|
+
|
|
196
|
+
const points = layout.marks.filter(
|
|
197
|
+
(m): m is PointMark => m.type === 'point' && m.opacity !== 0,
|
|
198
|
+
);
|
|
199
|
+
// 5 teams x 6 seasons.
|
|
200
|
+
expect(points).toHaveLength(30);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('orders both-end name labels by rank at each end', () => {
|
|
204
|
+
const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
|
|
205
|
+
|
|
206
|
+
const byY = (a: { labelY: number }, b: { labelY: number }) => a.labelY - b.labelY;
|
|
207
|
+
// 2019: Mercedes 1st, Ferrari 2nd, Red Bull 3rd, McLaren 4th, Alpine 5th.
|
|
208
|
+
const leftOrder = [...layout.endpointLabels!.leading!].sort(byY).map((e) => e.seriesKey);
|
|
209
|
+
expect(leftOrder).toEqual(['Mercedes', 'Ferrari', 'Red Bull', 'McLaren', 'Alpine']);
|
|
210
|
+
// 2024: McLaren 1st, Ferrari 2nd, Red Bull 3rd, Mercedes 4th, Alpine 6th.
|
|
211
|
+
const rightOrder = [...layout.endpointLabels!.entries].sort(byY).map((e) => e.seriesKey);
|
|
212
|
+
expect(rightOrder).toEqual(['McLaren', 'Ferrari', 'Red Bull', 'Mercedes', 'Alpine']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('renders name-only labels (no value line)', () => {
|
|
216
|
+
const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
|
|
217
|
+
|
|
218
|
+
for (const entry of layout.endpointLabels!.entries) {
|
|
219
|
+
expect(entry.labelLines).toEqual([entry.seriesKey]);
|
|
220
|
+
expect(entry.value).toBe('');
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('maps tied ranks to the same y (ties share a row)', () => {
|
|
225
|
+
const tieSpec: ChartSpec = {
|
|
226
|
+
...bumpConstructorsSpec,
|
|
227
|
+
data: [
|
|
228
|
+
{ season: '2023', team: 'A', position: 1 },
|
|
229
|
+
{ season: '2024', team: 'A', position: 2 },
|
|
230
|
+
{ season: '2023', team: 'B', position: 2 },
|
|
231
|
+
{ season: '2024', team: 'B', position: 2 },
|
|
232
|
+
{ season: '2023', team: 'C', position: 3 },
|
|
233
|
+
{ season: '2024', team: 'C', position: 1 },
|
|
234
|
+
],
|
|
235
|
+
encoding: {
|
|
236
|
+
...bumpConstructorsSpec.encoding,
|
|
237
|
+
y: {
|
|
238
|
+
field: 'position',
|
|
239
|
+
type: 'quantitative',
|
|
240
|
+
scale: { reverse: true, zero: false },
|
|
241
|
+
axis: { values: [1, 2, 3], format: 'ordinal' },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
const layout = compileChart(tieSpec, { width: 640, height: 400 });
|
|
246
|
+
|
|
247
|
+
const lines = layout.marks.filter(
|
|
248
|
+
(m): m is LineMark => m.type === 'line' && m.seriesKey != null,
|
|
249
|
+
);
|
|
250
|
+
const lastY = (key: string) => {
|
|
251
|
+
const mark = lines.find((m) => m.seriesKey === key)!;
|
|
252
|
+
const pts = mark.dataPoints ?? [];
|
|
253
|
+
return pts[pts.length - 1]!.y;
|
|
254
|
+
};
|
|
255
|
+
// A and B tie for 2nd in 2024: identical y.
|
|
256
|
+
expect(lastY('A')).toBeCloseTo(lastY('B'), 6);
|
|
257
|
+
// C is 1st, above the tied pair (smaller y = higher rank).
|
|
258
|
+
expect(lastY('C')).toBeLessThan(lastY('A'));
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -62,15 +62,21 @@ describe('computeScales', () => {
|
|
|
62
62
|
expect(scales.y).toBeDefined();
|
|
63
63
|
expect(scales.y!.type).toBe('linear');
|
|
64
64
|
|
|
65
|
-
// Y is inverted (SVG coordinates)
|
|
65
|
+
// Y is inverted (SVG coordinates); line charts default to zero: false
|
|
66
66
|
const domain = scales.y!.scale.domain();
|
|
67
|
-
expect(domain[0]).toBeLessThanOrEqual(
|
|
67
|
+
expect(domain[0]).toBeLessThanOrEqual(10);
|
|
68
68
|
expect(domain[1]).toBeGreaterThanOrEqual(50);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it('
|
|
71
|
+
it('line charts exclude zero by default (zero: false)', () => {
|
|
72
72
|
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
73
73
|
const domain = scales.y!.scale.domain();
|
|
74
|
+
expect(domain[0]).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('bar charts include zero by default', () => {
|
|
78
|
+
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
79
|
+
const domain = scales.x!.scale.domain();
|
|
74
80
|
expect(domain[0]).toBe(0);
|
|
75
81
|
});
|
|
76
82
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* seriesSearch compilation: band reservation and resolved content.
|
|
3
|
+
*
|
|
4
|
+
* The engine reserves an empty SVG band below chrome (and the metric bar)
|
|
5
|
+
* and exposes its rect + the searchable values on layout.seriesSearch; the
|
|
6
|
+
* vanilla adapter overlays the DOM combobox on that rect.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { compileChart } from '../compile';
|
|
11
|
+
|
|
12
|
+
const searchSpec = {
|
|
13
|
+
mark: 'line' as const,
|
|
14
|
+
data: [
|
|
15
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
16
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
17
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
18
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
19
|
+
{ date: '2020-01-01', value: 22, country: 'France' },
|
|
20
|
+
{ date: '2021-01-01', value: 28, country: 'France' },
|
|
21
|
+
],
|
|
22
|
+
encoding: {
|
|
23
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
24
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
25
|
+
color: { field: 'country', type: 'nominal' as const },
|
|
26
|
+
},
|
|
27
|
+
chrome: { title: 'Find your country' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('compileChart seriesSearch', () => {
|
|
31
|
+
it('omits the band when seriesSearch is not set', () => {
|
|
32
|
+
const layout = compileChart(searchSpec, { width: 600, height: 400 });
|
|
33
|
+
expect(layout.seriesSearch).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('reserves a band below chrome and shifts the chart area down', () => {
|
|
37
|
+
const base = compileChart(searchSpec, { width: 600, height: 400 });
|
|
38
|
+
const layout = compileChart({ ...searchSpec, seriesSearch: true }, { width: 600, height: 400 });
|
|
39
|
+
|
|
40
|
+
const band = layout.seriesSearch;
|
|
41
|
+
expect(band).toBeDefined();
|
|
42
|
+
expect(band!.height).toBeGreaterThan(0);
|
|
43
|
+
expect(band!.width).toBeGreaterThan(0);
|
|
44
|
+
// Band sits below the top chrome block and above the chart area
|
|
45
|
+
expect(band!.y).toBeGreaterThanOrEqual(layout.chrome.topHeight);
|
|
46
|
+
expect(band!.y + band!.height).toBeLessThanOrEqual(layout.area.y);
|
|
47
|
+
// The reservation pushes the plot down relative to the no-search layout
|
|
48
|
+
expect(layout.area.y).toBeGreaterThan(base.area.y);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('exposes the distinct color values and resolved placeholder', () => {
|
|
52
|
+
const layout = compileChart(
|
|
53
|
+
{ ...searchSpec, seriesSearch: { placeholder: 'Find a country' } },
|
|
54
|
+
{ width: 600, height: 400 },
|
|
55
|
+
);
|
|
56
|
+
expect(layout.seriesSearch?.values).toEqual(['US', 'UK', 'France']);
|
|
57
|
+
expect(layout.seriesSearch?.placeholder).toBe('Find a country');
|
|
58
|
+
|
|
59
|
+
const defaulted = compileChart(
|
|
60
|
+
{ ...searchSpec, seriesSearch: true },
|
|
61
|
+
{ width: 600, height: 400 },
|
|
62
|
+
);
|
|
63
|
+
expect(defaulted.seriesSearch?.placeholder).toBe('Find a series');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('warns and disables when there is no categorical color encoding', () => {
|
|
67
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
68
|
+
try {
|
|
69
|
+
const layout = compileChart(
|
|
70
|
+
{
|
|
71
|
+
mark: 'line' as const,
|
|
72
|
+
data: [
|
|
73
|
+
{ date: '2020-01-01', value: 10 },
|
|
74
|
+
{ date: '2021-01-01', value: 40 },
|
|
75
|
+
],
|
|
76
|
+
encoding: {
|
|
77
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
78
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
79
|
+
},
|
|
80
|
+
seriesSearch: true,
|
|
81
|
+
},
|
|
82
|
+
{ width: 600, height: 400 },
|
|
83
|
+
);
|
|
84
|
+
expect(layout.seriesSearch).toBeUndefined();
|
|
85
|
+
expect(warn).toHaveBeenCalledWith(
|
|
86
|
+
expect.stringContaining('seriesSearch requires a categorical color encoding'),
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
warn.mockRestore();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|