@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +6 -0
  2. package/dist/index.js +865 -3729
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
  6. package/src/__tests__/axes.test.ts +65 -0
  7. package/src/__tests__/compile-snapshot.test.ts +156 -0
  8. package/src/__tests__/legend.test.ts +39 -0
  9. package/src/charts/__tests__/registry.test.ts +6 -0
  10. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  11. package/src/charts/_shared/density-filter.ts +26 -0
  12. package/src/charts/_shared/format-label-value.ts +15 -0
  13. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  14. package/src/charts/bar/compute.ts +6 -11
  15. package/src/charts/bar/labels.ts +4 -15
  16. package/src/charts/builtin.ts +64 -0
  17. package/src/charts/column/compute.ts +6 -11
  18. package/src/charts/column/labels.ts +4 -19
  19. package/src/charts/dot/labels.ts +4 -19
  20. package/src/charts/pie/labels.ts +4 -6
  21. package/src/charts/registry.ts +6 -0
  22. package/src/compile/__tests__/color-scale-range.test.ts +79 -0
  23. package/src/compile/__tests__/data-clip.test.ts +59 -0
  24. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  25. package/src/compile/color-scale-range.ts +38 -0
  26. package/src/compile/data-clip.ts +33 -0
  27. package/src/compile/watermark-obstacle.ts +54 -0
  28. package/src/compile.ts +20 -97
  29. package/src/layout/axes/thinning.ts +96 -0
  30. package/src/layout/axes/ticks.ts +266 -0
  31. package/src/layout/axes.ts +148 -249
  32. package/src/legend/compute.ts +6 -51
  33. package/src/legend/wrap.ts +94 -0
  34. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  35. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  36. 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 { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
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}: ${formatBarValue(rawValue)}`,
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}: ${formatBarValue(value)}`,
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}: ${formatBarValue(value)}`,
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}: ${formatBarValue(value)}`,
399
+ label: `${category}: ${formatLabelValue(value)}`,
405
400
  };
406
401
 
407
402
  marks.push({
@@ -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
- // 'none': no labels at all
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 = formatBarValue(rawNum);
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();