@opendata-ai/openchart-engine 1.2.0 → 2.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/README.md +112 -0
- package/dist/index.js +101 -39
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/annotations/__tests__/compute.test.ts +226 -0
- package/src/annotations/compute.ts +116 -46
- package/src/charts/__tests__/utils.test.ts +195 -0
- package/src/charts/line/__tests__/compute.test.ts +364 -0
- package/src/charts/line/area.ts +9 -3
- package/src/charts/line/compute.ts +5 -2
- package/src/charts/utils.ts +48 -0
- package/src/layout/axes.ts +5 -4
- package/src/layout/scales.ts +8 -3
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @opendata-ai/openchart-engine
|
|
2
|
+
|
|
3
|
+
Headless compiler for OpenChart. Takes a spec (plain JSON), validates it, and produces a fully resolved layout with computed positions, scales, marks, and accessibility metadata. No DOM dependency.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @opendata-ai/openchart-engine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You typically don't install this directly. The framework packages (`openchart-react`, `openchart-vue`, `openchart-svelte`) and the vanilla adapter include it as a dependency.
|
|
12
|
+
|
|
13
|
+
## Core API
|
|
14
|
+
|
|
15
|
+
### compile()
|
|
16
|
+
|
|
17
|
+
The main entry point. Accepts any `VizSpec` and returns the compiled layout:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { compile } from '@opendata-ai/openchart-engine';
|
|
21
|
+
|
|
22
|
+
const result = compile(spec, {
|
|
23
|
+
width: 600,
|
|
24
|
+
height: 400,
|
|
25
|
+
darkMode: false,
|
|
26
|
+
});
|
|
27
|
+
// result is a ChartLayout, TableLayout, or GraphCompilation
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### compileChart()
|
|
31
|
+
|
|
32
|
+
Compiles a `ChartSpec` into a `ChartLayout` with positioned marks, axes, gridlines, legends, and annotations:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
36
|
+
|
|
37
|
+
const layout = compileChart(chartSpec, { width: 600, height: 400 });
|
|
38
|
+
// layout.marks: positioned line paths, bar rects, arc segments, etc.
|
|
39
|
+
// layout.axes: tick positions, labels, format strings
|
|
40
|
+
// layout.legend: entries, position, dimensions
|
|
41
|
+
// layout.annotations: pixel-positioned annotation elements
|
|
42
|
+
// layout.theme: fully resolved theme with all defaults filled
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### compileTable()
|
|
46
|
+
|
|
47
|
+
Compiles a `TableSpec` into a `TableLayout` with resolved columns, formatted cells, and visual enhancements:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { compileTable } from '@opendata-ai/openchart-engine';
|
|
51
|
+
|
|
52
|
+
const layout = compileTable(tableSpec, { darkMode: false });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### compileGraph()
|
|
56
|
+
|
|
57
|
+
Compiles a `GraphSpec` into a `GraphCompilation` with resolved node/edge visuals and simulation config. Note: the engine doesn't compute node positions. That happens at runtime via a force simulation in the vanilla adapter.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { compileGraph } from '@opendata-ai/openchart-engine';
|
|
61
|
+
|
|
62
|
+
const compilation = compileGraph(graphSpec, { width: 600, height: 400 });
|
|
63
|
+
// compilation.nodes: visual properties (size, color, label) resolved
|
|
64
|
+
// compilation.edges: visual properties (width, color) resolved
|
|
65
|
+
// compilation.simulationConfig: force parameters for the layout engine
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### validateSpec()
|
|
69
|
+
|
|
70
|
+
Runtime validation of any spec. Returns structured errors with paths and suggestions:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { validateSpec } from '@opendata-ai/openchart-engine';
|
|
74
|
+
|
|
75
|
+
const result = validateSpec(spec);
|
|
76
|
+
if (!result.valid) {
|
|
77
|
+
for (const error of result.errors) {
|
|
78
|
+
console.log(`${error.path}: ${error.message}`);
|
|
79
|
+
console.log(` Suggestion: ${error.suggestion}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### normalizeSpec()
|
|
85
|
+
|
|
86
|
+
Fills in defaults and resolves shorthand without validation. Useful when you know the spec is valid and want the normalized form:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { normalizeSpec } from '@opendata-ai/openchart-engine';
|
|
90
|
+
|
|
91
|
+
const normalized = normalizeSpec(spec);
|
|
92
|
+
// All optional fields resolved to their defaults
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Chart renderer registry
|
|
96
|
+
|
|
97
|
+
Chart types are registered via a plugin pattern. The engine ships with all 8 types pre-registered (line, area, bar, column, pie, donut, dot, scatter). For advanced use, you can register custom renderers:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { registerChartRenderer, getChartRenderer, clearRenderers } from '@opendata-ai/openchart-engine';
|
|
101
|
+
|
|
102
|
+
registerChartRenderer('custom', myCustomRenderer);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Re-exports
|
|
106
|
+
|
|
107
|
+
For convenience, this package re-exports all types from `@opendata-ai/openchart-core`, so you can import types from either package.
|
|
108
|
+
|
|
109
|
+
## Related docs
|
|
110
|
+
|
|
111
|
+
- [Architecture](../../docs/architecture.md) for how the compilation pipeline works
|
|
112
|
+
- [Spec reference](../../docs/spec-reference.md) for field-by-field type details
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { estimateTextWidth } from "@opendata-ai/openchart-core";
|
|
13
13
|
var DEFAULT_ANNOTATION_FONT_SIZE = 12;
|
|
14
14
|
var DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
|
|
15
|
+
var DEFAULT_LINE_HEIGHT = 1.3;
|
|
15
16
|
var DEFAULT_RANGE_FILL = "#f0c040";
|
|
16
17
|
var DEFAULT_RANGE_OPACITY = 0.15;
|
|
17
18
|
var DEFAULT_REFLINE_DASH = "4 3";
|
|
@@ -53,10 +54,23 @@ function makeAnnotationLabelStyle(fontSize, fontWeight, fill, isDark) {
|
|
|
53
54
|
fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
|
|
54
55
|
fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
55
56
|
fill: fill ?? defaultFill,
|
|
56
|
-
lineHeight:
|
|
57
|
+
lineHeight: DEFAULT_LINE_HEIGHT,
|
|
57
58
|
textAnchor: "start"
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
function computeTextBounds(labelX, labelY, text, fontSize, fontWeight) {
|
|
62
|
+
const lines = text.split("\n");
|
|
63
|
+
const isMultiLine = lines.length > 1;
|
|
64
|
+
const maxWidth = Math.max(...lines.map((line3) => estimateTextWidth(line3, fontSize, fontWeight)));
|
|
65
|
+
const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
|
|
66
|
+
const x = isMultiLine ? labelX - maxWidth / 2 : labelX;
|
|
67
|
+
return {
|
|
68
|
+
x,
|
|
69
|
+
y: labelY - fontSize,
|
|
70
|
+
width: maxWidth,
|
|
71
|
+
height: totalHeight
|
|
72
|
+
};
|
|
73
|
+
}
|
|
60
74
|
function computeAnchorOffset(anchor, _px, py, chartArea) {
|
|
61
75
|
if (!anchor || anchor === "auto") {
|
|
62
76
|
const isUpperHalf = py < chartArea.y + chartArea.height / 2;
|
|
@@ -80,6 +94,25 @@ function applyOffset(base, offset) {
|
|
|
80
94
|
dy: base.dy + (offset.dy ?? 0)
|
|
81
95
|
};
|
|
82
96
|
}
|
|
97
|
+
function computeConnectorOrigin(labelX, labelY, text, fontSize, fontWeight, targetX, targetY, connectorStyle) {
|
|
98
|
+
const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
|
|
99
|
+
const boxCenterX = box.x + box.width / 2;
|
|
100
|
+
const boxCenterY = box.y + box.height / 2;
|
|
101
|
+
if (connectorStyle === "curve") {
|
|
102
|
+
return {
|
|
103
|
+
x: box.x + box.width,
|
|
104
|
+
y: boxCenterY
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const halfW = box.width / 2 || 1;
|
|
108
|
+
const halfH = box.height / 2 || 1;
|
|
109
|
+
const ndx = (targetX - boxCenterX) / halfW;
|
|
110
|
+
const ndy = (targetY - boxCenterY) / halfH;
|
|
111
|
+
if (Math.abs(ndy) >= Math.abs(ndx)) {
|
|
112
|
+
return ndy < 0 ? { x: boxCenterX, y: box.y } : { x: boxCenterX, y: box.y + box.height };
|
|
113
|
+
}
|
|
114
|
+
return ndx < 0 ? { x: box.x, y: boxCenterY } : { x: box.x + box.width, y: boxCenterY };
|
|
115
|
+
}
|
|
83
116
|
function resolveTextAnnotation(annotation, scales, chartArea, isDark) {
|
|
84
117
|
const px = resolvePosition(annotation.x, scales.x);
|
|
85
118
|
const py = resolvePosition(annotation.y, scales.y);
|
|
@@ -99,17 +132,16 @@ function resolveTextAnnotation(annotation, scales, chartArea, isDark) {
|
|
|
99
132
|
const connectorStyle = annotation.connector === "curve" ? "curve" : "straight";
|
|
100
133
|
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
101
134
|
const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const connectorFromY = labelY + (lines.length - 1) * fontSize * lineHeight + fontSize * 0.3;
|
|
135
|
+
const { x: connectorFromX, y: connectorFromY } = computeConnectorOrigin(
|
|
136
|
+
labelX,
|
|
137
|
+
labelY,
|
|
138
|
+
annotation.text,
|
|
139
|
+
fontSize,
|
|
140
|
+
fontWeight,
|
|
141
|
+
px,
|
|
142
|
+
py,
|
|
143
|
+
connectorStyle
|
|
144
|
+
);
|
|
113
145
|
const baseFrom = { x: connectorFromX, y: connectorFromY };
|
|
114
146
|
const baseTo = { x: px, y: py };
|
|
115
147
|
const adjustedFrom = {
|
|
@@ -245,20 +277,9 @@ function resolveRefLineAnnotation(annotation, scales, chartArea, isDark) {
|
|
|
245
277
|
};
|
|
246
278
|
}
|
|
247
279
|
function estimateLabelBounds(label) {
|
|
248
|
-
const lines = label.text.split("\n");
|
|
249
280
|
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
250
281
|
const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
251
|
-
|
|
252
|
-
const maxWidth = Math.max(...lines.map((line3) => estimateTextWidth(line3, fontSize, fontWeight)));
|
|
253
|
-
const totalHeight = lines.length * fontSize * lineHeight;
|
|
254
|
-
const isMultiLine = lines.length > 1;
|
|
255
|
-
const anchorX = isMultiLine ? label.x - maxWidth / 2 : label.x;
|
|
256
|
-
return {
|
|
257
|
-
x: anchorX,
|
|
258
|
-
y: label.y - fontSize,
|
|
259
|
-
width: maxWidth,
|
|
260
|
-
height: totalHeight
|
|
261
|
-
};
|
|
282
|
+
return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
|
|
262
283
|
}
|
|
263
284
|
function rectsOverlap(a, b) {
|
|
264
285
|
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
@@ -296,17 +317,30 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
|
|
|
296
317
|
}
|
|
297
318
|
candidates.sort((a, b) => a.distance - b.distance);
|
|
298
319
|
for (const { dx, dy } of candidates) {
|
|
320
|
+
const newLabelX = annotation.label.x + dx;
|
|
321
|
+
const newLabelY = annotation.label.y + dy;
|
|
322
|
+
let newConnector = annotation.label.connector;
|
|
323
|
+
if (newConnector) {
|
|
324
|
+
const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
325
|
+
const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
326
|
+
const connStyle = newConnector.style === "curve" ? "curve" : "straight";
|
|
327
|
+
const newFrom = computeConnectorOrigin(
|
|
328
|
+
newLabelX,
|
|
329
|
+
newLabelY,
|
|
330
|
+
annotation.label.text,
|
|
331
|
+
annFontSize,
|
|
332
|
+
annFontWeight,
|
|
333
|
+
px,
|
|
334
|
+
py,
|
|
335
|
+
connStyle
|
|
336
|
+
);
|
|
337
|
+
newConnector = { ...newConnector, from: newFrom };
|
|
338
|
+
}
|
|
299
339
|
const candidateLabel = {
|
|
300
340
|
...annotation.label,
|
|
301
|
-
x:
|
|
302
|
-
y:
|
|
303
|
-
connector:
|
|
304
|
-
...annotation.label.connector,
|
|
305
|
-
from: {
|
|
306
|
-
x: annotation.label.connector.from.x + dx,
|
|
307
|
-
y: annotation.label.connector.from.y + dy
|
|
308
|
-
}
|
|
309
|
-
} : void 0
|
|
341
|
+
x: newLabelX,
|
|
342
|
+
y: newLabelY,
|
|
343
|
+
connector: newConnector
|
|
310
344
|
};
|
|
311
345
|
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
312
346
|
const stillCollides = obstacles.some(
|
|
@@ -395,6 +429,30 @@ function groupByField(data, field) {
|
|
|
395
429
|
}
|
|
396
430
|
return groups;
|
|
397
431
|
}
|
|
432
|
+
function sortByField(data, field) {
|
|
433
|
+
if (data.length <= 1) return [...data];
|
|
434
|
+
return [...data].sort((a, b) => {
|
|
435
|
+
const aVal = a[field];
|
|
436
|
+
const bVal = b[field];
|
|
437
|
+
if (aVal == null && bVal == null) return 0;
|
|
438
|
+
if (aVal == null) return 1;
|
|
439
|
+
if (bVal == null) return -1;
|
|
440
|
+
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
441
|
+
return aVal - bVal;
|
|
442
|
+
}
|
|
443
|
+
if (aVal instanceof Date && bVal instanceof Date) {
|
|
444
|
+
return aVal.getTime() - bVal.getTime();
|
|
445
|
+
}
|
|
446
|
+
const aStr = String(aVal);
|
|
447
|
+
const bStr = String(bVal);
|
|
448
|
+
const aNum = Number(aStr);
|
|
449
|
+
const bNum = Number(bStr);
|
|
450
|
+
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
|
|
451
|
+
return aNum - bNum;
|
|
452
|
+
}
|
|
453
|
+
return aStr.localeCompare(bStr);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
398
456
|
function getColor(scales, key, _index, fallback = DEFAULT_COLOR) {
|
|
399
457
|
if (scales.color && key !== "__default__") {
|
|
400
458
|
const colorScale = scales.color.scale;
|
|
@@ -1051,8 +1109,9 @@ function computeSingleArea(spec, scales, _chartArea) {
|
|
|
1051
1109
|
const marks = [];
|
|
1052
1110
|
for (const [seriesKey, rows] of groups) {
|
|
1053
1111
|
const color = getColor(scales, seriesKey);
|
|
1112
|
+
const sortedRows = sortByField(rows, xChannel.field);
|
|
1054
1113
|
const validPoints = [];
|
|
1055
|
-
for (const row of
|
|
1114
|
+
for (const row of sortedRows) {
|
|
1056
1115
|
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
1057
1116
|
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
1058
1117
|
if (xVal === null || yVal === null) continue;
|
|
@@ -1097,11 +1156,12 @@ function computeStackedArea(spec, scales, chartArea) {
|
|
|
1097
1156
|
if (!xChannel || !yChannel || !scales.x || !scales.y || !colorField) {
|
|
1098
1157
|
return computeSingleArea(spec, scales, chartArea);
|
|
1099
1158
|
}
|
|
1159
|
+
const sortedData = sortByField(spec.data, xChannel.field);
|
|
1100
1160
|
const seriesKeys = /* @__PURE__ */ new Set();
|
|
1101
1161
|
const xValueSet = /* @__PURE__ */ new Set();
|
|
1102
1162
|
const rowsByXSeries = /* @__PURE__ */ new Map();
|
|
1103
1163
|
const rowsByX = /* @__PURE__ */ new Map();
|
|
1104
|
-
for (const row of
|
|
1164
|
+
for (const row of sortedData) {
|
|
1105
1165
|
const xStr = String(row[xChannel.field]);
|
|
1106
1166
|
const series = String(row[colorField]);
|
|
1107
1167
|
seriesKeys.add(series);
|
|
@@ -1201,10 +1261,11 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
|
|
|
1201
1261
|
const marks = [];
|
|
1202
1262
|
for (const [seriesKey, rows] of groups) {
|
|
1203
1263
|
const color = getColor(scales, seriesKey);
|
|
1264
|
+
const sortedRows = sortByField(rows, xChannel.field);
|
|
1204
1265
|
const pointsWithData = [];
|
|
1205
1266
|
const segments = [];
|
|
1206
1267
|
let currentSegment = [];
|
|
1207
|
-
for (const row of
|
|
1268
|
+
for (const row of sortedRows) {
|
|
1208
1269
|
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
1209
1270
|
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
1210
1271
|
if (xVal === null || yVal === null) {
|
|
@@ -2728,9 +2789,10 @@ function continuousTicks(resolvedScale, density) {
|
|
|
2728
2789
|
function categoricalTicks(resolvedScale, density) {
|
|
2729
2790
|
const scale = resolvedScale.scale;
|
|
2730
2791
|
const domain = scale.domain();
|
|
2731
|
-
const
|
|
2792
|
+
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
2793
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
2732
2794
|
let selectedValues = domain;
|
|
2733
|
-
if (resolvedScale.type !== "band" && domain.length > maxTicks) {
|
|
2795
|
+
if ((resolvedScale.type !== "band" || explicitTickCount) && domain.length > maxTicks) {
|
|
2734
2796
|
const step = Math.ceil(domain.length / maxTicks);
|
|
2735
2797
|
selectedValues = domain.filter((_, i) => i % step === 0);
|
|
2736
2798
|
}
|
|
@@ -3108,7 +3170,7 @@ function computeScales(spec, chartArea, data) {
|
|
|
3108
3170
|
}
|
|
3109
3171
|
if (encoding.y) {
|
|
3110
3172
|
let yData = data;
|
|
3111
|
-
if (spec.type === "column" && encoding.color && encoding.y.type === "quantitative") {
|
|
3173
|
+
if ((spec.type === "column" || spec.type === "area") && encoding.color && encoding.y.type === "quantitative") {
|
|
3112
3174
|
const xField = encoding.x?.field;
|
|
3113
3175
|
const yField = encoding.y.field;
|
|
3114
3176
|
if (xField) {
|