@opendata-ai/openchart-engine 2.0.0 → 2.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/README.md +112 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +159 -52
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/compile-chart.test.ts +123 -0
- 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/compile.ts +33 -4
- package/src/compiler/normalize.ts +2 -0
- package/src/compiler/types.ts +4 -0
- package/src/graphs/__tests__/encoding.test.ts +101 -0
- package/src/graphs/compile-graph.ts +6 -1
- package/src/graphs/encoding.ts +30 -6
- package/src/graphs/types.ts +6 -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.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _opendata_ai_openchart_core from '@opendata-ai/openchart-core';
|
|
2
|
-
import { LegendLayout, ResolvedChrome, TooltipContent, A11yMetadata, ResolvedTheme, CompileOptions, ChartLayout, CompileTableOptions, TableLayout, ChartType, DataRow, Encoding, ChromeText, Annotation, LabelConfig, LegendConfig, ThemeConfig, DarkMode, ColumnConfig, GraphSpec, GraphEncoding, GraphLayoutConfig, VizSpec, EncodingChannel, Rect, LayoutStrategy, Mark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { LegendLayout, ResolvedChrome, TooltipContent, A11yMetadata, ResolvedTheme, CompileOptions, ChartLayout, CompileTableOptions, TableLayout, ChartType, DataRow, Encoding, ChromeText, Annotation, LabelConfig, LegendConfig, ThemeConfig, DarkMode, ColumnConfig, GraphSpec, GraphEncoding, GraphLayoutConfig, NodeOverride, VizSpec, EncodingChannel, Rect, LayoutStrategy, Mark } from '@opendata-ai/openchart-core';
|
|
3
3
|
export { ChartLayout, ChartSpec, CompileOptions, CompileTableOptions, GraphLayout, GraphSpec, TableLayout, TableSpec, VizSpec } from '@opendata-ai/openchart-core';
|
|
4
4
|
import { ScaleLinear, ScaleTime, ScaleLogarithmic, ScaleBand, ScalePoint, ScaleOrdinal } from 'd3-scale';
|
|
5
5
|
|
|
@@ -65,6 +65,12 @@ interface SimulationConfig {
|
|
|
65
65
|
velocityDecay: number;
|
|
66
66
|
/** Collision radius: max node radius + padding. */
|
|
67
67
|
collisionRadius: number;
|
|
68
|
+
/** Extra px added to node radius for collision (default 2). */
|
|
69
|
+
collisionPadding?: number;
|
|
70
|
+
/** Link force strength override. */
|
|
71
|
+
linkStrength?: number;
|
|
72
|
+
/** Whether to apply center force (default true). */
|
|
73
|
+
centerForce?: boolean;
|
|
68
74
|
}
|
|
69
75
|
/**
|
|
70
76
|
* The complete engine output for graph specs.
|
|
@@ -172,6 +178,8 @@ interface NormalizedChartSpec {
|
|
|
172
178
|
responsive: boolean;
|
|
173
179
|
theme: ThemeConfig;
|
|
174
180
|
darkMode: DarkMode;
|
|
181
|
+
/** Series names to hide from rendering. */
|
|
182
|
+
hiddenSeries: string[];
|
|
175
183
|
}
|
|
176
184
|
/** A TableSpec with all optional fields filled with sensible defaults. */
|
|
177
185
|
interface NormalizedTableSpec {
|
|
@@ -197,6 +205,7 @@ interface NormalizedGraphSpec {
|
|
|
197
205
|
edges: GraphSpec['edges'];
|
|
198
206
|
encoding: GraphEncoding;
|
|
199
207
|
layout: GraphLayoutConfig;
|
|
208
|
+
nodeOverrides?: Record<string, NodeOverride>;
|
|
200
209
|
chrome: NormalizedChrome;
|
|
201
210
|
annotations: Annotation[];
|
|
202
211
|
theme: ThemeConfig;
|
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) {
|
|
@@ -1837,7 +1898,8 @@ function normalizeChartSpec(spec, warnings) {
|
|
|
1837
1898
|
legend: spec.legend,
|
|
1838
1899
|
responsive: spec.responsive ?? true,
|
|
1839
1900
|
theme: spec.theme ?? {},
|
|
1840
|
-
darkMode: spec.darkMode ?? "off"
|
|
1901
|
+
darkMode: spec.darkMode ?? "off",
|
|
1902
|
+
hiddenSeries: spec.hiddenSeries ?? []
|
|
1841
1903
|
};
|
|
1842
1904
|
}
|
|
1843
1905
|
function normalizeTableSpec(spec, _warnings) {
|
|
@@ -1872,6 +1934,7 @@ function normalizeGraphSpec(spec, _warnings) {
|
|
|
1872
1934
|
edges: spec.edges,
|
|
1873
1935
|
encoding: spec.encoding ?? {},
|
|
1874
1936
|
layout,
|
|
1937
|
+
nodeOverrides: spec.nodeOverrides,
|
|
1875
1938
|
chrome: normalizeChrome(spec.chrome),
|
|
1876
1939
|
annotations: normalizeAnnotations(spec.annotations),
|
|
1877
1940
|
theme: spec.theme ?? {},
|
|
@@ -2402,7 +2465,7 @@ function computeDegrees(nodes, edges) {
|
|
|
2402
2465
|
}
|
|
2403
2466
|
return degrees;
|
|
2404
2467
|
}
|
|
2405
|
-
function resolveNodeVisuals(nodes, encoding, edges, theme) {
|
|
2468
|
+
function resolveNodeVisuals(nodes, encoding, edges, theme, nodeOverrides) {
|
|
2406
2469
|
const degrees = computeDegrees(nodes, edges);
|
|
2407
2470
|
const maxDegree = Math.max(1, ...degrees.values());
|
|
2408
2471
|
let sizeScale;
|
|
@@ -2456,14 +2519,20 @@ function resolveNodeVisuals(nodes, encoding, edges, theme) {
|
|
|
2456
2519
|
const labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
|
|
2457
2520
|
const { id: _id, ...rest } = node;
|
|
2458
2521
|
const data = { id: node.id, ...rest };
|
|
2522
|
+
const override = nodeOverrides?.[node.id];
|
|
2523
|
+
const finalFill = override?.fill ?? fill;
|
|
2524
|
+
const finalRadius = override?.radius ?? radius;
|
|
2525
|
+
const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH2;
|
|
2526
|
+
const finalStroke = override?.stroke ?? stroke;
|
|
2527
|
+
const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
|
|
2459
2528
|
return {
|
|
2460
2529
|
id: node.id,
|
|
2461
|
-
radius,
|
|
2462
|
-
fill,
|
|
2463
|
-
stroke,
|
|
2464
|
-
strokeWidth:
|
|
2530
|
+
radius: finalRadius,
|
|
2531
|
+
fill: finalFill,
|
|
2532
|
+
stroke: finalStroke,
|
|
2533
|
+
strokeWidth: finalStrokeWidth,
|
|
2465
2534
|
label,
|
|
2466
|
-
labelPriority,
|
|
2535
|
+
labelPriority: finalLabelPriority,
|
|
2467
2536
|
community: void 0,
|
|
2468
2537
|
data
|
|
2469
2538
|
};
|
|
@@ -2500,6 +2569,17 @@ function resolveEdgeVisuals(edges, encoding, theme) {
|
|
|
2500
2569
|
}
|
|
2501
2570
|
}
|
|
2502
2571
|
const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
|
|
2572
|
+
const EDGE_STYLES = ["solid", "dashed", "dotted"];
|
|
2573
|
+
let styleFn;
|
|
2574
|
+
if (encoding.edgeStyle?.field) {
|
|
2575
|
+
const field = encoding.edgeStyle.field;
|
|
2576
|
+
const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? "")))];
|
|
2577
|
+
const styleMap = /* @__PURE__ */ new Map();
|
|
2578
|
+
for (let i = 0; i < uniqueValues.length; i++) {
|
|
2579
|
+
styleMap.set(uniqueValues[i], EDGE_STYLES[i % EDGE_STYLES.length]);
|
|
2580
|
+
}
|
|
2581
|
+
styleFn = (edge) => styleMap.get(String(edge[field] ?? "")) ?? "solid";
|
|
2582
|
+
}
|
|
2503
2583
|
return edges.map((edge) => {
|
|
2504
2584
|
const { source, target, ...rest } = edge;
|
|
2505
2585
|
let strokeWidth = DEFAULT_EDGE_WIDTH;
|
|
@@ -2510,12 +2590,13 @@ function resolveEdgeVisuals(edges, encoding, theme) {
|
|
|
2510
2590
|
}
|
|
2511
2591
|
}
|
|
2512
2592
|
const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
|
|
2593
|
+
const style = styleFn ? styleFn(edge) : "solid";
|
|
2513
2594
|
return {
|
|
2514
2595
|
source,
|
|
2515
2596
|
target,
|
|
2516
2597
|
stroke,
|
|
2517
2598
|
strokeWidth,
|
|
2518
|
-
style
|
|
2599
|
+
style,
|
|
2519
2600
|
data: { source, target, ...rest }
|
|
2520
2601
|
};
|
|
2521
2602
|
});
|
|
@@ -2644,7 +2725,8 @@ function compileGraph(spec, options) {
|
|
|
2644
2725
|
graphSpec.nodes,
|
|
2645
2726
|
graphSpec.encoding,
|
|
2646
2727
|
graphSpec.edges,
|
|
2647
|
-
theme
|
|
2728
|
+
theme,
|
|
2729
|
+
graphSpec.nodeOverrides
|
|
2648
2730
|
);
|
|
2649
2731
|
const clusteringField = graphSpec.layout.clustering?.field;
|
|
2650
2732
|
const hasCommunities = !!clusteringField;
|
|
@@ -2670,6 +2752,7 @@ function compileGraph(spec, options) {
|
|
|
2670
2752
|
role: "img",
|
|
2671
2753
|
keyboardNavigable: compiledNodes.length > 0
|
|
2672
2754
|
};
|
|
2755
|
+
const collisionPadding = graphSpec.layout.collisionPadding ?? 2;
|
|
2673
2756
|
const maxRadius = compiledNodes.length > 0 ? Math.max(...compiledNodes.map((n) => n.radius)) : DEFAULT_COLLISION_PADDING;
|
|
2674
2757
|
const simulationConfig = {
|
|
2675
2758
|
chargeStrength: graphSpec.layout.chargeStrength ?? -300,
|
|
@@ -2677,7 +2760,10 @@ function compileGraph(spec, options) {
|
|
|
2677
2760
|
clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
|
|
2678
2761
|
alphaDecay: 0.0228,
|
|
2679
2762
|
velocityDecay: 0.4,
|
|
2680
|
-
collisionRadius: maxRadius +
|
|
2763
|
+
collisionRadius: maxRadius + collisionPadding,
|
|
2764
|
+
collisionPadding,
|
|
2765
|
+
linkStrength: graphSpec.layout.linkStrength,
|
|
2766
|
+
centerForce: graphSpec.layout.centerForce
|
|
2681
2767
|
};
|
|
2682
2768
|
const chrome = computeChrome(
|
|
2683
2769
|
{
|
|
@@ -2728,9 +2814,10 @@ function continuousTicks(resolvedScale, density) {
|
|
|
2728
2814
|
function categoricalTicks(resolvedScale, density) {
|
|
2729
2815
|
const scale = resolvedScale.scale;
|
|
2730
2816
|
const domain = scale.domain();
|
|
2731
|
-
const
|
|
2817
|
+
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
2818
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
2732
2819
|
let selectedValues = domain;
|
|
2733
|
-
if (resolvedScale.type !== "band" && domain.length > maxTicks) {
|
|
2820
|
+
if ((resolvedScale.type !== "band" || explicitTickCount) && domain.length > maxTicks) {
|
|
2734
2821
|
const step = Math.ceil(domain.length / maxTicks);
|
|
2735
2822
|
selectedValues = domain.filter((_, i) => i % step === 0);
|
|
2736
2823
|
}
|
|
@@ -3108,7 +3195,7 @@ function computeScales(spec, chartArea, data) {
|
|
|
3108
3195
|
}
|
|
3109
3196
|
if (encoding.y) {
|
|
3110
3197
|
let yData = data;
|
|
3111
|
-
if (spec.type === "column" && encoding.color && encoding.y.type === "quantitative") {
|
|
3198
|
+
if ((spec.type === "column" || spec.type === "area") && encoding.color && encoding.y.type === "quantitative") {
|
|
3112
3199
|
const xField = encoding.x?.field;
|
|
3113
3200
|
const yField = encoding.y.field;
|
|
3114
3201
|
if (xField) {
|
|
@@ -4120,7 +4207,27 @@ function compileChart(spec, options) {
|
|
|
4120
4207
|
}
|
|
4121
4208
|
}
|
|
4122
4209
|
const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
|
|
4123
|
-
|
|
4210
|
+
let renderData = chartSpec.data;
|
|
4211
|
+
if (chartSpec.hiddenSeries.length > 0 && chartSpec.encoding.color) {
|
|
4212
|
+
const colorField = chartSpec.encoding.color.field;
|
|
4213
|
+
const hiddenSet = new Set(chartSpec.hiddenSeries);
|
|
4214
|
+
renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
|
|
4215
|
+
}
|
|
4216
|
+
for (const channel of ["x", "y"]) {
|
|
4217
|
+
const enc = chartSpec.encoding[channel];
|
|
4218
|
+
if (!enc?.scale?.clip || !enc.scale.domain) continue;
|
|
4219
|
+
const domain = enc.scale.domain;
|
|
4220
|
+
const field = enc.field;
|
|
4221
|
+
if (Array.isArray(domain) && domain.length === 2 && typeof domain[0] === "number") {
|
|
4222
|
+
const [lo, hi] = domain;
|
|
4223
|
+
renderData = renderData.filter((row) => {
|
|
4224
|
+
const v = Number(row[field]);
|
|
4225
|
+
return Number.isFinite(v) && v >= lo && v <= hi;
|
|
4226
|
+
});
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
const renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
|
|
4230
|
+
const scales = computeScales(renderSpec, chartArea, renderSpec.data);
|
|
4124
4231
|
if (scales.color) {
|
|
4125
4232
|
if (scales.color.type === "sequential") {
|
|
4126
4233
|
const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
@@ -4140,8 +4247,8 @@ function compileChart(spec, options) {
|
|
|
4140
4247
|
if (!isRadial) {
|
|
4141
4248
|
computeGridlines(axes, chartArea);
|
|
4142
4249
|
}
|
|
4143
|
-
const renderer = getChartRenderer(
|
|
4144
|
-
const marks = renderer ? renderer(
|
|
4250
|
+
const renderer = getChartRenderer(renderSpec.type);
|
|
4251
|
+
const marks = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
4145
4252
|
const obstacles = [];
|
|
4146
4253
|
if (finalLegend.bounds.width > 0) {
|
|
4147
4254
|
obstacles.push(finalLegend.bounds);
|