@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 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: 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) {
@@ -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: DEFAULT_STROKE_WIDTH2,
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: "solid",
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 + 2
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 maxTicks = TICK_COUNTS[density];
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
- const scales = computeScales(chartSpec, chartArea, chartSpec.data);
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(chartSpec.type);
4144
- const marks = renderer ? renderer(chartSpec, scales, chartArea, strategy, theme) : [];
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);