@opendata-ai/openchart-engine 6.19.3 → 6.21.0
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 +6 -0
- package/dist/index.js +865 -3729
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
- package/src/__tests__/axes.test.ts +65 -0
- package/src/__tests__/compile-snapshot.test.ts +156 -0
- package/src/__tests__/legend.test.ts +39 -0
- package/src/charts/__tests__/registry.test.ts +6 -0
- package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
- package/src/charts/_shared/density-filter.ts +26 -0
- package/src/charts/_shared/format-label-value.ts +15 -0
- package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
- package/src/charts/bar/compute.ts +6 -11
- package/src/charts/bar/labels.ts +4 -15
- package/src/charts/builtin.ts +64 -0
- package/src/charts/column/compute.ts +6 -11
- package/src/charts/column/labels.ts +4 -19
- package/src/charts/dot/labels.ts +4 -19
- package/src/charts/pie/labels.ts +4 -6
- package/src/charts/registry.ts +6 -0
- package/src/compile/__tests__/color-scale-range.test.ts +79 -0
- package/src/compile/__tests__/data-clip.test.ts +59 -0
- package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
- package/src/compile/color-scale-range.ts +38 -0
- package/src/compile/data-clip.ts +33 -0
- package/src/compile/watermark-obstacle.ts +54 -0
- package/src/compile.ts +20 -97
- package/src/layout/axes/thinning.ts +96 -0
- package/src/layout/axes/ticks.ts +266 -0
- package/src/layout/axes.ts +148 -249
- package/src/legend/compute.ts +6 -51
- package/src/legend/wrap.ts +94 -0
- package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
- package/src/sankey/__tests__/node-sort.test.ts +45 -0
- package/src/sankey/compile-sankey.ts +5 -20
|
@@ -679,6 +679,71 @@ describe('y-axis tick density', () => {
|
|
|
679
679
|
// Wide label text shouldn't cause y-axis thinning (only height matters)
|
|
680
680
|
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
|
|
681
681
|
});
|
|
682
|
+
|
|
683
|
+
it('does not collapse to min/max when nice() domain creates a close last tick', () => {
|
|
684
|
+
// Regression: domain [0, 340] nice()'s to [0, 350]. The old thinning path
|
|
685
|
+
// kept both 340 and the endpoint 350, causing cascaded thinning down to
|
|
686
|
+
// [0, 340] (2 ticks). Fix re-requests at lower counts from D3 instead.
|
|
687
|
+
const barSpec: NormalizedChartSpec = {
|
|
688
|
+
markType: 'bar',
|
|
689
|
+
markDef: { type: 'bar' },
|
|
690
|
+
data: [
|
|
691
|
+
{ year: '2019', v: 110 },
|
|
692
|
+
{ year: '2023', v: 340 },
|
|
693
|
+
],
|
|
694
|
+
encoding: {
|
|
695
|
+
x: { field: 'year', type: 'nominal' },
|
|
696
|
+
y: { field: 'v', type: 'quantitative' },
|
|
697
|
+
},
|
|
698
|
+
chrome: {},
|
|
699
|
+
annotations: [],
|
|
700
|
+
responsive: true,
|
|
701
|
+
theme: {},
|
|
702
|
+
darkMode: 'off',
|
|
703
|
+
labels: { density: 'auto', format: '' },
|
|
704
|
+
};
|
|
705
|
+
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
706
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
707
|
+
|
|
708
|
+
// The specific bug produced exactly [0, 340] — only the min and max with
|
|
709
|
+
// no interior gridlines. Any output with interior values is a pass, even
|
|
710
|
+
// if the count varies with D3's step choice across platforms.
|
|
711
|
+
const values = axes.y!.ticks.map((t) => t.value);
|
|
712
|
+
expect(values).not.toEqual([0, 340]);
|
|
713
|
+
expect(values).not.toEqual([0, 350]);
|
|
714
|
+
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(4);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('adapts y-axis tick count down for short charts', () => {
|
|
718
|
+
const shortArea = { x: 50, y: 50, width: 500, height: 140 };
|
|
719
|
+
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
720
|
+
const axes = computeAxes(scales, shortArea, fullStrategy, theme);
|
|
721
|
+
|
|
722
|
+
// Short charts should still show multiple ticks but fewer than tall ones
|
|
723
|
+
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(2);
|
|
724
|
+
expect(axes.y!.ticks.length).toBeLessThan(10);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('steps continuous x-axis down when D3 overshoots on narrow temporal scales', () => {
|
|
728
|
+
// D3 time scales jump between calendar units — a request for 6 ticks on
|
|
729
|
+
// a 3-year range can return 4 (yearly) or 14 (quarterly). On a narrow
|
|
730
|
+
// chart we want the sparser choice, not the dense one.
|
|
731
|
+
const narrowTimeSpec: NormalizedChartSpec = {
|
|
732
|
+
...lineSpec,
|
|
733
|
+
data: [
|
|
734
|
+
{ date: '2022-01-01', value: 10 },
|
|
735
|
+
{ date: '2022-12-01', value: 40 },
|
|
736
|
+
],
|
|
737
|
+
};
|
|
738
|
+
const narrowArea = { x: 50, y: 50, width: 300, height: 300 };
|
|
739
|
+
const scales = computeScales(narrowTimeSpec, narrowArea, narrowTimeSpec.data);
|
|
740
|
+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
741
|
+
|
|
742
|
+
// A 300px-wide time axis should show at most ~6 labels, not the 10-14
|
|
743
|
+
// that D3 produces when its nice() step hops into monthly territory.
|
|
744
|
+
expect(axes.x!.ticks.length).toBeLessThanOrEqual(6);
|
|
745
|
+
expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
|
|
746
|
+
});
|
|
682
747
|
});
|
|
683
748
|
|
|
684
749
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration snapshot test: the backstop for Step 7 refactor.
|
|
3
|
+
*
|
|
4
|
+
* Compiles three representative ChartSpecs that exercise the extracted paths
|
|
5
|
+
* (legend-heavy, clipped-domain, watermark + gradient) and snapshots the full
|
|
6
|
+
* ChartLayout. Pre-refactor snapshots are captured before helper extraction;
|
|
7
|
+
* post-refactor snapshots must match exactly without --update-snapshots.
|
|
8
|
+
*
|
|
9
|
+
* Non-serializable fields (Map, Function) are normalized into plain structures
|
|
10
|
+
* so the snapshot can round-trip through the default serializer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { compileChart } from '../compile';
|
|
16
|
+
|
|
17
|
+
type AxisTick = { label?: string; value?: unknown; position?: unknown };
|
|
18
|
+
type AxisShape = Record<string, unknown> & { ticks?: AxisTick[] };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether an axis carries temporal (Date) ticks. D3 time scales produce
|
|
22
|
+
* midnight-local Date objects, so tick positions and values shift by hours
|
|
23
|
+
* between macOS (CDT/PDT) and Linux CI (UTC). We strip temporal axes from
|
|
24
|
+
* the main snapshot and assert their labels separately.
|
|
25
|
+
*/
|
|
26
|
+
function isTemporalAxis(axis: AxisShape | undefined): boolean {
|
|
27
|
+
return Array.isArray(axis?.ticks) && axis.ticks.some((t) => t.value instanceof Date);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Convert ChartLayout into a fully serializable shape for snapshot comparison. */
|
|
31
|
+
function serializeLayout(
|
|
32
|
+
layout: ChartLayout,
|
|
33
|
+
{ stripTemporalAxes = false } = {},
|
|
34
|
+
): Record<string, unknown> {
|
|
35
|
+
const { tooltipDescriptors, measureText: _measure, ...rest } = layout;
|
|
36
|
+
const axes = rest.axes as { x?: AxisShape; y?: AxisShape } | undefined;
|
|
37
|
+
|
|
38
|
+
let serializedAxes = axes;
|
|
39
|
+
if (axes && stripTemporalAxes) {
|
|
40
|
+
serializedAxes = {
|
|
41
|
+
...axes,
|
|
42
|
+
x: isTemporalAxis(axes.x) ? undefined : axes.x,
|
|
43
|
+
y: isTemporalAxis(axes.y) ? undefined : axes.y,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
...rest,
|
|
49
|
+
axes: serializedAxes,
|
|
50
|
+
tooltipDescriptors: Array.from(tooltipDescriptors.entries()),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('compileChart snapshot (Step 7 oracle)', () => {
|
|
55
|
+
it('legend-heavy multi-series line chart', () => {
|
|
56
|
+
const spec = {
|
|
57
|
+
mark: 'line' as const,
|
|
58
|
+
data: [
|
|
59
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
60
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
61
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
62
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
63
|
+
{ date: '2020-01-01', value: 8, country: 'FR' },
|
|
64
|
+
{ date: '2021-01-01', value: 22, country: 'FR' },
|
|
65
|
+
{ date: '2020-01-01', value: 12, country: 'DE' },
|
|
66
|
+
{ date: '2021-01-01', value: 28, country: 'DE' },
|
|
67
|
+
],
|
|
68
|
+
encoding: {
|
|
69
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
70
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
71
|
+
color: { field: 'country', type: 'nominal' as const },
|
|
72
|
+
},
|
|
73
|
+
chrome: {
|
|
74
|
+
title: 'GDP Growth',
|
|
75
|
+
subtitle: 'Four-country comparison',
|
|
76
|
+
source: 'World Bank',
|
|
77
|
+
},
|
|
78
|
+
legend: { show: true },
|
|
79
|
+
watermark: false,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const layout = compileChart(spec, { width: 800, height: 500 });
|
|
83
|
+
|
|
84
|
+
// Temporal x-axis positions shift with timezone (macOS CDT vs Linux UTC),
|
|
85
|
+
// so we strip it from the structural snapshot and assert just the labels.
|
|
86
|
+
expect(serializeLayout(layout, { stripTemporalAxes: true })).toMatchSnapshot();
|
|
87
|
+
|
|
88
|
+
const xAxes = (layout.axes as { x?: AxisShape } | undefined)?.x;
|
|
89
|
+
const tickLabels = (xAxes?.ticks ?? []).map((t: AxisTick) => t.label);
|
|
90
|
+
expect(tickLabels.length).toBeGreaterThanOrEqual(3);
|
|
91
|
+
expect(tickLabels.length).toBeLessThanOrEqual(6);
|
|
92
|
+
expect(tickLabels.every((l) => typeof l === 'string' && l.length > 0)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('clipped-domain bar chart (data outside scale.domain filtered)', () => {
|
|
96
|
+
const spec = {
|
|
97
|
+
mark: 'bar' as const,
|
|
98
|
+
data: [
|
|
99
|
+
{ name: 'A', value: 10 },
|
|
100
|
+
{ name: 'B', value: 25 },
|
|
101
|
+
{ name: 'C', value: 40 },
|
|
102
|
+
{ name: 'D', value: 75 },
|
|
103
|
+
{ name: 'E', value: 90 },
|
|
104
|
+
],
|
|
105
|
+
encoding: {
|
|
106
|
+
x: {
|
|
107
|
+
field: 'value',
|
|
108
|
+
type: 'quantitative' as const,
|
|
109
|
+
scale: { domain: [0, 50], clip: true },
|
|
110
|
+
},
|
|
111
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
112
|
+
},
|
|
113
|
+
chrome: {
|
|
114
|
+
title: 'Clipped Values',
|
|
115
|
+
},
|
|
116
|
+
watermark: false,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
120
|
+
expect(serializeLayout(layout)).toMatchSnapshot();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('watermarked column chart with gradient fill', () => {
|
|
124
|
+
const spec = {
|
|
125
|
+
mark: {
|
|
126
|
+
type: 'bar' as const,
|
|
127
|
+
fill: {
|
|
128
|
+
type: 'linear' as const,
|
|
129
|
+
stops: [
|
|
130
|
+
{ offset: 0, color: '#ff0000' },
|
|
131
|
+
{ offset: 1, color: '#0000ff' },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
data: [
|
|
136
|
+
{ category: 'A', score: 30 },
|
|
137
|
+
{ category: 'B', score: 55 },
|
|
138
|
+
{ category: 'C', score: 70 },
|
|
139
|
+
{ category: 'D', score: 45 },
|
|
140
|
+
],
|
|
141
|
+
encoding: {
|
|
142
|
+
x: { field: 'category', type: 'nominal' as const },
|
|
143
|
+
y: { field: 'score', type: 'quantitative' as const },
|
|
144
|
+
},
|
|
145
|
+
chrome: {
|
|
146
|
+
title: 'Gradient Columns',
|
|
147
|
+
source: 'Test data',
|
|
148
|
+
byline: 'By the engine',
|
|
149
|
+
},
|
|
150
|
+
watermark: true,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
154
|
+
expect(serializeLayout(layout)).toMatchSnapshot();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LayoutStrategy, Rect, ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { compileChart } from '../compile';
|
|
4
5
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
5
6
|
import { computeLegend } from '../legend/compute';
|
|
6
7
|
|
|
@@ -393,4 +394,42 @@ describe('computeLegend', () => {
|
|
|
393
394
|
expect(legend.bounds.width).toBe(0);
|
|
394
395
|
});
|
|
395
396
|
});
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Characterization test (refactor/v7-cohesion step 1):
|
|
400
|
+
// Pins the 4px gap between a top-positioned legend and the chart area,
|
|
401
|
+
// as enforced at packages/engine/src/compile.ts:331. Refactor step 4 will
|
|
402
|
+
// consolidate legend row-wrapping geometry; this test guards the spacing
|
|
403
|
+
// invariant through that change.
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
describe('top legend spacing', () => {
|
|
406
|
+
it('places the legend exactly 4px above the chart area', () => {
|
|
407
|
+
const spec = {
|
|
408
|
+
mark: 'bar' as const,
|
|
409
|
+
data: [
|
|
410
|
+
{ name: 'A', value: 10, group: 'X' },
|
|
411
|
+
{ name: 'A', value: 20, group: 'Y' },
|
|
412
|
+
{ name: 'B', value: 30, group: 'X' },
|
|
413
|
+
{ name: 'B', value: 25, group: 'Y' },
|
|
414
|
+
],
|
|
415
|
+
encoding: {
|
|
416
|
+
x: { field: 'name', type: 'nominal' as const },
|
|
417
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
418
|
+
color: { field: 'group', type: 'nominal' as const },
|
|
419
|
+
},
|
|
420
|
+
legend: { position: 'top' as const },
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
424
|
+
|
|
425
|
+
expect(layout.legend.position).toBe('top');
|
|
426
|
+
expect(layout.legend.entries.length).toBeGreaterThan(0);
|
|
427
|
+
expect(layout.legend.bounds.height).toBeGreaterThan(0);
|
|
428
|
+
|
|
429
|
+
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
|
|
430
|
+
const gap = layout.area.y - legendBottom;
|
|
431
|
+
// Pin value matches the literal at compile.ts:331 (legendArea.y -= legendLayout.bounds.height + 4)
|
|
432
|
+
expect(gap).toBe(4);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
396
435
|
});
|
|
@@ -2,6 +2,7 @@ import type { LayoutStrategy, Mark, Rect } from '@opendata-ai/openchart-core';
|
|
|
2
2
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
3
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
4
4
|
import type { ResolvedScales } from '../../layout/scales';
|
|
5
|
+
import { registerBuiltinRenderers } from '../builtin';
|
|
5
6
|
import { clearRenderers, getChartRenderer, registerChartRenderer } from '../registry';
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -38,7 +39,12 @@ function stubRenderer(
|
|
|
38
39
|
|
|
39
40
|
describe('chart renderer registry', () => {
|
|
40
41
|
afterEach(() => {
|
|
42
|
+
// Clear test-registered renderers AND restore the built-in set. The
|
|
43
|
+
// built-ins register as a module side-effect of importing `compile.ts`,
|
|
44
|
+
// so once cleared they don't come back on their own — leaving later
|
|
45
|
+
// tests in this suite with an empty registry.
|
|
41
46
|
clearRenderers();
|
|
47
|
+
registerBuiltinRenderers();
|
|
42
48
|
});
|
|
43
49
|
|
|
44
50
|
it('returns undefined for an unregistered chart type', () => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { filterByDensity } from '../density-filter';
|
|
3
|
+
|
|
4
|
+
describe('filterByDensity', () => {
|
|
5
|
+
const marks = ['a', 'b', 'c', 'd'];
|
|
6
|
+
|
|
7
|
+
it("returns [] for 'none'", () => {
|
|
8
|
+
expect(filterByDensity(marks, 'none')).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns first + last for 'endpoints'", () => {
|
|
12
|
+
expect(filterByDensity(marks, 'endpoints')).toEqual(['a', 'd']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns marks unchanged for 'all'", () => {
|
|
16
|
+
expect(filterByDensity(marks, 'all')).toBe(marks);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns marks unchanged for 'auto'", () => {
|
|
20
|
+
expect(filterByDensity(marks, 'auto')).toBe(marks);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns single-element array unchanged for 'endpoints'", () => {
|
|
24
|
+
const single = ['only'];
|
|
25
|
+
expect(filterByDensity(single, 'endpoints')).toBe(single);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns empty array unchanged for 'endpoints'", () => {
|
|
29
|
+
const empty: string[] = [];
|
|
30
|
+
expect(filterByDensity(empty, 'endpoints')).toBe(empty);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared density filter for data labels.
|
|
3
|
+
*
|
|
4
|
+
* Maps a `LabelDensity` setting to the subset of marks eligible for
|
|
5
|
+
* labeling. The `'auto'` branch is a pass-through — auto resolution
|
|
6
|
+
* happens upstream in the per-chart label modules (typically via
|
|
7
|
+
* collision detection on the full candidate set).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LabelDensity } from '@opendata-ai/openchart-core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Filter a mark array by label density.
|
|
14
|
+
*
|
|
15
|
+
* - `'none'` returns `[]` (no labels)
|
|
16
|
+
* - `'endpoints'` returns first + last marks when `marks.length > 1`,
|
|
17
|
+
* otherwise the input unchanged (preserves single-element arrays as-is)
|
|
18
|
+
* - `'all'` and `'auto'` return the input unchanged
|
|
19
|
+
*/
|
|
20
|
+
export function filterByDensity<T>(marks: T[], density: LabelDensity): T[] {
|
|
21
|
+
if (density === 'none') return [];
|
|
22
|
+
if (density === 'endpoints' && marks.length > 1) {
|
|
23
|
+
return [marks[0], marks[marks.length - 1]];
|
|
24
|
+
}
|
|
25
|
+
return marks;
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared numeric value formatter for data labels.
|
|
3
|
+
*
|
|
4
|
+
* Used by bar, column, and dot label computation to display a value:
|
|
5
|
+
* abbreviated (K/M/B/T) for magnitudes >= 1000, otherwise the default
|
|
6
|
+
* numeric format.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
/** Format a label value for display (abbreviate large numbers). */
|
|
12
|
+
export function formatLabelValue(value: number): string {
|
|
13
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
14
|
+
return formatNumber(value);
|
|
15
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Characterization tests for horizontal-bar gradient auto-orientation.
|
|
3
|
+
*
|
|
4
|
+
* Part of refactor/v7-cohesion step 1. Pins the behavior of
|
|
5
|
+
* `orientGradientForHorizontalBar` in `packages/engine/src/charts/bar/compute.ts`:
|
|
6
|
+
* when a user supplies the default vertical gradient (top-to-bottom) as a
|
|
7
|
+
* horizontal-bar fill, the engine rotates it to horizontal (left-to-right) so
|
|
8
|
+
* the gradient follows the bar's data direction. Vertical bars (columns) keep
|
|
9
|
+
* the gradient unchanged. Explicit non-default coordinates are never rewritten.
|
|
10
|
+
*
|
|
11
|
+
* These tests protect the behavior through upcoming refactors that may consolidate
|
|
12
|
+
* gradient logic across bar/column/stacked variants.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChartSpec, LinearGradient, RectMark } from '@opendata-ai/openchart-core';
|
|
16
|
+
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
import { compileChart } from '../../../compile';
|
|
19
|
+
|
|
20
|
+
// A default-vertical linear gradient (no explicit coords — defaults resolve to
|
|
21
|
+
// x1:0, y1:0, x2:0, y2:1 which is the "top-to-bottom" default).
|
|
22
|
+
const defaultVerticalGradient: LinearGradient = {
|
|
23
|
+
gradient: 'linear',
|
|
24
|
+
stops: [
|
|
25
|
+
{ offset: 0, color: '#1b7fa3', opacity: 0.4 },
|
|
26
|
+
{ offset: 1, color: '#1b7fa3' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function firstGradientRectFill(marks: RectMark[]): LinearGradient {
|
|
31
|
+
const mark = marks.find((m) => m.type === 'rect' && isGradientDef(m.fill));
|
|
32
|
+
if (!mark) throw new Error('expected at least one RectMark with a gradient fill');
|
|
33
|
+
const fill = mark.fill;
|
|
34
|
+
if (!isGradientDef(fill) || fill.gradient !== 'linear') {
|
|
35
|
+
throw new Error('expected a linear gradient fill');
|
|
36
|
+
}
|
|
37
|
+
return fill as LinearGradient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('horizontal bar gradient auto-orientation', () => {
|
|
41
|
+
it('rotates the default vertical gradient to horizontal on horizontal bars', () => {
|
|
42
|
+
const spec: ChartSpec = {
|
|
43
|
+
mark: { type: 'bar', fill: defaultVerticalGradient },
|
|
44
|
+
data: [
|
|
45
|
+
{ category: 'A', value: 10 },
|
|
46
|
+
{ category: 'B', value: 20 },
|
|
47
|
+
{ category: 'C', value: 30 },
|
|
48
|
+
],
|
|
49
|
+
// Horizontal bar: x is quantitative, y is nominal
|
|
50
|
+
encoding: {
|
|
51
|
+
x: { field: 'value', type: 'quantitative' },
|
|
52
|
+
y: { field: 'category', type: 'nominal' },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
57
|
+
const grad = firstGradientRectFill(layout.marks as RectMark[]);
|
|
58
|
+
|
|
59
|
+
// Horizontal gradient: x1 != x2, y1 == y2
|
|
60
|
+
expect(grad.x1).toBe(0);
|
|
61
|
+
expect(grad.y1).toBe(0);
|
|
62
|
+
expect(grad.x2).toBe(1);
|
|
63
|
+
expect(grad.y2).toBe(0);
|
|
64
|
+
expect(grad.x1).not.toBe(grad.x2);
|
|
65
|
+
expect(grad.y1).toBe(grad.y2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('leaves the default vertical gradient unchanged on vertical (column) bars', () => {
|
|
69
|
+
const spec: ChartSpec = {
|
|
70
|
+
mark: { type: 'bar', fill: defaultVerticalGradient },
|
|
71
|
+
data: [
|
|
72
|
+
{ category: 'A', value: 10 },
|
|
73
|
+
{ category: 'B', value: 20 },
|
|
74
|
+
{ category: 'C', value: 30 },
|
|
75
|
+
],
|
|
76
|
+
// Vertical bar (column): x is nominal, y is quantitative
|
|
77
|
+
encoding: {
|
|
78
|
+
x: { field: 'category', type: 'nominal' },
|
|
79
|
+
y: { field: 'value', type: 'quantitative' },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
84
|
+
const grad = firstGradientRectFill(layout.marks as RectMark[]);
|
|
85
|
+
|
|
86
|
+
// Vertical gradient preserved: x1 == x2 (both 0), y1 != y2 (0 vs 1)
|
|
87
|
+
expect(grad.x1 ?? 0).toBe(0);
|
|
88
|
+
expect(grad.x2 ?? 0).toBe(0);
|
|
89
|
+
expect(grad.y1 ?? 0).toBe(0);
|
|
90
|
+
expect(grad.y2 ?? 1).toBe(1);
|
|
91
|
+
expect(grad.x1 ?? 0).toBe(grad.x2 ?? 0);
|
|
92
|
+
expect(grad.y1 ?? 0).not.toBe(grad.y2 ?? 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('leaves an explicitly-oriented gradient unchanged on horizontal bars', () => {
|
|
96
|
+
// Explicit non-default direction — user knows what they want, the engine
|
|
97
|
+
// must not rewrite it. Here we pass a diagonal gradient.
|
|
98
|
+
const explicitDiagonal: LinearGradient = {
|
|
99
|
+
gradient: 'linear',
|
|
100
|
+
x1: 0,
|
|
101
|
+
y1: 0,
|
|
102
|
+
x2: 1,
|
|
103
|
+
y2: 1,
|
|
104
|
+
stops: [
|
|
105
|
+
{ offset: 0, color: '#1b7fa3' },
|
|
106
|
+
{ offset: 1, color: '#ff6600' },
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const spec: ChartSpec = {
|
|
111
|
+
mark: { type: 'bar', fill: explicitDiagonal },
|
|
112
|
+
data: [{ category: 'A', value: 10 }],
|
|
113
|
+
encoding: {
|
|
114
|
+
x: { field: 'value', type: 'quantitative' },
|
|
115
|
+
y: { field: 'category', type: 'nominal' },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
120
|
+
const grad = firstGradientRectFill(layout.marks as RectMark[]);
|
|
121
|
+
|
|
122
|
+
expect(grad.x1).toBe(0);
|
|
123
|
+
expect(grad.y1).toBe(0);
|
|
124
|
+
expect(grad.x2).toBe(1);
|
|
125
|
+
expect(grad.y2).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -17,11 +17,12 @@ import type {
|
|
|
17
17
|
Rect,
|
|
18
18
|
RectMark,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import {
|
|
20
|
+
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
21
21
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
22
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
23
23
|
import type { ResolvedScales } from '../../layout/scales';
|
|
24
24
|
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
25
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
25
26
|
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -53,12 +54,6 @@ function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
|
|
|
53
54
|
|
|
54
55
|
const MIN_BAR_WIDTH = 1;
|
|
55
56
|
|
|
56
|
-
/** Format a bar value for display (abbreviate large numbers). */
|
|
57
|
-
function formatBarValue(value: number): string {
|
|
58
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
59
|
-
return formatNumber(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
57
|
// ---------------------------------------------------------------------------
|
|
63
58
|
// Public API
|
|
64
59
|
// ---------------------------------------------------------------------------
|
|
@@ -221,7 +216,7 @@ function computeStackedBars(
|
|
|
221
216
|
const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
|
|
222
217
|
|
|
223
218
|
const aria: MarkAria = {
|
|
224
|
-
label: `${category}, ${groupKey}: ${
|
|
219
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(rawValue)}`,
|
|
225
220
|
};
|
|
226
221
|
|
|
227
222
|
marks.push({
|
|
@@ -291,7 +286,7 @@ function computeGroupedBars(
|
|
|
291
286
|
const subY = bandY + groupIndex * (subBandHeight + gap);
|
|
292
287
|
|
|
293
288
|
const aria: MarkAria = {
|
|
294
|
-
label: `${category}, ${groupKey}: ${
|
|
289
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
|
295
290
|
};
|
|
296
291
|
|
|
297
292
|
marks.push({
|
|
@@ -341,7 +336,7 @@ function computeColoredBars(
|
|
|
341
336
|
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
342
337
|
|
|
343
338
|
const aria: MarkAria = {
|
|
344
|
-
label: `${category}, ${groupKey}: ${
|
|
339
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
|
345
340
|
};
|
|
346
341
|
|
|
347
342
|
marks.push({
|
|
@@ -401,7 +396,7 @@ function computeSimpleBars(
|
|
|
401
396
|
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
402
397
|
|
|
403
398
|
const aria: MarkAria = {
|
|
404
|
-
label: `${category}: ${
|
|
399
|
+
label: `${category}: ${formatLabelValue(value)}`,
|
|
405
400
|
};
|
|
406
401
|
|
|
407
402
|
marks.push({
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -18,24 +18,18 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
-
abbreviateNumber,
|
|
22
21
|
buildD3Formatter,
|
|
23
22
|
estimateTextWidth,
|
|
24
|
-
formatNumber,
|
|
25
23
|
getRepresentativeColor,
|
|
26
24
|
resolveCollisions,
|
|
27
25
|
} from '@opendata-ai/openchart-core';
|
|
26
|
+
import { filterByDensity } from '../_shared/density-filter';
|
|
27
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
28
28
|
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Helpers
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
|
|
33
|
-
/** Format a bar value for display (abbreviate large numbers). */
|
|
34
|
-
function formatBarValue(value: number): string {
|
|
35
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
-
return formatNumber(value);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
33
|
/** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
|
|
40
34
|
const SUFFIX_MULTIPLIERS: Record<string, number> = {
|
|
41
35
|
K: 1_000,
|
|
@@ -100,12 +94,7 @@ export function computeBarLabels(
|
|
|
100
94
|
labelPrefix?: string,
|
|
101
95
|
valueField?: string,
|
|
102
96
|
): ResolvedLabel[] {
|
|
103
|
-
|
|
104
|
-
if (density === 'none') return [];
|
|
105
|
-
|
|
106
|
-
// Filter marks for 'endpoints' density
|
|
107
|
-
const targetMarks =
|
|
108
|
-
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
97
|
+
const targetMarks = filterByDensity(marks, density);
|
|
109
98
|
|
|
110
99
|
const candidates: LabelCandidate[] = [];
|
|
111
100
|
// Track whether each candidate fits within its stacked segment.
|
|
@@ -124,7 +113,7 @@ export function computeBarLabels(
|
|
|
124
113
|
if (formatter && Number.isFinite(rawNum)) {
|
|
125
114
|
valuePart = formatter(rawNum);
|
|
126
115
|
} else if (Number.isFinite(rawNum)) {
|
|
127
|
-
valuePart =
|
|
116
|
+
valuePart = formatLabelValue(rawNum);
|
|
128
117
|
} else {
|
|
129
118
|
// Fallback: extract from aria label
|
|
130
119
|
const ariaLabel = mark.aria.label;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in chart renderer registration.
|
|
3
|
+
*
|
|
4
|
+
* Each chart type (line, bar, column, scatter, pie, etc.) has a renderer that
|
|
5
|
+
* produces marks from a normalized spec. This module wires them into the
|
|
6
|
+
* registry. Importing it is a side effect: the built-ins register as soon as
|
|
7
|
+
* it's loaded.
|
|
8
|
+
*
|
|
9
|
+
* Tests that call `clearRenderers()` can call `registerBuiltinRenderers()` to
|
|
10
|
+
* restore the default set rather than leaving the registry empty for later
|
|
11
|
+
* tests in the same process.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { barRenderer } from './bar';
|
|
15
|
+
import { columnRenderer } from './column';
|
|
16
|
+
import { dotRenderer } from './dot';
|
|
17
|
+
import { areaRenderer, lineRenderer } from './line';
|
|
18
|
+
import { donutRenderer, pieRenderer } from './pie';
|
|
19
|
+
import { type ChartRenderer, registerChartRenderer } from './registry';
|
|
20
|
+
import { ruleRenderer } from './rule';
|
|
21
|
+
import { scatterRenderer } from './scatter';
|
|
22
|
+
import { textRenderer } from './text';
|
|
23
|
+
import { tickRenderer } from './tick';
|
|
24
|
+
|
|
25
|
+
// Mark type mapping from old chart types:
|
|
26
|
+
// - 'bar' -> barRenderer (horizontal bars, old 'bar')
|
|
27
|
+
// - 'bar:vertical' is handled by columnRenderer (old 'column')
|
|
28
|
+
// - 'arc' -> pieRenderer (old 'pie'); donutRenderer is also registered
|
|
29
|
+
// - 'point' -> scatterRenderer (old 'scatter')
|
|
30
|
+
// - 'circle' -> dotRenderer (old 'dot')
|
|
31
|
+
// - 'line' and 'area' unchanged
|
|
32
|
+
// - 'text', 'rule', 'tick' are new Vega-Lite mark types
|
|
33
|
+
//
|
|
34
|
+
// For 'bar', orientation is resolved at compile time to dispatch to the right
|
|
35
|
+
// renderer. We register both barRenderer and columnRenderer; the compile
|
|
36
|
+
// function picks based on orientation.
|
|
37
|
+
const builtinRenderers: Record<string, ChartRenderer> = {
|
|
38
|
+
line: lineRenderer,
|
|
39
|
+
area: areaRenderer,
|
|
40
|
+
bar: barRenderer, // horizontal bars
|
|
41
|
+
'bar:vertical': columnRenderer, // vertical bars (old 'column')
|
|
42
|
+
point: scatterRenderer, // old 'scatter'
|
|
43
|
+
arc: pieRenderer, // old 'pie' (donut handled via innerRadius)
|
|
44
|
+
'arc:donut': donutRenderer, // old 'donut'
|
|
45
|
+
circle: dotRenderer, // old 'dot'
|
|
46
|
+
lollipop: dotRenderer, // semantic alias for dot/circle
|
|
47
|
+
text: textRenderer,
|
|
48
|
+
rule: ruleRenderer,
|
|
49
|
+
tick: tickRenderer,
|
|
50
|
+
rect: columnRenderer, // rect uses column renderer (RectMark output) as baseline for heatmaps
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register all built-in chart renderers. Called once on module load; also
|
|
55
|
+
* exported so tests that clear the registry can restore the defaults.
|
|
56
|
+
*/
|
|
57
|
+
export function registerBuiltinRenderers(): void {
|
|
58
|
+
for (const [type, renderer] of Object.entries(builtinRenderers)) {
|
|
59
|
+
registerChartRenderer(type, renderer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Side effect: register on first import.
|
|
64
|
+
registerBuiltinRenderers();
|