@opendata-ai/openchart-engine 3.0.0 → 6.1.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 +155 -19
- package/dist/index.js +1513 -164
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -3
- package/src/__tests__/axes.test.ts +168 -4
- package/src/__tests__/compile-chart.test.ts +23 -12
- package/src/__tests__/compile-layer.test.ts +386 -0
- package/src/__tests__/dimensions.test.ts +6 -3
- package/src/__tests__/legend.test.ts +6 -3
- package/src/__tests__/scales.test.ts +176 -2
- package/src/annotations/__tests__/compute.test.ts +8 -4
- package/src/charts/bar/__tests__/compute.test.ts +12 -6
- package/src/charts/bar/compute.ts +21 -5
- package/src/charts/column/__tests__/compute.test.ts +14 -7
- package/src/charts/column/compute.ts +21 -6
- package/src/charts/dot/__tests__/compute.test.ts +10 -5
- package/src/charts/dot/compute.ts +10 -4
- package/src/charts/line/__tests__/compute.test.ts +102 -11
- package/src/charts/line/__tests__/curves.test.ts +51 -0
- package/src/charts/line/__tests__/labels.test.ts +2 -1
- package/src/charts/line/__tests__/mark-options.test.ts +175 -0
- package/src/charts/line/area.ts +19 -8
- package/src/charts/line/compute.ts +64 -25
- package/src/charts/line/curves.ts +40 -0
- package/src/charts/pie/__tests__/compute.test.ts +10 -5
- package/src/charts/pie/compute.ts +2 -1
- package/src/charts/rule/index.ts +127 -0
- package/src/charts/scatter/__tests__/compute.test.ts +10 -5
- package/src/charts/scatter/compute.ts +15 -5
- package/src/charts/text/index.ts +92 -0
- package/src/charts/tick/index.ts +84 -0
- package/src/charts/utils.ts +1 -1
- package/src/compile.ts +175 -23
- package/src/compiler/__tests__/compile.test.ts +4 -4
- package/src/compiler/__tests__/normalize.test.ts +4 -4
- package/src/compiler/__tests__/validate.test.ts +25 -26
- package/src/compiler/index.ts +1 -1
- package/src/compiler/normalize.ts +77 -4
- package/src/compiler/types.ts +6 -2
- package/src/compiler/validate.ts +167 -35
- package/src/graphs/__tests__/compile-graph.test.ts +2 -2
- package/src/graphs/compile-graph.ts +2 -2
- package/src/index.ts +17 -1
- package/src/layout/axes.ts +122 -20
- package/src/layout/dimensions.ts +15 -9
- package/src/layout/scales.ts +320 -31
- package/src/legend/compute.ts +9 -6
- package/src/tables/__tests__/compile-table.test.ts +1 -1
- package/src/tooltips/__tests__/compute.test.ts +10 -5
- package/src/tooltips/compute.ts +32 -14
- package/src/transforms/__tests__/bin.test.ts +88 -0
- package/src/transforms/__tests__/calculate.test.ts +146 -0
- package/src/transforms/__tests__/conditional.test.ts +109 -0
- package/src/transforms/__tests__/filter.test.ts +59 -0
- package/src/transforms/__tests__/index.test.ts +93 -0
- package/src/transforms/__tests__/predicates.test.ts +176 -0
- package/src/transforms/__tests__/timeunit.test.ts +129 -0
- package/src/transforms/bin.ts +87 -0
- package/src/transforms/calculate.ts +60 -0
- package/src/transforms/conditional.ts +46 -0
- package/src/transforms/filter.ts +17 -0
- package/src/transforms/index.ts +48 -0
- package/src/transforms/predicates.ts +90 -0
- package/src/transforms/timeunit.ts +88 -0
|
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
20
20
|
|
|
21
21
|
function makeBasicScatterSpec(): NormalizedChartSpec {
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
markType: 'point',
|
|
24
|
+
markDef: { type: 'point' },
|
|
24
25
|
data: [
|
|
25
26
|
{ x: 10, y: 20 },
|
|
26
27
|
{ x: 30, y: 50 },
|
|
@@ -43,7 +44,8 @@ function makeBasicScatterSpec(): NormalizedChartSpec {
|
|
|
43
44
|
|
|
44
45
|
function makeBubbleSpec(): NormalizedChartSpec {
|
|
45
46
|
return {
|
|
46
|
-
|
|
47
|
+
markType: 'point',
|
|
48
|
+
markDef: { type: 'point' },
|
|
47
49
|
data: [
|
|
48
50
|
{ gdp: 10, life: 60, population: 1000 },
|
|
49
51
|
{ gdp: 30, life: 70, population: 5000 },
|
|
@@ -66,7 +68,8 @@ function makeBubbleSpec(): NormalizedChartSpec {
|
|
|
66
68
|
|
|
67
69
|
function makeColoredScatterSpec(): NormalizedChartSpec {
|
|
68
70
|
return {
|
|
69
|
-
|
|
71
|
+
markType: 'point',
|
|
72
|
+
markDef: { type: 'point' },
|
|
70
73
|
data: [
|
|
71
74
|
{ x: 10, y: 20, group: 'A' },
|
|
72
75
|
{ x: 30, y: 50, group: 'A' },
|
|
@@ -202,7 +205,8 @@ describe('computeScatterMarks', () => {
|
|
|
202
205
|
describe('edge cases', () => {
|
|
203
206
|
it('returns empty array when no x encoding', () => {
|
|
204
207
|
const spec: NormalizedChartSpec = {
|
|
205
|
-
|
|
208
|
+
markType: 'point',
|
|
209
|
+
markDef: { type: 'point' },
|
|
206
210
|
data: [{ y: 10 }],
|
|
207
211
|
encoding: {
|
|
208
212
|
y: { field: 'y', type: 'quantitative' },
|
|
@@ -221,7 +225,8 @@ describe('computeScatterMarks', () => {
|
|
|
221
225
|
|
|
222
226
|
it('skips rows with non-finite values', () => {
|
|
223
227
|
const spec: NormalizedChartSpec = {
|
|
224
|
-
|
|
228
|
+
markType: 'point',
|
|
229
|
+
markDef: { type: 'point' },
|
|
225
230
|
data: [
|
|
226
231
|
{ x: 10, y: 20 },
|
|
227
232
|
{ x: NaN, y: 30 },
|
|
@@ -20,7 +20,7 @@ import { scaleSqrt } from 'd3-scale';
|
|
|
20
20
|
|
|
21
21
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
22
22
|
import type { ResolvedScales } from '../../layout/scales';
|
|
23
|
-
import { getColor } from '../utils';
|
|
23
|
+
import { getColor, getSequentialColor } from '../utils';
|
|
24
24
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// Constants
|
|
@@ -58,8 +58,10 @@ export function computeScatterMarks(
|
|
|
58
58
|
const xScale = scales.x.scale as ScaleLinear<number, number>;
|
|
59
59
|
const yScale = scales.y.scale as ScaleLinear<number, number>;
|
|
60
60
|
|
|
61
|
-
const
|
|
62
|
-
const
|
|
61
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
62
|
+
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
63
|
+
const colorField = colorEnc?.field;
|
|
64
|
+
const sizeField = encoding.size && 'field' in encoding.size ? encoding.size.field : undefined;
|
|
63
65
|
|
|
64
66
|
// Build a size scale for bubble variant
|
|
65
67
|
let sizeScale: ((v: number) => number) | undefined;
|
|
@@ -85,8 +87,16 @@ export function computeScatterMarks(
|
|
|
85
87
|
const cx = xScale(xVal);
|
|
86
88
|
const cy = yScale(yVal);
|
|
87
89
|
|
|
88
|
-
const category = colorField ? String(row[colorField] ?? '') : undefined;
|
|
89
|
-
|
|
90
|
+
const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
|
|
91
|
+
let color: string;
|
|
92
|
+
if (isSequentialColor && colorField) {
|
|
93
|
+
const val = Number(row[colorField]);
|
|
94
|
+
color = Number.isFinite(val)
|
|
95
|
+
? getSequentialColor(scales, val)
|
|
96
|
+
: getColor(scales, '__default__');
|
|
97
|
+
} else {
|
|
98
|
+
color = getColor(scales, category ?? '__default__');
|
|
99
|
+
}
|
|
90
100
|
|
|
91
101
|
let radius = DEFAULT_POINT_RADIUS;
|
|
92
102
|
if (sizeScale && sizeField) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text mark renderer.
|
|
3
|
+
*
|
|
4
|
+
* Computes TextMarkLayout marks from a normalized chart spec.
|
|
5
|
+
* Positions text at data coordinates using x/y encoding channels,
|
|
6
|
+
* with content from the text encoding channel.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
12
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
13
|
+
import type { ChartRenderer } from '../registry';
|
|
14
|
+
import { getColor, scaleValue } from '../utils';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute text marks from spec data and resolved scales.
|
|
18
|
+
*/
|
|
19
|
+
export function computeTextMarks(
|
|
20
|
+
spec: NormalizedChartSpec,
|
|
21
|
+
scales: ResolvedScales,
|
|
22
|
+
): TextMarkLayout[] {
|
|
23
|
+
const encoding = spec.encoding as Encoding;
|
|
24
|
+
const xChannel = encoding.x;
|
|
25
|
+
const yChannel = encoding.y;
|
|
26
|
+
const textChannel = encoding.text;
|
|
27
|
+
|
|
28
|
+
if (!textChannel || !('field' in textChannel)) return [];
|
|
29
|
+
|
|
30
|
+
const marks: TextMarkLayout[] = [];
|
|
31
|
+
const colorEncoding = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
32
|
+
const colorField = colorEncoding?.field;
|
|
33
|
+
const sizeEncoding = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
|
|
34
|
+
|
|
35
|
+
for (const row of spec.data) {
|
|
36
|
+
// Resolve x position (center of chart if no x encoding)
|
|
37
|
+
let x = 0;
|
|
38
|
+
if (xChannel && scales.x) {
|
|
39
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
40
|
+
if (xVal == null) continue;
|
|
41
|
+
x = xVal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolve y position (center of chart if no y encoding)
|
|
45
|
+
let y = 0;
|
|
46
|
+
if (yChannel && scales.y) {
|
|
47
|
+
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
48
|
+
if (yVal == null) continue;
|
|
49
|
+
y = yVal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const text = String(row[textChannel.field] ?? '');
|
|
53
|
+
if (!text) continue;
|
|
54
|
+
|
|
55
|
+
const color = colorField
|
|
56
|
+
? getColor(scales, String(row[colorField] ?? '__default__'))
|
|
57
|
+
: getColor(scales, '__default__');
|
|
58
|
+
|
|
59
|
+
const fontSize = sizeEncoding
|
|
60
|
+
? Math.max(8, Math.min(48, Number(row[sizeEncoding.field]) || 12))
|
|
61
|
+
: 12;
|
|
62
|
+
|
|
63
|
+
const aria: MarkAria = {
|
|
64
|
+
label: text,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
marks.push({
|
|
68
|
+
type: 'textMark',
|
|
69
|
+
x,
|
|
70
|
+
y,
|
|
71
|
+
text,
|
|
72
|
+
fill: color,
|
|
73
|
+
fontSize,
|
|
74
|
+
textAnchor: 'middle',
|
|
75
|
+
angle:
|
|
76
|
+
encoding.angle && 'field' in encoding.angle
|
|
77
|
+
? Number(row[encoding.angle.field]) || 0
|
|
78
|
+
: undefined,
|
|
79
|
+
data: row as Record<string, unknown>,
|
|
80
|
+
aria,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return marks;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Text chart renderer.
|
|
89
|
+
*/
|
|
90
|
+
export const textRenderer: ChartRenderer = (spec, scales, _chartArea, _strategy, _theme) => {
|
|
91
|
+
return computeTextMarks(spec, scales) as Mark[];
|
|
92
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick mark renderer.
|
|
3
|
+
*
|
|
4
|
+
* Computes TickMarkLayout marks from a normalized chart spec.
|
|
5
|
+
* Ticks are short line segments used for strip/rug plots.
|
|
6
|
+
* Each data point produces a small tick perpendicular to the data axis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Encoding, Mark, MarkAria, Rect, TickMarkLayout } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
12
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
13
|
+
import type { ChartRenderer } from '../registry';
|
|
14
|
+
import { getColor, scaleValue } from '../utils';
|
|
15
|
+
|
|
16
|
+
/** Default tick length in pixels. */
|
|
17
|
+
const DEFAULT_TICK_LENGTH = 18;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute tick marks from spec data and resolved scales.
|
|
21
|
+
*
|
|
22
|
+
* Orientation is inferred from the encoding:
|
|
23
|
+
* - If x is quantitative and y is categorical (or vice versa), ticks are
|
|
24
|
+
* perpendicular to the quantitative axis.
|
|
25
|
+
* - Default: vertical ticks (short vertical lines at each x position).
|
|
26
|
+
*/
|
|
27
|
+
export function computeTickMarks(
|
|
28
|
+
spec: NormalizedChartSpec,
|
|
29
|
+
scales: ResolvedScales,
|
|
30
|
+
_chartArea: Rect,
|
|
31
|
+
): TickMarkLayout[] {
|
|
32
|
+
const encoding = spec.encoding as Encoding;
|
|
33
|
+
const xChannel = encoding.x;
|
|
34
|
+
const yChannel = encoding.y;
|
|
35
|
+
|
|
36
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) return [];
|
|
37
|
+
|
|
38
|
+
const colorEncoding = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
39
|
+
const colorField = colorEncoding?.field;
|
|
40
|
+
const marks: TickMarkLayout[] = [];
|
|
41
|
+
|
|
42
|
+
// Determine orientation: ticks are perpendicular to the quantitative axis
|
|
43
|
+
const isHorizontal = xChannel.type === 'quantitative' && yChannel.type !== 'quantitative';
|
|
44
|
+
const orient: 'horizontal' | 'vertical' = isHorizontal ? 'horizontal' : 'vertical';
|
|
45
|
+
|
|
46
|
+
for (const row of spec.data) {
|
|
47
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
48
|
+
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
49
|
+
if (xVal == null || yVal == null) continue;
|
|
50
|
+
|
|
51
|
+
const color = colorField
|
|
52
|
+
? getColor(scales, String(row[colorField] ?? '__default__'))
|
|
53
|
+
: getColor(scales, '__default__');
|
|
54
|
+
|
|
55
|
+
const aria: MarkAria = {
|
|
56
|
+
label: `${row[xChannel.field]}, ${row[yChannel.field]}`,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
marks.push({
|
|
60
|
+
type: 'tick',
|
|
61
|
+
x: xVal,
|
|
62
|
+
y: yVal,
|
|
63
|
+
length: DEFAULT_TICK_LENGTH,
|
|
64
|
+
orient,
|
|
65
|
+
stroke: color,
|
|
66
|
+
strokeWidth: 1,
|
|
67
|
+
opacity:
|
|
68
|
+
encoding.opacity && 'field' in encoding.opacity
|
|
69
|
+
? Math.max(0, Math.min(1, Number(row[encoding.opacity.field]) || 1))
|
|
70
|
+
: undefined,
|
|
71
|
+
data: row as Record<string, unknown>,
|
|
72
|
+
aria,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return marks;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Tick chart renderer.
|
|
81
|
+
*/
|
|
82
|
+
export const tickRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, _theme) => {
|
|
83
|
+
return computeTickMarks(spec, scales, chartArea) as Mark[];
|
|
84
|
+
};
|
package/src/charts/utils.ts
CHANGED
|
@@ -32,7 +32,7 @@ export const DEFAULT_COLOR = '#1b7fa3';
|
|
|
32
32
|
export function scaleValue(scale: D3Scale, scaleType: string, value: unknown): number | null {
|
|
33
33
|
if (value == null) return null;
|
|
34
34
|
|
|
35
|
-
if (scaleType === 'time') {
|
|
35
|
+
if (scaleType === 'time' || scaleType === 'utc') {
|
|
36
36
|
const date = value instanceof Date ? value : new Date(String(value));
|
|
37
37
|
if (Number.isNaN(date.getTime())) return null;
|
|
38
38
|
return (scale as ScaleTime<number, number>)(date);
|
package/src/compile.ts
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import type {
|
|
15
15
|
ChartLayout,
|
|
16
|
+
ChartSpec,
|
|
16
17
|
CompileOptions,
|
|
17
18
|
CompileTableOptions,
|
|
19
|
+
LayerSpec,
|
|
18
20
|
Mark,
|
|
19
21
|
PointMark,
|
|
20
22
|
Rect,
|
|
@@ -41,21 +43,39 @@ import { dotRenderer } from './charts/dot';
|
|
|
41
43
|
import { areaRenderer, lineRenderer } from './charts/line';
|
|
42
44
|
import { donutRenderer, pieRenderer } from './charts/pie';
|
|
43
45
|
import { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
|
|
46
|
+
import { ruleRenderer } from './charts/rule';
|
|
44
47
|
import { scatterRenderer } from './charts/scatter';
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
48
|
+
import { textRenderer } from './charts/text';
|
|
49
|
+
import { tickRenderer } from './charts/tick';
|
|
50
|
+
import { compile as compileSpec, flattenLayers } from './compiler/index';
|
|
51
|
+
|
|
52
|
+
// Register all built-in chart renderers under the new Vega-Lite mark type names.
|
|
53
|
+
// Explicit imports ensure bundlers cannot tree-shake the registrations away.
|
|
54
|
+
//
|
|
55
|
+
// Mark type mapping from old chart types:
|
|
56
|
+
// - 'bar' -> barRenderer (horizontal bars, old 'bar')
|
|
57
|
+
// - 'bar:vertical' is handled by columnRenderer (old 'column')
|
|
58
|
+
// - 'arc' -> pieRenderer (old 'pie'); donutRenderer is also registered
|
|
59
|
+
// - 'point' -> scatterRenderer (old 'scatter')
|
|
60
|
+
// - 'circle' -> dotRenderer (old 'dot')
|
|
61
|
+
// - 'line' and 'area' unchanged
|
|
62
|
+
// - 'text', 'rule', 'tick' are new Vega-Lite mark types
|
|
63
|
+
//
|
|
64
|
+
// For 'bar', orientation is resolved at compile time to dispatch to the right renderer.
|
|
65
|
+
// We register both barRenderer and columnRenderer; the compile function picks based on orientation.
|
|
50
66
|
const builtinRenderers: Record<string, ChartRenderer> = {
|
|
51
67
|
line: lineRenderer,
|
|
52
68
|
area: areaRenderer,
|
|
53
|
-
bar: barRenderer,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
donut: donutRenderer,
|
|
58
|
-
|
|
69
|
+
bar: barRenderer, // horizontal bars
|
|
70
|
+
'bar:vertical': columnRenderer, // vertical bars (old 'column')
|
|
71
|
+
point: scatterRenderer, // old 'scatter'
|
|
72
|
+
arc: pieRenderer, // old 'pie' (donut handled via innerRadius)
|
|
73
|
+
'arc:donut': donutRenderer, // old 'donut'
|
|
74
|
+
circle: dotRenderer, // old 'dot'
|
|
75
|
+
text: textRenderer,
|
|
76
|
+
rule: ruleRenderer,
|
|
77
|
+
tick: tickRenderer,
|
|
78
|
+
rect: columnRenderer, // rect uses column renderer (RectMark output) as baseline for heatmaps
|
|
59
79
|
};
|
|
60
80
|
for (const [type, renderer] of Object.entries(builtinRenderers)) {
|
|
61
81
|
registerChartRenderer(type, renderer);
|
|
@@ -71,6 +91,7 @@ import { computeScales, type ResolvedScales } from './layout/scales';
|
|
|
71
91
|
import { computeLegend } from './legend/compute';
|
|
72
92
|
import { compileTableLayout } from './tables/compile-table';
|
|
73
93
|
import { computeTooltipDescriptors } from './tooltips/compute';
|
|
94
|
+
import { runTransforms } from './transforms';
|
|
74
95
|
|
|
75
96
|
// ---------------------------------------------------------------------------
|
|
76
97
|
// Mark obstacles for annotation collision avoidance
|
|
@@ -182,15 +203,25 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
182
203
|
// Validate + normalize
|
|
183
204
|
const { spec: normalized } = compileSpec(spec);
|
|
184
205
|
|
|
185
|
-
if (normalized.type === 'table') {
|
|
206
|
+
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
|
|
186
207
|
throw new Error('compileChart received a table spec. Use compileTable instead.');
|
|
187
208
|
}
|
|
188
|
-
if (normalized.type === 'graph') {
|
|
209
|
+
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'graph') {
|
|
189
210
|
throw new Error('compileChart received a graph spec. Use compileGraph instead.');
|
|
190
211
|
}
|
|
191
212
|
|
|
192
213
|
let chartSpec = normalized as NormalizedChartSpec;
|
|
193
214
|
|
|
215
|
+
// Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
|
|
216
|
+
// Transforms are defined on the original spec, not the normalized spec, since
|
|
217
|
+
// NormalizedChartSpec doesn't carry the transform field.
|
|
218
|
+
const rawTransforms = (spec as Record<string, unknown>).transform as
|
|
219
|
+
| import('@opendata-ai/openchart-core').Transform[]
|
|
220
|
+
| undefined;
|
|
221
|
+
if (rawTransforms && rawTransforms.length > 0) {
|
|
222
|
+
chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
|
|
223
|
+
}
|
|
224
|
+
|
|
194
225
|
// Responsive strategy
|
|
195
226
|
const breakpoint = getBreakpoint(options.width);
|
|
196
227
|
const heightClass = getHeightClass(options.height);
|
|
@@ -292,7 +323,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
292
323
|
let renderData = chartSpec.data;
|
|
293
324
|
|
|
294
325
|
// Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
|
|
295
|
-
if (
|
|
326
|
+
if (
|
|
327
|
+
chartSpec.hiddenSeries.length > 0 &&
|
|
328
|
+
chartSpec.encoding.color &&
|
|
329
|
+
'field' in chartSpec.encoding.color
|
|
330
|
+
) {
|
|
296
331
|
const colorField = chartSpec.encoding.color.field;
|
|
297
332
|
const hiddenSet = new Set(chartSpec.hiddenSeries);
|
|
298
333
|
renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
|
|
@@ -338,8 +373,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
338
373
|
// Set default color for single-series charts (no color encoding)
|
|
339
374
|
scales.defaultColor = theme.colors.categorical[0];
|
|
340
375
|
|
|
341
|
-
//
|
|
342
|
-
const isRadial = chartSpec.
|
|
376
|
+
// Arc charts (pie/donut) don't use axes or gridlines
|
|
377
|
+
const isRadial = chartSpec.markType === 'arc';
|
|
343
378
|
|
|
344
379
|
// Compute axes (skip for radial charts)
|
|
345
380
|
const axes = isRadial
|
|
@@ -351,8 +386,28 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
351
386
|
computeGridlines(axes, chartArea);
|
|
352
387
|
}
|
|
353
388
|
|
|
354
|
-
// Get chart renderer and compute marks (using filtered data)
|
|
355
|
-
|
|
389
|
+
// Get chart renderer and compute marks (using filtered data).
|
|
390
|
+
// For 'bar' mark, resolve orientation to pick horizontal vs vertical renderer.
|
|
391
|
+
// For 'arc' mark, resolve innerRadius to pick pie vs donut renderer.
|
|
392
|
+
let rendererKey = renderSpec.markType as string;
|
|
393
|
+
if (rendererKey === 'bar') {
|
|
394
|
+
// Infer orientation from encoding: if x is quantitative and y is categorical, horizontal (default)
|
|
395
|
+
// If x is categorical and y is quantitative, vertical (old 'column')
|
|
396
|
+
const xType = renderSpec.encoding.x?.type;
|
|
397
|
+
const yType = renderSpec.encoding.y?.type;
|
|
398
|
+
const isVertical =
|
|
399
|
+
(xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
|
|
400
|
+
yType === 'quantitative';
|
|
401
|
+
if (isVertical) {
|
|
402
|
+
rendererKey = 'bar:vertical';
|
|
403
|
+
}
|
|
404
|
+
} else if (rendererKey === 'arc') {
|
|
405
|
+
const innerRadius = renderSpec.markDef.innerRadius;
|
|
406
|
+
if (innerRadius && innerRadius > 0) {
|
|
407
|
+
rendererKey = 'arc:donut';
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const renderer = getChartRenderer(rendererKey);
|
|
356
411
|
const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
357
412
|
|
|
358
413
|
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
@@ -364,7 +419,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
364
419
|
|
|
365
420
|
// Add visible data label bounds as obstacles so annotations avoid overlapping them
|
|
366
421
|
for (const mark of marks) {
|
|
367
|
-
if (
|
|
422
|
+
if ('label' in mark && mark.label?.visible) {
|
|
368
423
|
obstacles.push(computeLabelBounds(mark.label));
|
|
369
424
|
}
|
|
370
425
|
}
|
|
@@ -395,7 +450,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
395
450
|
// Compute accessibility
|
|
396
451
|
const altText = generateAltText(
|
|
397
452
|
{
|
|
398
|
-
|
|
453
|
+
mark: chartSpec.markType,
|
|
399
454
|
data: chartSpec.data,
|
|
400
455
|
encoding: chartSpec.encoding,
|
|
401
456
|
chrome: chartSpec.chrome,
|
|
@@ -404,7 +459,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
404
459
|
);
|
|
405
460
|
const dataTableFallback = generateDataTable(
|
|
406
461
|
{
|
|
407
|
-
|
|
462
|
+
mark: chartSpec.markType,
|
|
408
463
|
data: chartSpec.data,
|
|
409
464
|
encoding: chartSpec.encoding,
|
|
410
465
|
},
|
|
@@ -436,6 +491,101 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
436
491
|
};
|
|
437
492
|
}
|
|
438
493
|
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Layer compilation
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Compile a LayerSpec into a single ChartLayout.
|
|
500
|
+
*
|
|
501
|
+
* Flattens nested layers, merges inherited data/encoding/transforms,
|
|
502
|
+
* compiles each leaf layer independently, unions scale domains (shared
|
|
503
|
+
* by default), and concatenates marks in layer order.
|
|
504
|
+
*
|
|
505
|
+
* @param spec - A LayerSpec with child layers.
|
|
506
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
507
|
+
* @returns A single ChartLayout with combined marks from all layers.
|
|
508
|
+
*/
|
|
509
|
+
export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
|
|
510
|
+
// Flatten nested layers into leaf ChartSpecs with merged data/encoding/transforms
|
|
511
|
+
const leaves = flattenLayers(spec);
|
|
512
|
+
|
|
513
|
+
if (leaves.length === 0) {
|
|
514
|
+
throw new Error('LayerSpec has no leaf chart specs after flattening');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// If there's only one layer, just compile it directly
|
|
518
|
+
if (leaves.length === 1) {
|
|
519
|
+
const singleSpec = buildPrimarySpec(leaves, spec);
|
|
520
|
+
return compileChart(singleSpec, options);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Build primary spec with unioned data for shared scale computation.
|
|
524
|
+
// The primary layout provides chrome, axes, dimensions, legend, and a11y.
|
|
525
|
+
const primarySpec = buildPrimarySpec(leaves, spec);
|
|
526
|
+
const primaryLayout = compileChart(primarySpec, options);
|
|
527
|
+
|
|
528
|
+
// Compile each leaf layer independently but with the full unioned data
|
|
529
|
+
// so they all share the same scale domains.
|
|
530
|
+
const allMarks: Mark[] = [];
|
|
531
|
+
const seenLabels = new Set<string>();
|
|
532
|
+
const mergedLegendEntries = [...primaryLayout.legend.entries];
|
|
533
|
+
for (const entry of mergedLegendEntries) {
|
|
534
|
+
seenLabels.add(entry.label);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
for (const leaf of leaves) {
|
|
538
|
+
// Compile each leaf with its own data so marks correspond to its rows only.
|
|
539
|
+
// Scale domains may differ slightly between layers, but this prevents
|
|
540
|
+
// duplicate marks from feeding unioned data into every renderer.
|
|
541
|
+
const leafLayout = compileChart(leaf as unknown, options);
|
|
542
|
+
|
|
543
|
+
allMarks.push(...leafLayout.marks);
|
|
544
|
+
|
|
545
|
+
// Deduplicate legend entries across layers
|
|
546
|
+
for (const entry of leafLayout.legend.entries) {
|
|
547
|
+
if (!seenLabels.has(entry.label)) {
|
|
548
|
+
seenLabels.add(entry.label);
|
|
549
|
+
mergedLegendEntries.push(entry);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
...primaryLayout,
|
|
556
|
+
marks: allMarks,
|
|
557
|
+
legend: {
|
|
558
|
+
...primaryLayout.legend,
|
|
559
|
+
entries: mergedLegendEntries,
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Build the primary ChartSpec from all leaves for shared compilation.
|
|
566
|
+
* Unions all data rows across layers so scales see the full domain.
|
|
567
|
+
* Uses the first leaf's mark/encoding as the base, with layer-level chrome.
|
|
568
|
+
*/
|
|
569
|
+
function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec {
|
|
570
|
+
// Union all data across layers for domain computation
|
|
571
|
+
const allData = leaves.flatMap((leaf) => leaf.data);
|
|
572
|
+
|
|
573
|
+
const primary = {
|
|
574
|
+
...leaves[0],
|
|
575
|
+
data: allData,
|
|
576
|
+
// Layer-level chrome overrides leaf chrome
|
|
577
|
+
chrome: layerSpec.chrome ?? leaves[0].chrome,
|
|
578
|
+
labels: layerSpec.labels ?? leaves[0].labels,
|
|
579
|
+
legend: layerSpec.legend ?? leaves[0].legend,
|
|
580
|
+
responsive: layerSpec.responsive ?? leaves[0].responsive,
|
|
581
|
+
theme: layerSpec.theme ?? leaves[0].theme,
|
|
582
|
+
darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
|
|
583
|
+
hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
return primary;
|
|
587
|
+
}
|
|
588
|
+
|
|
439
589
|
// ---------------------------------------------------------------------------
|
|
440
590
|
// Table compilation
|
|
441
591
|
// ---------------------------------------------------------------------------
|
|
@@ -454,8 +604,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
454
604
|
export function compileTable(spec: unknown, options: CompileTableOptions): TableLayout {
|
|
455
605
|
const { spec: normalized } = compileSpec(spec);
|
|
456
606
|
|
|
457
|
-
|
|
458
|
-
|
|
607
|
+
const normType =
|
|
608
|
+
'type' in normalized ? (normalized as unknown as Record<string, unknown>).type : undefined;
|
|
609
|
+
if (normType !== 'table') {
|
|
610
|
+
throw new Error(`compileTable received a non-table spec. Use compileChart instead.`);
|
|
459
611
|
}
|
|
460
612
|
|
|
461
613
|
const tableSpec = normalized as NormalizedTableSpec;
|
|
@@ -4,7 +4,7 @@ import type { NormalizedChartSpec } from '../types';
|
|
|
4
4
|
|
|
5
5
|
describe('compile (validate + normalize pipeline)', () => {
|
|
6
6
|
const validSpec = {
|
|
7
|
-
|
|
7
|
+
mark: 'line',
|
|
8
8
|
data: [
|
|
9
9
|
{ date: '2020-01-01', value: 10 },
|
|
10
10
|
{ date: '2021-01-01', value: 20 },
|
|
@@ -19,7 +19,7 @@ describe('compile (validate + normalize pipeline)', () => {
|
|
|
19
19
|
it('returns a normalized spec for valid input', () => {
|
|
20
20
|
const result = compile(validSpec);
|
|
21
21
|
expect(result.spec).toBeDefined();
|
|
22
|
-
expect(result.spec.
|
|
22
|
+
expect((result.spec as NormalizedChartSpec).markType).toBe('line');
|
|
23
23
|
expect(result.warnings).toBeInstanceOf(Array);
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -42,7 +42,7 @@ describe('compile (validate + normalize pipeline)', () => {
|
|
|
42
42
|
expect(() => compile({})).toThrow('Invalid spec');
|
|
43
43
|
expect(() =>
|
|
44
44
|
compile({
|
|
45
|
-
|
|
45
|
+
mark: 'line',
|
|
46
46
|
data: [],
|
|
47
47
|
encoding: {},
|
|
48
48
|
}),
|
|
@@ -51,7 +51,7 @@ describe('compile (validate + normalize pipeline)', () => {
|
|
|
51
51
|
|
|
52
52
|
it('produces warnings for inferred types', () => {
|
|
53
53
|
const spec = {
|
|
54
|
-
|
|
54
|
+
mark: 'point',
|
|
55
55
|
data: [
|
|
56
56
|
{ x: 10, y: 20 },
|
|
57
57
|
{ x: 30, y: 40 },
|
|
@@ -15,7 +15,7 @@ import type { NormalizedChartSpec, NormalizedGraphSpec, NormalizedTableSpec } fr
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
17
|
const lineSpec: ChartSpec = {
|
|
18
|
-
|
|
18
|
+
mark: 'line',
|
|
19
19
|
data: [
|
|
20
20
|
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
21
21
|
{ date: '2021-01-01', value: 20, country: 'UK' },
|
|
@@ -76,7 +76,7 @@ describe('normalizeSpec', () => {
|
|
|
76
76
|
it('infers encoding types from data when not specified', () => {
|
|
77
77
|
const warnings: string[] = [];
|
|
78
78
|
const spec: ChartSpec = {
|
|
79
|
-
|
|
79
|
+
mark: 'point',
|
|
80
80
|
data: [
|
|
81
81
|
{ x: 10, y: 20 },
|
|
82
82
|
{ x: 30, y: 40 },
|
|
@@ -97,7 +97,7 @@ describe('normalizeSpec', () => {
|
|
|
97
97
|
it('infers temporal type from date strings', () => {
|
|
98
98
|
const warnings: string[] = [];
|
|
99
99
|
const spec: ChartSpec = {
|
|
100
|
-
|
|
100
|
+
mark: 'line',
|
|
101
101
|
data: [
|
|
102
102
|
{ date: '2020-01-01', value: 10 },
|
|
103
103
|
{ date: '2021-06-15', value: 20 },
|
|
@@ -116,7 +116,7 @@ describe('normalizeSpec', () => {
|
|
|
116
116
|
it('warns on type mismatch (temporal declared as nominal)', () => {
|
|
117
117
|
const warnings: string[] = [];
|
|
118
118
|
const spec: ChartSpec = {
|
|
119
|
-
|
|
119
|
+
mark: 'line',
|
|
120
120
|
data: [
|
|
121
121
|
{ date: '2020-01-01', value: 10 },
|
|
122
122
|
{ date: '2021-06-15', value: 20 },
|