@opendata-ai/openchart-engine 6.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.
Files changed (65) hide show
  1. package/dist/index.d.ts +155 -19
  2. package/dist/index.js +1513 -164
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -3
  6. package/src/__tests__/axes.test.ts +168 -4
  7. package/src/__tests__/compile-chart.test.ts +23 -12
  8. package/src/__tests__/compile-layer.test.ts +386 -0
  9. package/src/__tests__/dimensions.test.ts +6 -3
  10. package/src/__tests__/legend.test.ts +6 -3
  11. package/src/__tests__/scales.test.ts +176 -2
  12. package/src/annotations/__tests__/compute.test.ts +8 -4
  13. package/src/charts/bar/__tests__/compute.test.ts +12 -6
  14. package/src/charts/bar/compute.ts +21 -5
  15. package/src/charts/column/__tests__/compute.test.ts +14 -7
  16. package/src/charts/column/compute.ts +21 -6
  17. package/src/charts/dot/__tests__/compute.test.ts +10 -5
  18. package/src/charts/dot/compute.ts +10 -4
  19. package/src/charts/line/__tests__/compute.test.ts +102 -11
  20. package/src/charts/line/__tests__/curves.test.ts +51 -0
  21. package/src/charts/line/__tests__/labels.test.ts +2 -1
  22. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  23. package/src/charts/line/area.ts +19 -8
  24. package/src/charts/line/compute.ts +64 -25
  25. package/src/charts/line/curves.ts +40 -0
  26. package/src/charts/pie/__tests__/compute.test.ts +10 -5
  27. package/src/charts/pie/compute.ts +2 -1
  28. package/src/charts/rule/index.ts +127 -0
  29. package/src/charts/scatter/__tests__/compute.test.ts +10 -5
  30. package/src/charts/scatter/compute.ts +15 -5
  31. package/src/charts/text/index.ts +92 -0
  32. package/src/charts/tick/index.ts +84 -0
  33. package/src/charts/utils.ts +1 -1
  34. package/src/compile.ts +175 -23
  35. package/src/compiler/__tests__/compile.test.ts +4 -4
  36. package/src/compiler/__tests__/normalize.test.ts +4 -4
  37. package/src/compiler/__tests__/validate.test.ts +25 -26
  38. package/src/compiler/index.ts +1 -1
  39. package/src/compiler/normalize.ts +77 -4
  40. package/src/compiler/types.ts +6 -2
  41. package/src/compiler/validate.ts +167 -35
  42. package/src/graphs/__tests__/compile-graph.test.ts +2 -2
  43. package/src/graphs/compile-graph.ts +2 -2
  44. package/src/index.ts +17 -1
  45. package/src/layout/axes.ts +122 -20
  46. package/src/layout/dimensions.ts +15 -9
  47. package/src/layout/scales.ts +320 -31
  48. package/src/legend/compute.ts +9 -6
  49. package/src/tables/__tests__/compile-table.test.ts +1 -1
  50. package/src/tooltips/__tests__/compute.test.ts +10 -5
  51. package/src/tooltips/compute.ts +32 -14
  52. package/src/transforms/__tests__/bin.test.ts +88 -0
  53. package/src/transforms/__tests__/calculate.test.ts +146 -0
  54. package/src/transforms/__tests__/conditional.test.ts +109 -0
  55. package/src/transforms/__tests__/filter.test.ts +59 -0
  56. package/src/transforms/__tests__/index.test.ts +93 -0
  57. package/src/transforms/__tests__/predicates.test.ts +176 -0
  58. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  59. package/src/transforms/bin.ts +87 -0
  60. package/src/transforms/calculate.ts +60 -0
  61. package/src/transforms/conditional.ts +46 -0
  62. package/src/transforms/filter.ts +17 -0
  63. package/src/transforms/index.ts +48 -0
  64. package/src/transforms/predicates.ts +90 -0
  65. 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
+ });
@@ -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, curveMonotoneX, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
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?.field;
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(curveMonotoneX);
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(curveMonotoneX);
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?.field;
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(curveMonotoneX);
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(curveMonotoneX);
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 { curveMonotoneX, line } from 'd3-shape';
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 colorField = encoding.color?.field;
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
- const color = getColor(scales, seriesKey);
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 monotone interpolation
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(curveMonotoneX);
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
- // Create point marks for hover targets (skip if showPoints is false)
170
- const showPoints = styleOverride?.showPoints !== false;
171
- for (let i = 0; i < pointsWithData.length; i++) {
172
- const p = pointsWithData[i];
173
- const pointMark: PointMark = {
174
- type: 'point',
175
- cx: p.x,
176
- cy: p.y,
177
- r: showPoints ? DEFAULT_POINT_RADIUS : 0,
178
- fill: color,
179
- stroke: showPoints ? '#ffffff' : 'transparent',
180
- strokeWidth: showPoints ? 1.5 : 0,
181
- fillOpacity: 0,
182
- data: p.row,
183
- aria: {
184
- label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`,
185
- },
186
- };
187
- marks.push(pointMark);
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
- type: 'pie',
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
- type: 'pie',
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
- type: 'donut',
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
- type: 'pie',
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
- type: 'pie',
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 = encoding.color?.field;
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
+ };