@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.
- package/dist/index.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- 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
|
+
}
|