@opendata-ai/openchart-engine 6.20.0 → 6.22.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 +756 -3592
- 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/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/bar/labels.ts +2 -6
- package/src/charts/builtin.ts +64 -0
- package/src/charts/column/labels.ts +2 -6
- package/src/charts/dot/labels.ts +2 -6
- 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
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getRepresentativeColor,
|
|
24
24
|
resolveCollisions,
|
|
25
25
|
} from '@opendata-ai/openchart-core';
|
|
26
|
+
import { filterByDensity } from '../_shared/density-filter';
|
|
26
27
|
import { formatLabelValue } from '../_shared/format-label-value';
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -93,12 +94,7 @@ export function computeBarLabels(
|
|
|
93
94
|
labelPrefix?: string,
|
|
94
95
|
valueField?: string,
|
|
95
96
|
): ResolvedLabel[] {
|
|
96
|
-
|
|
97
|
-
if (density === 'none') return [];
|
|
98
|
-
|
|
99
|
-
// Filter marks for 'endpoints' density
|
|
100
|
-
const targetMarks =
|
|
101
|
-
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
97
|
+
const targetMarks = filterByDensity(marks, density);
|
|
102
98
|
|
|
103
99
|
const candidates: LabelCandidate[] = [];
|
|
104
100
|
// Track whether each candidate fits within its stacked segment.
|
|
@@ -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();
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getRepresentativeColor,
|
|
24
24
|
resolveCollisions,
|
|
25
25
|
} from '@opendata-ai/openchart-core';
|
|
26
|
+
import { filterByDensity } from '../_shared/density-filter';
|
|
26
27
|
import { formatLabelValue } from '../_shared/format-label-value';
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -50,12 +51,7 @@ export function computeColumnLabels(
|
|
|
50
51
|
labelPrefix?: string,
|
|
51
52
|
valueField?: string,
|
|
52
53
|
): ResolvedLabel[] {
|
|
53
|
-
|
|
54
|
-
if (density === 'none') return [];
|
|
55
|
-
|
|
56
|
-
// Filter marks for 'endpoints' density
|
|
57
|
-
const targetMarks =
|
|
58
|
-
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
54
|
+
const targetMarks = filterByDensity(marks, density);
|
|
59
55
|
|
|
60
56
|
const formatter = buildD3Formatter(labelFormat);
|
|
61
57
|
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getRepresentativeColor,
|
|
24
24
|
resolveCollisions,
|
|
25
25
|
} from '@opendata-ai/openchart-core';
|
|
26
|
+
import { filterByDensity } from '../_shared/density-filter';
|
|
26
27
|
import { formatLabelValue } from '../_shared/format-label-value';
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -50,12 +51,7 @@ export function computeDotLabels(
|
|
|
50
51
|
labelFormat?: string,
|
|
51
52
|
valueField?: string,
|
|
52
53
|
): ResolvedLabel[] {
|
|
53
|
-
|
|
54
|
-
if (density === 'none') return [];
|
|
55
|
-
|
|
56
|
-
// Filter marks for 'endpoints' density
|
|
57
|
-
const targetMarks =
|
|
58
|
-
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
54
|
+
const targetMarks = filterByDensity(marks, density);
|
|
59
55
|
|
|
60
56
|
const formatter = buildD3Formatter(labelFormat);
|
|
61
57
|
const candidates: LabelCandidate[] = [];
|
package/src/charts/pie/labels.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
ResolvedLabel,
|
|
21
21
|
} from '@opendata-ai/openchart-core';
|
|
22
22
|
import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
|
|
23
|
+
import { filterByDensity } from '../_shared/density-filter';
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Constants
|
|
@@ -48,16 +49,13 @@ export function computePieLabels(
|
|
|
48
49
|
): ResolvedLabel[] {
|
|
49
50
|
if (marks.length === 0) return [];
|
|
50
51
|
|
|
51
|
-
// 'none': no labels at all
|
|
52
|
-
if (density === 'none') return [];
|
|
53
|
-
|
|
54
52
|
// Get the pie center from the first mark's center property
|
|
53
|
+
// (read before filtering — 'endpoints' still needs the original center)
|
|
55
54
|
const centerX = marks[0].center.x;
|
|
56
55
|
const centerY = marks[0].center.y;
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
57
|
+
const targetMarks = filterByDensity(marks, density);
|
|
58
|
+
if (targetMarks.length === 0) return [];
|
|
61
59
|
|
|
62
60
|
const candidates: LabelCandidate[] = [];
|
|
63
61
|
const targetMarkIndices: number[] = [];
|
package/src/charts/registry.ts
CHANGED
|
@@ -58,6 +58,12 @@ export function getChartRenderer(type: string): ChartRenderer | undefined {
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Clear all registered renderers. Useful for testing.
|
|
61
|
+
*
|
|
62
|
+
* Note: the built-in renderers (line, bar, column, ...) register as a
|
|
63
|
+
* side-effect of importing `./builtin`. Once cleared, they do not come
|
|
64
|
+
* back on their own — subsequent `compileChart(...)` calls will return
|
|
65
|
+
* empty marks. Tests that call `clearRenderers()` should follow up with
|
|
66
|
+
* `registerBuiltinRenderers()` from `./builtin` to restore the defaults.
|
|
61
67
|
*/
|
|
62
68
|
export function clearRenderers(): void {
|
|
63
69
|
renderers.clear();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Encoding, ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
|
+
import { scaleLinear, scaleOrdinal } from 'd3-scale';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
6
|
+
import { applyColorScaleRange } from '../color-scale-range';
|
|
7
|
+
|
|
8
|
+
const theme: ResolvedTheme = resolveTheme();
|
|
9
|
+
|
|
10
|
+
describe('applyColorScaleRange', () => {
|
|
11
|
+
it('is a no-op when no color scale is present', () => {
|
|
12
|
+
const scales: ResolvedScales = {};
|
|
13
|
+
const encoding: Encoding = {
|
|
14
|
+
x: { field: 'x', type: 'quantitative' },
|
|
15
|
+
y: { field: 'y', type: 'quantitative' },
|
|
16
|
+
};
|
|
17
|
+
expect(() => applyColorScaleRange(scales, encoding, theme)).not.toThrow();
|
|
18
|
+
expect(scales.color).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not overwrite the range when the encoding declares an explicit palette', () => {
|
|
22
|
+
// computeScales has already applied the explicit palette to the scale.
|
|
23
|
+
// The helper must leave it untouched (not replace it with the theme palette).
|
|
24
|
+
const explicit = ['#111111', '#222222', '#333333'];
|
|
25
|
+
const ordinal = scaleOrdinal<string, string>().domain(['a', 'b', 'c']).range(explicit);
|
|
26
|
+
const scales: ResolvedScales = {
|
|
27
|
+
color: { scale: ordinal, type: 'ordinal', channel: 'color' },
|
|
28
|
+
};
|
|
29
|
+
const encoding: Encoding = {
|
|
30
|
+
x: { field: 'x', type: 'nominal' },
|
|
31
|
+
y: { field: 'y', type: 'quantitative' },
|
|
32
|
+
color: {
|
|
33
|
+
field: 'c',
|
|
34
|
+
type: 'nominal',
|
|
35
|
+
scale: { range: explicit },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
applyColorScaleRange(scales, encoding, theme);
|
|
39
|
+
expect(ordinal.range()).toEqual(explicit);
|
|
40
|
+
expect(ordinal.range()).not.toEqual(theme.colors.categorical);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('assigns the theme categorical palette when no range is set', () => {
|
|
44
|
+
const ordinal = scaleOrdinal<string, string>().domain(['a', 'b', 'c']);
|
|
45
|
+
const scales: ResolvedScales = {
|
|
46
|
+
color: { scale: ordinal, type: 'ordinal', channel: 'color' },
|
|
47
|
+
};
|
|
48
|
+
const encoding: Encoding = {
|
|
49
|
+
x: { field: 'x', type: 'nominal' },
|
|
50
|
+
y: { field: 'y', type: 'quantitative' },
|
|
51
|
+
color: { field: 'c', type: 'nominal' },
|
|
52
|
+
};
|
|
53
|
+
applyColorScaleRange(scales, encoding, theme);
|
|
54
|
+
expect(ordinal.range()).toEqual(theme.colors.categorical);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uses the first sequential palette endpoints for sequential color scales', () => {
|
|
58
|
+
const linear = scaleLinear<string, string>().domain([0, 100]);
|
|
59
|
+
const scales: ResolvedScales = {
|
|
60
|
+
color: {
|
|
61
|
+
scale: linear as unknown as ResolvedScales['color'] extends infer T
|
|
62
|
+
? T extends { scale: infer S }
|
|
63
|
+
? S
|
|
64
|
+
: never
|
|
65
|
+
: never,
|
|
66
|
+
type: 'sequential',
|
|
67
|
+
channel: 'color',
|
|
68
|
+
} as NonNullable<ResolvedScales['color']>,
|
|
69
|
+
};
|
|
70
|
+
const encoding: Encoding = {
|
|
71
|
+
x: { field: 'x', type: 'quantitative' },
|
|
72
|
+
y: { field: 'y', type: 'quantitative' },
|
|
73
|
+
color: { field: 'v', type: 'quantitative' },
|
|
74
|
+
};
|
|
75
|
+
applyColorScaleRange(scales, encoding, theme);
|
|
76
|
+
const firstSeq = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
77
|
+
expect(linear.range()).toEqual([firstSeq[0], firstSeq[firstSeq.length - 1]]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Encoding } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { filterClippedDomains } from '../data-clip';
|
|
4
|
+
|
|
5
|
+
describe('filterClippedDomains', () => {
|
|
6
|
+
const data = [
|
|
7
|
+
{ x: -5, y: 10 },
|
|
8
|
+
{ x: 10, y: 20 },
|
|
9
|
+
{ x: 25, y: 80 },
|
|
10
|
+
{ x: 50, y: 110 },
|
|
11
|
+
{ x: 80, y: 200 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it('returns data unchanged when no channel declares scale.clip', () => {
|
|
15
|
+
const encoding: Encoding = {
|
|
16
|
+
x: { field: 'x', type: 'quantitative' },
|
|
17
|
+
y: { field: 'y', type: 'quantitative' },
|
|
18
|
+
};
|
|
19
|
+
const result = filterClippedDomains(data, encoding);
|
|
20
|
+
expect(result).toBe(data);
|
|
21
|
+
expect(result).toHaveLength(5);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('filters rows outside the x-axis clipped domain', () => {
|
|
25
|
+
const encoding: Encoding = {
|
|
26
|
+
x: {
|
|
27
|
+
field: 'x',
|
|
28
|
+
type: 'quantitative',
|
|
29
|
+
scale: { domain: [0, 30], clip: true },
|
|
30
|
+
},
|
|
31
|
+
y: { field: 'y', type: 'quantitative' },
|
|
32
|
+
};
|
|
33
|
+
const result = filterClippedDomains(data, encoding);
|
|
34
|
+
expect(result).toEqual([
|
|
35
|
+
{ x: 10, y: 20 },
|
|
36
|
+
{ x: 25, y: 80 },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('filters rows outside both x- and y-axis clipped domains', () => {
|
|
41
|
+
const encoding: Encoding = {
|
|
42
|
+
x: {
|
|
43
|
+
field: 'x',
|
|
44
|
+
type: 'quantitative',
|
|
45
|
+
scale: { domain: [0, 60], clip: true },
|
|
46
|
+
},
|
|
47
|
+
y: {
|
|
48
|
+
field: 'y',
|
|
49
|
+
type: 'quantitative',
|
|
50
|
+
scale: { domain: [0, 100], clip: true },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const result = filterClippedDomains(data, encoding);
|
|
54
|
+
expect(result).toEqual([
|
|
55
|
+
{ x: 10, y: 20 },
|
|
56
|
+
{ x: 25, y: 80 },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
});
|