@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 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: 1.3,
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 lines = annotation.text.split("\n");
103
- const lineHeight = 1.3;
104
- let connectorFromX;
105
- if (connectorStyle === "curve") {
106
- connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight);
107
- } else if (lines.length > 1) {
108
- connectorFromX = labelX;
109
- } else {
110
- connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight) / 2;
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
- const lineHeight = label.style.lineHeight ?? 1.3;
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: annotation.label.x + dx,
302
- y: annotation.label.y + dy,
303
- connector: annotation.label.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 rows) {
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 spec.data) {
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 rows) {
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 maxTicks = TICK_COUNTS[density];
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) {