@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { LayoutStrategy, LineMark, PointMark, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { NormalizedChartSpec } from '../../../compiler/types';
|
|
4
|
+
import { computeScales } from '../../../layout/scales';
|
|
5
|
+
import { computeLineMarks } from '../compute';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Shared fixtures
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
|
|
12
|
+
|
|
13
|
+
const fullStrategy: LayoutStrategy = {
|
|
14
|
+
labelMode: 'all',
|
|
15
|
+
legendPosition: 'right',
|
|
16
|
+
annotationPosition: 'inline',
|
|
17
|
+
axisLabelDensity: 'full',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function makeSpec(markDef: NormalizedChartSpec['markDef']): NormalizedChartSpec {
|
|
21
|
+
return {
|
|
22
|
+
markType: 'line',
|
|
23
|
+
markDef,
|
|
24
|
+
data: [
|
|
25
|
+
{ date: '2020-01-01', value: 10 },
|
|
26
|
+
{ date: '2021-01-01', value: 40 },
|
|
27
|
+
{ date: '2022-01-01', value: 30 },
|
|
28
|
+
],
|
|
29
|
+
encoding: {
|
|
30
|
+
x: { field: 'date', type: 'temporal' },
|
|
31
|
+
y: { field: 'value', type: 'quantitative' },
|
|
32
|
+
},
|
|
33
|
+
chrome: {},
|
|
34
|
+
annotations: [],
|
|
35
|
+
responsive: true,
|
|
36
|
+
theme: {},
|
|
37
|
+
darkMode: 'off',
|
|
38
|
+
labels: { density: 'auto', format: '' },
|
|
39
|
+
hiddenSeries: [],
|
|
40
|
+
seriesStyles: {},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// markDef.point controls PointMark emission
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('markDef.point controls PointMark emission', () => {
|
|
49
|
+
it('does not emit PointMark when point is undefined (default)', () => {
|
|
50
|
+
const spec = makeSpec({ type: 'line' });
|
|
51
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
52
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
53
|
+
|
|
54
|
+
const lineMarks = marks.filter((m) => m.type === 'line');
|
|
55
|
+
const pointMarks = marks.filter((m) => m.type === 'point');
|
|
56
|
+
|
|
57
|
+
expect(lineMarks.length).toBeGreaterThan(0);
|
|
58
|
+
expect(pointMarks.length).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not emit PointMark when point is false', () => {
|
|
62
|
+
const spec = makeSpec({ type: 'line', point: false });
|
|
63
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
64
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
65
|
+
|
|
66
|
+
const pointMarks = marks.filter((m) => m.type === 'point');
|
|
67
|
+
expect(pointMarks.length).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('emits visible PointMark when point is true', () => {
|
|
71
|
+
const spec = makeSpec({ type: 'line', point: true });
|
|
72
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
73
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
74
|
+
|
|
75
|
+
const pointMarks = marks.filter((m) => m.type === 'point') as PointMark[];
|
|
76
|
+
expect(pointMarks.length).toBe(3); // One per data point
|
|
77
|
+
|
|
78
|
+
// Points should have a non-zero radius (visible)
|
|
79
|
+
for (const p of pointMarks) {
|
|
80
|
+
expect(p.r).toBeGreaterThan(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('emits transparent PointMark when point is "transparent"', () => {
|
|
85
|
+
const spec = makeSpec({ type: 'line', point: 'transparent' });
|
|
86
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
87
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
88
|
+
|
|
89
|
+
const pointMarks = marks.filter((m) => m.type === 'point') as PointMark[];
|
|
90
|
+
expect(pointMarks.length).toBe(3);
|
|
91
|
+
|
|
92
|
+
// Transparent points have zero radius and zero opacity
|
|
93
|
+
for (const p of pointMarks) {
|
|
94
|
+
expect(p.r).toBe(0);
|
|
95
|
+
expect(p.fillOpacity).toBe(0);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// dataPoints on LineMark
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe('LineMark.dataPoints', () => {
|
|
105
|
+
it('populates dataPoints with pixel coordinates and original data', () => {
|
|
106
|
+
const spec = makeSpec({ type: 'line' });
|
|
107
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
108
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
109
|
+
|
|
110
|
+
const lineMarks = marks.filter((m) => m.type === 'line') as LineMark[];
|
|
111
|
+
expect(lineMarks.length).toBe(1);
|
|
112
|
+
|
|
113
|
+
const lineMark = lineMarks[0];
|
|
114
|
+
expect(lineMark.dataPoints).toBeDefined();
|
|
115
|
+
expect(lineMark.dataPoints!.length).toBe(3);
|
|
116
|
+
|
|
117
|
+
for (const dp of lineMark.dataPoints!) {
|
|
118
|
+
expect(typeof dp.x).toBe('number');
|
|
119
|
+
expect(typeof dp.y).toBe('number');
|
|
120
|
+
expect(dp.datum).toBeDefined();
|
|
121
|
+
expect(dp.datum.date).toBeDefined();
|
|
122
|
+
expect(dp.datum.value).toBeDefined();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// markDef.interpolate affects line path computation
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
describe('markDef.interpolate affects line generation', () => {
|
|
132
|
+
it('produces different paths for different interpolation modes', () => {
|
|
133
|
+
const defaultSpec = makeSpec({ type: 'line' });
|
|
134
|
+
const linearSpec = makeSpec({ type: 'line', interpolate: 'linear' });
|
|
135
|
+
const stepSpec = makeSpec({ type: 'line', interpolate: 'step' });
|
|
136
|
+
|
|
137
|
+
const defaultScales = computeScales(defaultSpec, chartArea, defaultSpec.data);
|
|
138
|
+
const linearScales = computeScales(linearSpec, chartArea, linearSpec.data);
|
|
139
|
+
const stepScales = computeScales(stepSpec, chartArea, stepSpec.data);
|
|
140
|
+
|
|
141
|
+
const defaultMarks = computeLineMarks(defaultSpec, defaultScales, chartArea, fullStrategy);
|
|
142
|
+
const linearMarks = computeLineMarks(linearSpec, linearScales, chartArea, fullStrategy);
|
|
143
|
+
const stepMarks = computeLineMarks(stepSpec, stepScales, chartArea, fullStrategy);
|
|
144
|
+
|
|
145
|
+
const defaultLine = defaultMarks.find((m) => m.type === 'line') as LineMark;
|
|
146
|
+
const linearLine = linearMarks.find((m) => m.type === 'line') as LineMark;
|
|
147
|
+
const stepLine = stepMarks.find((m) => m.type === 'line') as LineMark;
|
|
148
|
+
|
|
149
|
+
// Default (monotone) and linear should produce different paths
|
|
150
|
+
expect(defaultLine.path).not.toBe(linearLine.path);
|
|
151
|
+
// Linear and step should produce different paths
|
|
152
|
+
expect(linearLine.path).not.toBe(stepLine.path);
|
|
153
|
+
// All paths should be non-empty
|
|
154
|
+
expect(defaultLine.path!.length).toBeGreaterThan(0);
|
|
155
|
+
expect(linearLine.path!.length).toBeGreaterThan(0);
|
|
156
|
+
expect(stepLine.path!.length).toBeGreaterThan(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('uses monotone interpolation by default', () => {
|
|
160
|
+
const spec = makeSpec({ type: 'line' });
|
|
161
|
+
const monotoneSpec = makeSpec({ type: 'line', interpolate: 'monotone' });
|
|
162
|
+
|
|
163
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
164
|
+
const monotoneScales = computeScales(monotoneSpec, chartArea, monotoneSpec.data);
|
|
165
|
+
|
|
166
|
+
const defaultMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
167
|
+
const monotoneMarks = computeLineMarks(monotoneSpec, monotoneScales, chartArea, fullStrategy);
|
|
168
|
+
|
|
169
|
+
const defaultLine = defaultMarks.find((m) => m.type === 'line') as LineMark;
|
|
170
|
+
const monotoneLine = monotoneMarks.find((m) => m.type === 'line') as LineMark;
|
|
171
|
+
|
|
172
|
+
// Default and explicit monotone should produce the same path
|
|
173
|
+
expect(defaultLine.path).toBe(monotoneLine.path);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/charts/line/area.ts
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
|
|
10
10
|
import type { ScaleLinear } from 'd3-scale';
|
|
11
|
-
import { area,
|
|
11
|
+
import { area, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
|
|
12
12
|
|
|
13
13
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
14
14
|
import type { ResolvedScales } from '../../layout/scales';
|
|
15
15
|
import { getColor, scaleValue, sortByField } from '../utils';
|
|
16
|
+
import { resolveCurve } from './curves';
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Constants
|
|
@@ -42,7 +43,7 @@ function computeSingleArea(
|
|
|
42
43
|
const baselineY = yScale(Math.min(domain[0], domain[1]));
|
|
43
44
|
|
|
44
45
|
// Group by color field
|
|
45
|
-
const colorField = encoding.color
|
|
46
|
+
const colorField = encoding.color && 'field' in encoding.color ? encoding.color.field : undefined;
|
|
46
47
|
const groups = new Map<string, DataRow[]>();
|
|
47
48
|
|
|
48
49
|
if (!colorField) {
|
|
@@ -86,12 +87,13 @@ function computeSingleArea(
|
|
|
86
87
|
|
|
87
88
|
if (validPoints.length === 0) continue;
|
|
88
89
|
|
|
89
|
-
// Build the area path
|
|
90
|
+
// Build the area path with configured interpolation
|
|
91
|
+
const curve = resolveCurve(spec.markDef.interpolate);
|
|
90
92
|
const areaGenerator = area<{ x: number; yTop: number; yBottom: number }>()
|
|
91
93
|
.x((d) => d.x)
|
|
92
94
|
.y0((d) => d.yBottom)
|
|
93
95
|
.y1((d) => d.yTop)
|
|
94
|
-
.curve(
|
|
96
|
+
.curve(curve);
|
|
95
97
|
|
|
96
98
|
const pathStr = areaGenerator(validPoints) ?? '';
|
|
97
99
|
|
|
@@ -99,7 +101,7 @@ function computeSingleArea(
|
|
|
99
101
|
const topLineGenerator = line<{ x: number; yTop: number }>()
|
|
100
102
|
.x((d) => d.x)
|
|
101
103
|
.y((d) => d.yTop)
|
|
102
|
-
.curve(
|
|
104
|
+
.curve(curve);
|
|
103
105
|
const topPathStr = topLineGenerator(validPoints) ?? '';
|
|
104
106
|
|
|
105
107
|
const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
|
|
@@ -124,6 +126,7 @@ function computeSingleArea(
|
|
|
124
126
|
strokeWidth: 2,
|
|
125
127
|
seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
|
|
126
128
|
data: validPoints.map((p) => p.row),
|
|
129
|
+
dataPoints: validPoints.map((p) => ({ x: p.x, y: p.yTop, datum: p.row })),
|
|
127
130
|
aria,
|
|
128
131
|
});
|
|
129
132
|
}
|
|
@@ -143,7 +146,7 @@ function computeStackedArea(
|
|
|
143
146
|
const encoding = spec.encoding as Encoding;
|
|
144
147
|
const xChannel = encoding.x;
|
|
145
148
|
const yChannel = encoding.y;
|
|
146
|
-
const colorField = encoding.color
|
|
149
|
+
const colorField = encoding.color && 'field' in encoding.color ? encoding.color.field : undefined;
|
|
147
150
|
|
|
148
151
|
if (!xChannel || !yChannel || !scales.x || !scales.y || !colorField) {
|
|
149
152
|
// If no color field, can't stack -- fall back to single area
|
|
@@ -225,18 +228,19 @@ function computeStackedArea(
|
|
|
225
228
|
|
|
226
229
|
if (validPoints.length === 0) continue;
|
|
227
230
|
|
|
231
|
+
const stackCurve = resolveCurve(spec.markDef.interpolate);
|
|
228
232
|
const areaGenerator = area<{ x: number; yTop: number; yBottom: number }>()
|
|
229
233
|
.x((p) => p.x)
|
|
230
234
|
.y0((p) => p.yBottom)
|
|
231
235
|
.y1((p) => p.yTop)
|
|
232
|
-
.curve(
|
|
236
|
+
.curve(stackCurve);
|
|
233
237
|
|
|
234
238
|
const pathStr = areaGenerator(validPoints) ?? '';
|
|
235
239
|
|
|
236
240
|
const topLineGenerator = line<{ x: number; yTop: number }>()
|
|
237
241
|
.x((p) => p.x)
|
|
238
242
|
.y((p) => p.yTop)
|
|
239
|
-
.curve(
|
|
243
|
+
.curve(stackCurve);
|
|
240
244
|
const topPathStr = topLineGenerator(validPoints) ?? '';
|
|
241
245
|
|
|
242
246
|
const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
|
|
@@ -261,6 +265,13 @@ function computeStackedArea(
|
|
|
261
265
|
const xStr = String(d.data.__x__);
|
|
262
266
|
return (rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? d.data) as Record<string, unknown>;
|
|
263
267
|
}),
|
|
268
|
+
dataPoints: validPoints.map((p, idx) => {
|
|
269
|
+
const xStr = String(layer[idx]?.data.__x__);
|
|
270
|
+
const datum = (rowsByXSeries.get(`${xStr}::${seriesKey}`) ??
|
|
271
|
+
layer[idx]?.data ??
|
|
272
|
+
{}) as Record<string, unknown>;
|
|
273
|
+
return { x: p.x, y: p.yTop, datum };
|
|
274
|
+
}),
|
|
264
275
|
aria,
|
|
265
276
|
});
|
|
266
277
|
}
|
|
@@ -16,11 +16,12 @@ import type {
|
|
|
16
16
|
PointMark,
|
|
17
17
|
Rect,
|
|
18
18
|
} from '@opendata-ai/openchart-core';
|
|
19
|
-
import {
|
|
19
|
+
import { line } from 'd3-shape';
|
|
20
20
|
|
|
21
21
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
22
22
|
import type { ResolvedScales } from '../../layout/scales';
|
|
23
|
-
import { getColor, groupByField, scaleValue, sortByField } from '../utils';
|
|
23
|
+
import { getColor, getSequentialColor, groupByField, scaleValue, sortByField } from '../utils';
|
|
24
|
+
import { resolveCurve } from './curves';
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// Constants
|
|
@@ -57,12 +58,19 @@ export function computeLineMarks(
|
|
|
57
58
|
return [];
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
const
|
|
61
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
62
|
+
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
63
|
+
// Sequential color: single series, per-point coloring. Categorical: group by color field.
|
|
64
|
+
const colorField = isSequentialColor ? undefined : colorEnc?.field;
|
|
65
|
+
const sequentialColorField = isSequentialColor ? colorEnc.field : undefined;
|
|
61
66
|
const groups = groupByField(spec.data, colorField);
|
|
62
67
|
const marks: (LineMark | PointMark)[] = [];
|
|
63
68
|
|
|
64
69
|
for (const [seriesKey, rows] of groups) {
|
|
65
|
-
|
|
70
|
+
// For sequential color, use a mid-range color for the line stroke
|
|
71
|
+
const color = isSequentialColor
|
|
72
|
+
? getSequentialColor(scales, _getMidValue(rows, sequentialColorField!))
|
|
73
|
+
: getColor(scales, seriesKey);
|
|
66
74
|
|
|
67
75
|
// Sort rows by x-axis field so lines draw left-to-right
|
|
68
76
|
const sortedRows = sortByField(rows, xChannel.field);
|
|
@@ -102,11 +110,12 @@ export function computeLineMarks(
|
|
|
102
110
|
segments.push(currentSegment);
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
// Build the D3 line generator with
|
|
113
|
+
// Build the D3 line generator with configured interpolation
|
|
114
|
+
const curve = resolveCurve(spec.markDef.interpolate);
|
|
106
115
|
const lineGenerator = line<{ x: number; y: number }>()
|
|
107
116
|
.x((d) => d.x)
|
|
108
117
|
.y((d) => d.y)
|
|
109
|
-
.curve(
|
|
118
|
+
.curve(curve);
|
|
110
119
|
|
|
111
120
|
// Combine all segments into a single path string with M/L commands.
|
|
112
121
|
// Each segment starts a new M (moveto) command, creating line breaks
|
|
@@ -151,6 +160,7 @@ export function computeLineMarks(
|
|
|
151
160
|
|
|
152
161
|
// Create the LineMark with the combined path points.
|
|
153
162
|
// The points array includes all valid points across all segments.
|
|
163
|
+
// dataPoints carries pixel coordinates + original data for voronoi tooltip overlay.
|
|
154
164
|
const lineMark: LineMark = {
|
|
155
165
|
type: 'line',
|
|
156
166
|
points: allPoints,
|
|
@@ -161,32 +171,61 @@ export function computeLineMarks(
|
|
|
161
171
|
opacity: styleOverride?.opacity,
|
|
162
172
|
seriesKey: seriesStyleKey,
|
|
163
173
|
data: pointsWithData.map((p) => p.row),
|
|
174
|
+
dataPoints: pointsWithData.map((p) => ({ x: p.x, y: p.y, datum: p.row })),
|
|
164
175
|
aria,
|
|
165
176
|
};
|
|
166
177
|
|
|
167
178
|
marks.push(lineMark);
|
|
168
179
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
// Emit PointMark objects when markDef.point is truthy, or when sequential
|
|
181
|
+
// color is active (points carry the gradient since SVG paths are single-color).
|
|
182
|
+
const markPoint = spec.markDef.point;
|
|
183
|
+
const showPoints = markPoint === true || markPoint === 'transparent' || isSequentialColor;
|
|
184
|
+
|
|
185
|
+
if (showPoints) {
|
|
186
|
+
const isTransparent = markPoint === 'transparent';
|
|
187
|
+
// Also respect per-series showPoints override
|
|
188
|
+
const seriesShowPoints = styleOverride?.showPoints !== false;
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < pointsWithData.length; i++) {
|
|
191
|
+
const p = pointsWithData[i];
|
|
192
|
+
const visible = seriesShowPoints && !isTransparent;
|
|
193
|
+
// Sequential color: each point gets colored by its data value
|
|
194
|
+
let pointColor = color;
|
|
195
|
+
if (isSequentialColor) {
|
|
196
|
+
const val = Number(p.row[sequentialColorField!]);
|
|
197
|
+
pointColor = Number.isFinite(val) ? getSequentialColor(scales, val) : color;
|
|
198
|
+
}
|
|
199
|
+
const pointMark: PointMark = {
|
|
200
|
+
type: 'point',
|
|
201
|
+
cx: p.x,
|
|
202
|
+
cy: p.y,
|
|
203
|
+
r: visible ? DEFAULT_POINT_RADIUS : 0,
|
|
204
|
+
fill: pointColor,
|
|
205
|
+
stroke: visible ? '#ffffff' : 'transparent',
|
|
206
|
+
strokeWidth: visible ? 1.5 : 0,
|
|
207
|
+
fillOpacity: isTransparent ? 0 : 1,
|
|
208
|
+
data: p.row,
|
|
209
|
+
aria: {
|
|
210
|
+
label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
marks.push(pointMark);
|
|
214
|
+
}
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
return marks;
|
|
192
219
|
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Helpers
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/** Get the midpoint numeric value of a field across rows (for sequential line stroke). */
|
|
226
|
+
function _getMidValue(rows: DataRow[], field: string): number {
|
|
227
|
+
const values = rows.map((r) => Number(r[field])).filter(Number.isFinite);
|
|
228
|
+
if (values.length === 0) return 0;
|
|
229
|
+
const sorted = values.sort((a, b) => a - b);
|
|
230
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
231
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curve interpolation mapping.
|
|
3
|
+
*
|
|
4
|
+
* Maps Vega-Lite-style interpolation strings to d3-shape curve factories.
|
|
5
|
+
* Used by both line and area chart computations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MarkDef } from '@opendata-ai/openchart-core';
|
|
9
|
+
import type { CurveFactory } from 'd3-shape';
|
|
10
|
+
import {
|
|
11
|
+
curveBasis,
|
|
12
|
+
curveCardinal,
|
|
13
|
+
curveLinear,
|
|
14
|
+
curveMonotoneX,
|
|
15
|
+
curveNatural,
|
|
16
|
+
curveStep,
|
|
17
|
+
curveStepAfter,
|
|
18
|
+
curveStepBefore,
|
|
19
|
+
} from 'd3-shape';
|
|
20
|
+
|
|
21
|
+
/** Map of interpolation string names to d3 curve factories. */
|
|
22
|
+
const CURVE_MAP: Record<NonNullable<MarkDef['interpolate']>, CurveFactory> = {
|
|
23
|
+
linear: curveLinear,
|
|
24
|
+
monotone: curveMonotoneX,
|
|
25
|
+
step: curveStep,
|
|
26
|
+
'step-before': curveStepBefore,
|
|
27
|
+
'step-after': curveStepAfter,
|
|
28
|
+
basis: curveBasis,
|
|
29
|
+
cardinal: curveCardinal,
|
|
30
|
+
natural: curveNatural,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve an interpolation string to a d3 curve factory.
|
|
35
|
+
* Defaults to `curveMonotoneX` when no interpolation is specified.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveCurve(interpolate?: MarkDef['interpolate']): CurveFactory {
|
|
38
|
+
if (!interpolate) return curveMonotoneX;
|
|
39
|
+
return CURVE_MAP[interpolate] ?? curveMonotoneX;
|
|
40
|
+
}
|
|
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
20
20
|
|
|
21
21
|
function makeBasicPieSpec(): NormalizedChartSpec {
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
markType: 'arc',
|
|
24
|
+
markDef: { type: 'arc' },
|
|
24
25
|
data: [
|
|
25
26
|
{ category: 'A', value: 40 },
|
|
26
27
|
{ category: 'B', value: 30 },
|
|
@@ -42,7 +43,8 @@ function makeBasicPieSpec(): NormalizedChartSpec {
|
|
|
42
43
|
|
|
43
44
|
function makeSmallSlicePieSpec(): NormalizedChartSpec {
|
|
44
45
|
return {
|
|
45
|
-
|
|
46
|
+
markType: 'arc',
|
|
47
|
+
markDef: { type: 'arc' },
|
|
46
48
|
data: [
|
|
47
49
|
{ category: 'Big', value: 90 },
|
|
48
50
|
{ category: 'Medium', value: 7 },
|
|
@@ -65,7 +67,8 @@ function makeSmallSlicePieSpec(): NormalizedChartSpec {
|
|
|
65
67
|
|
|
66
68
|
function makeDonutSpec(): NormalizedChartSpec {
|
|
67
69
|
return {
|
|
68
|
-
|
|
70
|
+
markType: 'arc',
|
|
71
|
+
markDef: { type: 'arc', innerRadius: 0.5 },
|
|
69
72
|
data: [
|
|
70
73
|
{ segment: 'Desktop', users: 55 },
|
|
71
74
|
{ segment: 'Mobile', users: 35 },
|
|
@@ -209,7 +212,8 @@ describe('computePieMarks', () => {
|
|
|
209
212
|
describe('edge cases', () => {
|
|
210
213
|
it('returns empty array when no value encoding', () => {
|
|
211
214
|
const spec: NormalizedChartSpec = {
|
|
212
|
-
|
|
215
|
+
markType: 'arc',
|
|
216
|
+
markDef: { type: 'arc' },
|
|
213
217
|
data: [{ category: 'A' }],
|
|
214
218
|
encoding: {
|
|
215
219
|
color: { field: 'category', type: 'nominal' },
|
|
@@ -228,7 +232,8 @@ describe('computePieMarks', () => {
|
|
|
228
232
|
|
|
229
233
|
it('returns empty array for empty data', () => {
|
|
230
234
|
const spec: NormalizedChartSpec = {
|
|
231
|
-
|
|
235
|
+
markType: 'arc',
|
|
236
|
+
markDef: { type: 'arc' },
|
|
232
237
|
data: [],
|
|
233
238
|
encoding: {
|
|
234
239
|
y: { field: 'value', type: 'quantitative' },
|
|
@@ -107,7 +107,8 @@ export function computePieMarks(
|
|
|
107
107
|
// For pie/donut charts, we need a value field (typically y or x) and
|
|
108
108
|
// a category field (typically color). The value field provides the slice sizes.
|
|
109
109
|
const valueChannel = encoding.y ?? encoding.x;
|
|
110
|
-
const categoryField =
|
|
110
|
+
const categoryField =
|
|
111
|
+
encoding.color && 'field' in encoding.color ? encoding.color.field : undefined;
|
|
111
112
|
|
|
112
113
|
if (!valueChannel) return [];
|
|
113
114
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule mark renderer.
|
|
3
|
+
*
|
|
4
|
+
* Computes RuleMarkLayout marks from a normalized chart spec.
|
|
5
|
+
* Rules are line segments, typically used for reference lines as data marks.
|
|
6
|
+
* Supports x, y, x2, y2 encoding channels for start/end positioning.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Encoding, Mark, MarkAria, Rect, RuleMarkLayout } 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 rule marks from spec data and resolved scales.
|
|
18
|
+
*
|
|
19
|
+
* Positioning logic:
|
|
20
|
+
* - x only: vertical line spanning full chart height
|
|
21
|
+
* - y only: horizontal line spanning full chart width
|
|
22
|
+
* - x + x2: horizontal segment at the y position (or spanning full height)
|
|
23
|
+
* - y + y2: vertical segment at the x position (or spanning full width)
|
|
24
|
+
* - x + y + x2 + y2: arbitrary line segment
|
|
25
|
+
*/
|
|
26
|
+
export function computeRuleMarks(
|
|
27
|
+
spec: NormalizedChartSpec,
|
|
28
|
+
scales: ResolvedScales,
|
|
29
|
+
chartArea: Rect,
|
|
30
|
+
): RuleMarkLayout[] {
|
|
31
|
+
const encoding = spec.encoding as Encoding;
|
|
32
|
+
const xChannel = encoding.x;
|
|
33
|
+
const yChannel = encoding.y;
|
|
34
|
+
const x2Channel = encoding.x2;
|
|
35
|
+
const y2Channel = encoding.y2;
|
|
36
|
+
const colorEncoding = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
37
|
+
const colorField = colorEncoding?.field;
|
|
38
|
+
|
|
39
|
+
const marks: RuleMarkLayout[] = [];
|
|
40
|
+
|
|
41
|
+
for (const row of spec.data) {
|
|
42
|
+
let x1 = chartArea.x;
|
|
43
|
+
let y1 = chartArea.y;
|
|
44
|
+
let x2 = chartArea.x + chartArea.width;
|
|
45
|
+
let y2 = chartArea.y + chartArea.height;
|
|
46
|
+
|
|
47
|
+
// Resolve x position
|
|
48
|
+
if (xChannel && scales.x) {
|
|
49
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
50
|
+
if (xVal == null) continue;
|
|
51
|
+
x1 = xVal;
|
|
52
|
+
x2 = xVal; // default: vertical line (same x)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolve y position
|
|
56
|
+
if (yChannel && scales.y) {
|
|
57
|
+
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
58
|
+
if (yVal == null) continue;
|
|
59
|
+
y1 = yVal;
|
|
60
|
+
y2 = yVal; // default: horizontal line (same y)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// If x is set but not y, span full height (vertical line)
|
|
64
|
+
if (xChannel && !yChannel) {
|
|
65
|
+
y1 = chartArea.y;
|
|
66
|
+
y2 = chartArea.y + chartArea.height;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If y is set but not x, span full width (horizontal line)
|
|
70
|
+
if (yChannel && !xChannel) {
|
|
71
|
+
x1 = chartArea.x;
|
|
72
|
+
x2 = chartArea.x + chartArea.width;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Resolve x2 if present
|
|
76
|
+
if (x2Channel && scales.x) {
|
|
77
|
+
const x2Val = scaleValue(scales.x.scale, scales.x.type, row[x2Channel.field]);
|
|
78
|
+
if (x2Val != null) x2 = x2Val;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolve y2 if present
|
|
82
|
+
if (y2Channel && scales.y) {
|
|
83
|
+
const y2Val = scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field]);
|
|
84
|
+
if (y2Val != null) y2 = y2Val;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const color = colorField
|
|
88
|
+
? getColor(scales, String(row[colorField] ?? '__default__'))
|
|
89
|
+
: getColor(scales, '__default__');
|
|
90
|
+
|
|
91
|
+
const strokeDashEncoding =
|
|
92
|
+
encoding.strokeDash && 'field' in encoding.strokeDash ? encoding.strokeDash : undefined;
|
|
93
|
+
const strokeDasharray = strokeDashEncoding
|
|
94
|
+
? String(row[strokeDashEncoding.field] ?? '')
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
const aria: MarkAria = {
|
|
98
|
+
label: `Rule from (${Math.round(x1)}, ${Math.round(y1)}) to (${Math.round(x2)}, ${Math.round(y2)})`,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
marks.push({
|
|
102
|
+
type: 'rule',
|
|
103
|
+
x1,
|
|
104
|
+
y1,
|
|
105
|
+
x2,
|
|
106
|
+
y2,
|
|
107
|
+
stroke: color,
|
|
108
|
+
strokeWidth: 1,
|
|
109
|
+
strokeDasharray: strokeDasharray || undefined,
|
|
110
|
+
opacity:
|
|
111
|
+
encoding.opacity && 'field' in encoding.opacity
|
|
112
|
+
? Math.max(0, Math.min(1, Number(row[encoding.opacity.field]) || 1))
|
|
113
|
+
: undefined,
|
|
114
|
+
data: row as Record<string, unknown>,
|
|
115
|
+
aria,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return marks;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rule chart renderer.
|
|
124
|
+
*/
|
|
125
|
+
export const ruleRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, _theme) => {
|
|
126
|
+
return computeRuleMarks(spec, scales, chartArea) as Mark[];
|
|
127
|
+
};
|