@opendata-ai/openchart-engine 1.2.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 (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Area chart mark computation.
3
+ *
4
+ * Uses D3 area() generator to produce AreaMark[] with top/bottom
5
+ * boundary points and SVG path strings. Supports single areas and
6
+ * stacked areas via d3-shape stack layout.
7
+ */
8
+
9
+ import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
10
+ import type { ScaleLinear } from 'd3-scale';
11
+ import { area, curveMonotoneX, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
12
+
13
+ import type { NormalizedChartSpec } from '../../compiler/types';
14
+ import type { ResolvedScales } from '../../layout/scales';
15
+ import { getColor, scaleValue } from '../utils';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const DEFAULT_FILL_OPACITY = 0.15;
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Single area (non-stacked)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function computeSingleArea(
28
+ spec: NormalizedChartSpec,
29
+ scales: ResolvedScales,
30
+ _chartArea: Rect,
31
+ ): AreaMark[] {
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 yScale = scales.y.scale as ScaleLinear<number, number>;
39
+ // Use the domain minimum as the baseline so the area fill doesn't drop
40
+ // below the visible scale range when zero: false excludes 0 from the domain.
41
+ const domain = yScale.domain();
42
+ const baselineY = yScale(Math.min(domain[0], domain[1]));
43
+
44
+ // Group by color field
45
+ const colorField = encoding.color?.field;
46
+ const groups = new Map<string, DataRow[]>();
47
+
48
+ if (!colorField) {
49
+ groups.set('__default__', spec.data);
50
+ } else {
51
+ for (const row of spec.data) {
52
+ const key = String(row[colorField] ?? '__default__');
53
+ const existing = groups.get(key);
54
+ if (existing) {
55
+ existing.push(row);
56
+ } else {
57
+ groups.set(key, [row]);
58
+ }
59
+ }
60
+ }
61
+
62
+ const marks: AreaMark[] = [];
63
+
64
+ for (const [seriesKey, rows] of groups) {
65
+ const color = getColor(scales, seriesKey);
66
+
67
+ // Compute points, filtering out null values
68
+ const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
69
+
70
+ for (const row of rows) {
71
+ const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
72
+ const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
73
+
74
+ if (xVal === null || yVal === null) continue;
75
+
76
+ validPoints.push({
77
+ x: xVal,
78
+ yTop: yVal,
79
+ yBottom: baselineY,
80
+ row,
81
+ });
82
+ }
83
+
84
+ if (validPoints.length === 0) continue;
85
+
86
+ // Build the area path
87
+ const areaGenerator = area<{ x: number; yTop: number; yBottom: number }>()
88
+ .x((d) => d.x)
89
+ .y0((d) => d.yBottom)
90
+ .y1((d) => d.yTop)
91
+ .curve(curveMonotoneX);
92
+
93
+ const pathStr = areaGenerator(validPoints) ?? '';
94
+
95
+ // Top-line path for stroking only the data line (not the baseline)
96
+ const topLineGenerator = line<{ x: number; yTop: number }>()
97
+ .x((d) => d.x)
98
+ .y((d) => d.yTop)
99
+ .curve(curveMonotoneX);
100
+ const topPathStr = topLineGenerator(validPoints) ?? '';
101
+
102
+ const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
103
+ const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
104
+
105
+ const ariaLabel =
106
+ seriesKey === '__default__'
107
+ ? `Area with ${validPoints.length} data points`
108
+ : `${seriesKey}: area with ${validPoints.length} data points`;
109
+
110
+ const aria: MarkAria = { label: ariaLabel };
111
+
112
+ marks.push({
113
+ type: 'area',
114
+ topPoints,
115
+ bottomPoints,
116
+ path: pathStr,
117
+ topPath: topPathStr,
118
+ fill: color,
119
+ fillOpacity: DEFAULT_FILL_OPACITY,
120
+ stroke: color,
121
+ strokeWidth: 2,
122
+ seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
123
+ data: validPoints.map((p) => p.row),
124
+ aria,
125
+ });
126
+ }
127
+
128
+ return marks;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Stacked area
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function computeStackedArea(
136
+ spec: NormalizedChartSpec,
137
+ scales: ResolvedScales,
138
+ chartArea: Rect,
139
+ ): AreaMark[] {
140
+ const encoding = spec.encoding as Encoding;
141
+ const xChannel = encoding.x;
142
+ const yChannel = encoding.y;
143
+ const colorField = encoding.color?.field;
144
+
145
+ if (!xChannel || !yChannel || !scales.x || !scales.y || !colorField) {
146
+ // If no color field, can't stack -- fall back to single area
147
+ return computeSingleArea(spec, scales, chartArea);
148
+ }
149
+
150
+ // Collect unique series keys and x values, and build a lookup from
151
+ // (x-value, series-key) -> original data row so stacked area marks
152
+ // get original rows instead of pivot rows.
153
+ const seriesKeys = new Set<string>();
154
+ const xValueSet = new Set<string>();
155
+ const rowsByXSeries = new Map<string, DataRow>();
156
+ const rowsByX = new Map<string, DataRow[]>();
157
+
158
+ for (const row of spec.data) {
159
+ const xStr = String(row[xChannel.field]);
160
+ const series = String(row[colorField]);
161
+ seriesKeys.add(series);
162
+ xValueSet.add(xStr);
163
+ rowsByXSeries.set(`${xStr}::${series}`, row);
164
+
165
+ const existing = rowsByX.get(xStr);
166
+ if (existing) {
167
+ existing.push(row);
168
+ } else {
169
+ rowsByX.set(xStr, [row]);
170
+ }
171
+ }
172
+
173
+ const keys = Array.from(seriesKeys);
174
+ const xValues = Array.from(xValueSet);
175
+
176
+ // Build a pivot table: one row per x value, one column per series
177
+ const pivotData: Record<string, unknown>[] = xValues.map((xVal) => {
178
+ const pivot: Record<string, unknown> = { __x__: xVal };
179
+ for (const key of keys) {
180
+ pivot[key] = 0;
181
+ }
182
+ // Fill in actual values from pre-grouped data
183
+ const xRows = rowsByX.get(xVal);
184
+ if (xRows) {
185
+ for (const row of xRows) {
186
+ const series = String(row[colorField]);
187
+ pivot[series] = row[yChannel.field] ?? 0;
188
+ }
189
+ }
190
+ return pivot;
191
+ });
192
+
193
+ // Use d3 stack to compute the stacked layout
194
+ const stackGenerator = stack<Record<string, unknown>>()
195
+ .keys(keys)
196
+ .order(stackOrderNone)
197
+ .offset(stackOffsetNone);
198
+
199
+ const stackedData = stackGenerator(pivotData);
200
+ const yScale = scales.y.scale as ScaleLinear<number, number>;
201
+ const marks: AreaMark[] = [];
202
+
203
+ for (const layer of stackedData) {
204
+ const seriesKey = layer.key;
205
+ const color = getColor(scales, seriesKey);
206
+
207
+ const validPoints: { x: number; yTop: number; yBottom: number }[] = [];
208
+
209
+ for (const d of layer) {
210
+ const xVal = scaleValue(scales.x.scale, scales.x.type, d.data.__x__);
211
+
212
+ if (xVal === null) continue;
213
+
214
+ const yTop = yScale(d[1] as number);
215
+ const yBottom = yScale(d[0] as number);
216
+
217
+ validPoints.push({ x: xVal, yTop, yBottom });
218
+ }
219
+
220
+ if (validPoints.length === 0) continue;
221
+
222
+ const areaGenerator = area<{ x: number; yTop: number; yBottom: number }>()
223
+ .x((p) => p.x)
224
+ .y0((p) => p.yBottom)
225
+ .y1((p) => p.yTop)
226
+ .curve(curveMonotoneX);
227
+
228
+ const pathStr = areaGenerator(validPoints) ?? '';
229
+
230
+ const topLineGenerator = line<{ x: number; yTop: number }>()
231
+ .x((p) => p.x)
232
+ .y((p) => p.yTop)
233
+ .curve(curveMonotoneX);
234
+ const topPathStr = topLineGenerator(validPoints) ?? '';
235
+
236
+ const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
237
+ const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
238
+
239
+ const aria: MarkAria = {
240
+ label: `${seriesKey}: stacked area with ${validPoints.length} data points`,
241
+ };
242
+
243
+ marks.push({
244
+ type: 'area',
245
+ topPoints,
246
+ bottomPoints,
247
+ path: pathStr,
248
+ topPath: topPathStr,
249
+ fill: color,
250
+ fillOpacity: 0.7, // Higher opacity for stacked so layers are visible
251
+ stroke: color,
252
+ strokeWidth: 1,
253
+ seriesKey,
254
+ data: layer.map((d) => {
255
+ const xStr = String(d.data.__x__);
256
+ return (rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? d.data) as Record<string, unknown>;
257
+ }),
258
+ aria,
259
+ });
260
+ }
261
+
262
+ return marks;
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Public API
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * Compute area marks from a normalized chart spec.
271
+ *
272
+ * For multi-series with color encoding, produces stacked areas.
273
+ * For single series, produces a simple area fill from the line to baseline (y=0).
274
+ */
275
+ export function computeAreaMarks(
276
+ spec: NormalizedChartSpec,
277
+ scales: ResolvedScales,
278
+ chartArea: Rect,
279
+ ): AreaMark[] {
280
+ const encoding = spec.encoding as Encoding;
281
+ const hasColor = !!encoding.color;
282
+
283
+ if (hasColor) {
284
+ return computeStackedArea(spec, scales, chartArea);
285
+ }
286
+
287
+ return computeSingleArea(spec, scales, chartArea);
288
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Line chart mark computation.
3
+ *
4
+ * Takes a normalized chart spec with resolved scales and produces
5
+ * LineMark[] and PointMark[] arrays for rendering. Groups data by
6
+ * color field for multi-series, uses D3 line() generator for SVG
7
+ * path computation, and handles missing data with line breaks.
8
+ */
9
+
10
+ import type {
11
+ DataRow,
12
+ Encoding,
13
+ LayoutStrategy,
14
+ LineMark,
15
+ MarkAria,
16
+ PointMark,
17
+ Rect,
18
+ } from '@opendata-ai/openchart-core';
19
+ import { curveMonotoneX, line } from 'd3-shape';
20
+
21
+ import type { NormalizedChartSpec } from '../../compiler/types';
22
+ import type { ResolvedScales } from '../../layout/scales';
23
+ import { getColor, groupByField, scaleValue } from '../utils';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Default stroke width for line marks. */
30
+ const DEFAULT_STROKE_WIDTH = 2.5;
31
+
32
+ /** Default radius for point marks (hover targets). */
33
+ const DEFAULT_POINT_RADIUS = 3;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Public API
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Compute line marks from a normalized chart spec.
41
+ *
42
+ * Produces one LineMark per series (grouped by color field) plus
43
+ * PointMark entries at each data point for hover targets. Missing
44
+ * data (null/undefined y values) breaks the line path.
45
+ */
46
+ export function computeLineMarks(
47
+ spec: NormalizedChartSpec,
48
+ scales: ResolvedScales,
49
+ _chartArea: Rect,
50
+ _strategy: LayoutStrategy,
51
+ ): (LineMark | PointMark)[] {
52
+ const encoding = spec.encoding as Encoding;
53
+ const xChannel = encoding.x;
54
+ const yChannel = encoding.y;
55
+
56
+ if (!xChannel || !yChannel || !scales.x || !scales.y) {
57
+ return [];
58
+ }
59
+
60
+ const colorField = encoding.color?.field;
61
+ const groups = groupByField(spec.data, colorField);
62
+ const marks: (LineMark | PointMark)[] = [];
63
+
64
+ for (const [seriesKey, rows] of groups) {
65
+ const color = getColor(scales, seriesKey);
66
+
67
+ // Compute pixel positions for each data point, preserving nulls
68
+ // for line break handling
69
+ const pointsWithData: {
70
+ x: number;
71
+ y: number;
72
+ row: DataRow;
73
+ }[] = [];
74
+
75
+ // We need to track segments separated by null values
76
+ const segments: { x: number; y: number }[][] = [];
77
+ let currentSegment: { x: number; y: number }[] = [];
78
+
79
+ for (const row of rows) {
80
+ const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
81
+ const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
82
+
83
+ if (xVal === null || yVal === null) {
84
+ // Break the line here. Push current segment if non-empty.
85
+ if (currentSegment.length > 0) {
86
+ segments.push(currentSegment);
87
+ currentSegment = [];
88
+ }
89
+ continue;
90
+ }
91
+
92
+ const point = { x: xVal, y: yVal };
93
+ currentSegment.push(point);
94
+ pointsWithData.push({ ...point, row });
95
+ }
96
+
97
+ // Push the last segment
98
+ if (currentSegment.length > 0) {
99
+ segments.push(currentSegment);
100
+ }
101
+
102
+ // Build the D3 line generator with monotone interpolation
103
+ const lineGenerator = line<{ x: number; y: number }>()
104
+ .x((d) => d.x)
105
+ .y((d) => d.y)
106
+ .curve(curveMonotoneX);
107
+
108
+ // Combine all segments into a single path string with M/L commands.
109
+ // Each segment starts a new M (moveto) command, creating line breaks
110
+ // where data is missing.
111
+ const allPoints: { x: number; y: number }[] = [];
112
+ const pathParts: string[] = [];
113
+
114
+ for (const segment of segments) {
115
+ if (segment.length === 0) continue;
116
+ const pathStr = lineGenerator(segment);
117
+ if (pathStr) {
118
+ pathParts.push(pathStr);
119
+ }
120
+ allPoints.push(...segment);
121
+ }
122
+
123
+ // Skip this series if there are no valid data points
124
+ if (allPoints.length === 0) continue;
125
+
126
+ const ariaLabel =
127
+ seriesKey === '__default__'
128
+ ? `Line with ${allPoints.length} data points`
129
+ : `${seriesKey}: line with ${allPoints.length} data points`;
130
+
131
+ const aria: MarkAria = {
132
+ label: ariaLabel,
133
+ };
134
+
135
+ // Combine D3 curve path segments into a single path string.
136
+ // Each segment produces a smooth monotone curve; line breaks between
137
+ // segments are created by starting a new M command.
138
+ const combinedPath = pathParts.join(' ');
139
+
140
+ // Create the LineMark with the combined path points.
141
+ // The points array includes all valid points across all segments.
142
+ const lineMark: LineMark = {
143
+ type: 'line',
144
+ points: allPoints,
145
+ path: combinedPath,
146
+ stroke: color,
147
+ strokeWidth: DEFAULT_STROKE_WIDTH,
148
+ seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
149
+ data: pointsWithData.map((p) => p.row),
150
+ aria,
151
+ };
152
+
153
+ marks.push(lineMark);
154
+
155
+ // Create point marks for hover targets
156
+ for (let i = 0; i < pointsWithData.length; i++) {
157
+ const p = pointsWithData[i];
158
+ const pointMark: PointMark = {
159
+ type: 'point',
160
+ cx: p.x,
161
+ cy: p.y,
162
+ r: DEFAULT_POINT_RADIUS,
163
+ fill: color,
164
+ stroke: '#ffffff',
165
+ strokeWidth: 1.5,
166
+ fillOpacity: 0,
167
+ data: p.row,
168
+ aria: {
169
+ label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`,
170
+ },
171
+ };
172
+ marks.push(pointMark);
173
+ }
174
+ }
175
+
176
+ return marks;
177
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Line & area chart module.
3
+ *
4
+ * Exports line and area chart renderers and computation functions.
5
+ */
6
+
7
+ import type { LineMark, Mark } from '@opendata-ai/openchart-core';
8
+ import type { ChartRenderer } from '../registry';
9
+ import { computeAreaMarks } from './area';
10
+ import { computeLineMarks } from './compute';
11
+ import { computeLineLabels } from './labels';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Line chart renderer
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Line chart renderer.
19
+ *
20
+ * Computes line marks + point marks for hover targets, then resolves
21
+ * end-of-line labels and attaches them to the corresponding line marks.
22
+ */
23
+ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
24
+ const marks = computeLineMarks(spec, scales, chartArea, strategy);
25
+
26
+ // Extract just the line marks for label computation
27
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
28
+
29
+ // Compute and attach labels to line marks by seriesKey lookup
30
+ const labelMap = computeLineLabels(lineMarks, strategy, spec.labels.density, spec.labels.offsets);
31
+ for (const mark of marks) {
32
+ if (mark.type === 'line' && mark.seriesKey) {
33
+ const label = labelMap.get(mark.seriesKey);
34
+ if (label) {
35
+ mark.label = label;
36
+ }
37
+ }
38
+ }
39
+
40
+ return marks as Mark[];
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Area chart renderer
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Area chart renderer.
49
+ *
50
+ * Computes area fill marks (stacked if multi-series).
51
+ * Also computes line marks for the top boundary and point marks
52
+ * for hover targets, layered on top of the areas.
53
+ */
54
+ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
55
+ const areas = computeAreaMarks(spec, scales, chartArea);
56
+ const lines = computeLineMarks(spec, scales, chartArea, strategy);
57
+
58
+ // Areas go first (rendered behind lines), then lines on top
59
+ return [...areas, ...lines] as Mark[];
60
+ };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Public exports
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export { computeAreaMarks } from './area';
67
+ export { computeLineMarks } from './compute';
68
+ export { computeLineLabels } from './labels';
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Line chart label computation.
3
+ *
4
+ * Produces end-of-line labels (series name at the last data point)
5
+ * and feeds them through the core collision engine. At compact
6
+ * breakpoints, labels are suppressed in favor of the legend.
7
+ *
8
+ * Respects the spec's label density setting:
9
+ * - 'all': show every label, skip collision detection
10
+ * - 'auto': existing behavior (collision detection)
11
+ * - 'endpoints': first and last data point labels per series
12
+ * - 'none': return empty map
13
+ */
14
+
15
+ import type {
16
+ LabelCandidate,
17
+ LabelDensity,
18
+ LayoutStrategy,
19
+ LineMark,
20
+ ResolvedLabel,
21
+ } from '@opendata-ai/openchart-core';
22
+ import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Default label font size. */
29
+ const LABEL_FONT_SIZE = 11;
30
+
31
+ /** Default label font weight. */
32
+ const LABEL_FONT_WEIGHT = 600;
33
+
34
+ /** Horizontal offset from last point to label. */
35
+ const LABEL_OFFSET_X = 6;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Public API
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Compute end-of-line labels for line marks.
43
+ *
44
+ * For each series, places a label at the position of the last data point.
45
+ * At compact breakpoints (labelMode === 'none'), all labels are hidden
46
+ * so the legend takes over. Labels go through collision detection to
47
+ * avoid overlap.
48
+ *
49
+ * Returns a Map keyed by seriesKey so callers can look up labels by
50
+ * mark identity instead of relying on positional indices.
51
+ *
52
+ * @param marks - Line marks (only processes marks with type === 'line').
53
+ * @param strategy - Layout strategy from the responsive breakpoint.
54
+ * @param density - Label density mode from spec.labels.density.
55
+ * @returns Map of seriesKey -> ResolvedLabel after collision detection.
56
+ */
57
+ export function computeLineLabels(
58
+ marks: LineMark[],
59
+ strategy: LayoutStrategy,
60
+ density: LabelDensity = 'auto',
61
+ labelOffsets?: Record<string, { dx?: number; dy?: number }>,
62
+ ): Map<string, ResolvedLabel> {
63
+ const result = new Map<string, ResolvedLabel>();
64
+
65
+ // 'none': no labels
66
+ if (density === 'none') return result;
67
+
68
+ // At compact breakpoint, suppress inline labels entirely
69
+ if (strategy.labelMode === 'none') {
70
+ return result;
71
+ }
72
+
73
+ const candidates: LabelCandidate[] = [];
74
+ const seriesOrder: string[] = [];
75
+
76
+ for (const mark of marks) {
77
+ if (mark.points.length === 0) continue;
78
+
79
+ const labelText = mark.seriesKey ?? '';
80
+ if (!labelText) continue;
81
+
82
+ const lastPoint = mark.points[mark.points.length - 1];
83
+ const textWidth = estimateTextWidth(labelText, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
84
+ const textHeight = LABEL_FONT_SIZE * 1.2;
85
+
86
+ candidates.push({
87
+ text: labelText,
88
+ anchorX: lastPoint.x + LABEL_OFFSET_X,
89
+ anchorY: lastPoint.y - textHeight / 2,
90
+ width: textWidth,
91
+ height: textHeight,
92
+ priority: 'data',
93
+ style: {
94
+ fontFamily: 'system-ui, -apple-system, sans-serif',
95
+ fontSize: LABEL_FONT_SIZE,
96
+ fontWeight: LABEL_FONT_WEIGHT,
97
+ fill: mark.stroke,
98
+ lineHeight: 1.2,
99
+ textAnchor: 'start',
100
+ dominantBaseline: 'central',
101
+ },
102
+ });
103
+
104
+ seriesOrder.push(labelText);
105
+ }
106
+
107
+ if (candidates.length === 0) return result;
108
+
109
+ // 'all': skip collision detection, mark everything visible
110
+ if (density === 'all') {
111
+ for (let i = 0; i < candidates.length; i++) {
112
+ const c = candidates[i];
113
+ const seriesKey = seriesOrder[i];
114
+ const userOffset = labelOffsets?.[seriesKey];
115
+ result.set(seriesKey, {
116
+ text: c.text,
117
+ x: c.anchorX + (userOffset?.dx ?? 0),
118
+ y: c.anchorY + (userOffset?.dy ?? 0),
119
+ style: c.style,
120
+ visible: true,
121
+ });
122
+ }
123
+ return result;
124
+ }
125
+
126
+ // 'endpoints': for line charts, endpoints means showing just the end-of-line
127
+ // label (which is what we already compute). This is the same as 'auto' for lines
128
+ // since we only compute the endpoint label per series.
129
+
130
+ const resolved = resolveCollisions(candidates);
131
+ for (let i = 0; i < resolved.length; i++) {
132
+ const seriesKey = seriesOrder[i];
133
+ const label = resolved[i];
134
+ // Apply user-provided per-series label offset after collision resolution
135
+ const userOffset = labelOffsets?.[seriesKey];
136
+ if (userOffset) {
137
+ label.x += userOffset.dx ?? 0;
138
+ label.y += userOffset.dy ?? 0;
139
+ }
140
+ result.set(seriesKey, label);
141
+ }
142
+
143
+ return result;
144
+ }