@opendata-ai/openchart-engine 2.1.0 → 2.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.1.0",
48
+ "@opendata-ai/openchart-core": "2.2.1",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -66,6 +66,7 @@ export function makeLineSpec(): NormalizedChartSpec {
66
66
  theme: {},
67
67
  darkMode: 'off',
68
68
  labels: { density: 'auto', format: '' },
69
+ hiddenSeries: [],
69
70
  };
70
71
  }
71
72
 
@@ -92,6 +93,7 @@ export function makeBarSpec(): NormalizedChartSpec {
92
93
  theme: {},
93
94
  darkMode: 'off',
94
95
  labels: { density: 'auto', format: '' },
96
+ hiddenSeries: [],
95
97
  };
96
98
  }
97
99
 
@@ -120,5 +122,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
120
122
  theme: {},
121
123
  darkMode: 'off',
122
124
  labels: { density: 'auto', format: '' },
125
+ hiddenSeries: [],
123
126
  };
124
127
  }
@@ -260,6 +260,129 @@ describe('compileChart', () => {
260
260
  ),
261
261
  ).toThrow('compileTable');
262
262
  });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // hiddenSeries
266
+ // ---------------------------------------------------------------------------
267
+
268
+ it('hiddenSeries filters out data for hidden series from marks', () => {
269
+ const spec = {
270
+ ...lineSpec,
271
+ hiddenSeries: ['UK'],
272
+ };
273
+ const layout = compileChart(spec, { width: 600, height: 400 });
274
+
275
+ // With UK hidden, only US marks should be present.
276
+ // Line marks carry a series property.
277
+ const lineMarks = layout.marks.filter((m) => m.type === 'line');
278
+ for (const mark of lineMarks) {
279
+ if (mark.type === 'line') {
280
+ expect(mark.series).not.toBe('UK');
281
+ }
282
+ }
283
+
284
+ // Legend should still have entries for both series (hidden ones are dimmed, not removed)
285
+ expect(layout.legend.entries.length).toBe(2);
286
+ expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
287
+ expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
288
+ });
289
+
290
+ it('hiddenSeries with all series hidden produces no marks', () => {
291
+ const spec = {
292
+ ...lineSpec,
293
+ hiddenSeries: ['US', 'UK'],
294
+ };
295
+ const layout = compileChart(spec, { width: 600, height: 400 });
296
+ // No data left means no marks
297
+ expect(layout.marks.length).toBe(0);
298
+ });
299
+
300
+ it('hiddenSeries with empty array behaves normally', () => {
301
+ const spec = {
302
+ ...lineSpec,
303
+ hiddenSeries: [],
304
+ };
305
+ const layout = compileChart(spec, { width: 600, height: 400 });
306
+ expect(layout.marks.length).toBeGreaterThan(0);
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // scale.clip
311
+ // ---------------------------------------------------------------------------
312
+
313
+ it('scale.clip filters data rows outside the y-axis domain', () => {
314
+ const spec = {
315
+ type: 'scatter' as const,
316
+ data: [
317
+ { x: 1, y: 5 },
318
+ { x: 2, y: 15 },
319
+ { x: 3, y: 25 },
320
+ { x: 4, y: 35 },
321
+ ],
322
+ encoding: {
323
+ x: { field: 'x', type: 'quantitative' as const },
324
+ y: {
325
+ field: 'y',
326
+ type: 'quantitative' as const,
327
+ scale: { domain: [10, 30] as [number, number], clip: true },
328
+ },
329
+ },
330
+ };
331
+ const layout = compileChart(spec, { width: 600, height: 400 });
332
+
333
+ // Only y=15 and y=25 should remain (y=5 and y=35 are outside [10,30])
334
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
335
+ expect(pointMarks.length).toBe(2);
336
+ });
337
+
338
+ it('scale.clip filters data rows outside the x-axis domain', () => {
339
+ const spec = {
340
+ type: 'scatter' as const,
341
+ data: [
342
+ { x: 1, y: 10 },
343
+ { x: 5, y: 20 },
344
+ { x: 10, y: 30 },
345
+ { x: 15, y: 40 },
346
+ ],
347
+ encoding: {
348
+ x: {
349
+ field: 'x',
350
+ type: 'quantitative' as const,
351
+ scale: { domain: [3, 12] as [number, number], clip: true },
352
+ },
353
+ y: { field: 'y', type: 'quantitative' as const },
354
+ },
355
+ };
356
+ const layout = compileChart(spec, { width: 600, height: 400 });
357
+
358
+ // Only x=5 and x=10 should remain
359
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
360
+ expect(pointMarks.length).toBe(2);
361
+ });
362
+
363
+ it('scale.clip=false does not filter data even with domain set', () => {
364
+ const spec = {
365
+ type: 'scatter' as const,
366
+ data: [
367
+ { x: 1, y: 5 },
368
+ { x: 2, y: 15 },
369
+ { x: 3, y: 25 },
370
+ ],
371
+ encoding: {
372
+ x: { field: 'x', type: 'quantitative' as const },
373
+ y: {
374
+ field: 'y',
375
+ type: 'quantitative' as const,
376
+ scale: { domain: [10, 20] as [number, number], clip: false },
377
+ },
378
+ },
379
+ };
380
+ const layout = compileChart(spec, { width: 600, height: 400 });
381
+
382
+ // All 3 points should still be present (clip is false)
383
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
384
+ expect(pointMarks.length).toBe(3);
385
+ });
263
386
  });
264
387
 
265
388
  describe('compileTable', () => {
package/src/compile.ts CHANGED
@@ -209,8 +209,37 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
209
209
  }
210
210
  const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
211
211
 
212
+ // Apply data filtering after legend (so legend retains all series), but before
213
+ // scale computation (so hidden/clipped data doesn't affect domains or marks).
214
+ let renderData = chartSpec.data;
215
+
216
+ // Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
217
+ if (chartSpec.hiddenSeries.length > 0 && chartSpec.encoding.color) {
218
+ const colorField = chartSpec.encoding.color.field;
219
+ const hiddenSet = new Set(chartSpec.hiddenSeries);
220
+ renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
221
+ }
222
+
223
+ // Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
224
+ for (const channel of ['x', 'y'] as const) {
225
+ const enc = chartSpec.encoding[channel];
226
+ if (!enc?.scale?.clip || !enc.scale.domain) continue;
227
+ const domain = enc.scale.domain;
228
+ const field = enc.field;
229
+ if (Array.isArray(domain) && domain.length === 2 && typeof domain[0] === 'number') {
230
+ const [lo, hi] = domain as [number, number];
231
+ renderData = renderData.filter((row) => {
232
+ const v = Number(row[field]);
233
+ return Number.isFinite(v) && v >= lo && v <= hi;
234
+ });
235
+ }
236
+ }
237
+
238
+ // Build a filtered spec for scales and marks, keeping all other properties intact
239
+ const renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
240
+
212
241
  // Compute scales
213
- const scales = computeScales(chartSpec, chartArea, chartSpec.data);
242
+ const scales = computeScales(renderSpec, chartArea, renderSpec.data);
214
243
 
215
244
  // Update color scale to use theme palette
216
245
  if (scales.color) {
@@ -244,9 +273,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
244
273
  computeGridlines(axes, chartArea);
245
274
  }
246
275
 
247
- // Get chart renderer and compute marks
248
- const renderer = getChartRenderer(chartSpec.type);
249
- const marks: Mark[] = renderer ? renderer(chartSpec, scales, chartArea, strategy, theme) : [];
276
+ // Get chart renderer and compute marks (using filtered data)
277
+ const renderer = getChartRenderer(renderSpec.type);
278
+ const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
250
279
 
251
280
  // Compute annotations from spec, passing legend + mark bounds as obstacles for collision avoidance
252
281
  const obstacles: Rect[] = [];
@@ -196,6 +196,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
196
196
  responsive: spec.responsive ?? true,
197
197
  theme: spec.theme ?? {},
198
198
  darkMode: spec.darkMode ?? 'off',
199
+ hiddenSeries: spec.hiddenSeries ?? [],
199
200
  };
200
201
  }
201
202
 
@@ -236,6 +237,7 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
236
237
  edges: spec.edges,
237
238
  encoding: spec.encoding ?? {},
238
239
  layout,
240
+ nodeOverrides: spec.nodeOverrides,
239
241
  chrome: normalizeChrome(spec.chrome),
240
242
  annotations: normalizeAnnotations(spec.annotations),
241
243
  theme: spec.theme ?? {},
@@ -22,6 +22,7 @@ import type {
22
22
  GraphSpec,
23
23
  LabelConfig,
24
24
  LegendConfig,
25
+ NodeOverride,
25
26
  ScaleConfig,
26
27
  ThemeConfig,
27
28
  } from '@opendata-ai/openchart-core';
@@ -70,6 +71,8 @@ export interface NormalizedChartSpec {
70
71
  responsive: boolean;
71
72
  theme: ThemeConfig;
72
73
  darkMode: DarkMode;
74
+ /** Series names to hide from rendering. */
75
+ hiddenSeries: string[];
73
76
  }
74
77
 
75
78
  /** A TableSpec with all optional fields filled with sensible defaults. */
@@ -95,6 +98,7 @@ export interface NormalizedGraphSpec {
95
98
  edges: GraphSpec['edges'];
96
99
  encoding: GraphEncoding;
97
100
  layout: GraphLayoutConfig;
101
+ nodeOverrides?: Record<string, NodeOverride>;
98
102
  chrome: NormalizedChrome;
99
103
  annotations: Annotation[];
100
104
  theme: ThemeConfig;
@@ -55,8 +55,8 @@ describe('resolveNodeVisuals', () => {
55
55
 
56
56
  // Min value should get min radius (3px)
57
57
  expect(minNode.radius).toBeCloseTo(3, 0);
58
- // Max value should get max radius (20px)
59
- expect(maxNode.radius).toBeCloseTo(20, 0);
58
+ // Max value should get max radius (12px)
59
+ expect(maxNode.radius).toBeCloseTo(12, 0);
60
60
  });
61
61
 
62
62
  it('uses default radius when no nodeSize encoding', () => {
@@ -215,6 +215,60 @@ describe('resolveNodeVisuals', () => {
215
215
  });
216
216
  });
217
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // nodeOverrides tests
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe('nodeOverrides', () => {
223
+ it('overrides fill color for a specific node', () => {
224
+ const overrides = { a: { fill: '#ff0000' } };
225
+ const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
226
+
227
+ const nodeA = nodes.find((n) => n.id === 'a')!;
228
+ expect(nodeA.fill).toBe('#ff0000');
229
+
230
+ // Other nodes should not be affected
231
+ const nodeB = nodes.find((n) => n.id === 'b')!;
232
+ expect(nodeB.fill).not.toBe('#ff0000');
233
+ });
234
+
235
+ it('overrides radius for a specific node', () => {
236
+ const overrides = { b: { radius: 15 } };
237
+ const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
238
+
239
+ const nodeB = nodes.find((n) => n.id === 'b')!;
240
+ expect(nodeB.radius).toBe(15);
241
+ });
242
+
243
+ it('overrides strokeWidth and stroke', () => {
244
+ const overrides = { c: { strokeWidth: 3, stroke: '#00ff00' } };
245
+ const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
246
+
247
+ const nodeC = nodes.find((n) => n.id === 'c')!;
248
+ expect(nodeC.strokeWidth).toBe(3);
249
+ expect(nodeC.stroke).toBe('#00ff00');
250
+ });
251
+
252
+ it('alwaysShowLabel sets labelPriority to Infinity', () => {
253
+ const overrides = { a: { alwaysShowLabel: true } };
254
+ const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
255
+
256
+ const nodeA = nodes.find((n) => n.id === 'a')!;
257
+ expect(nodeA.labelPriority).toBe(Infinity);
258
+ });
259
+
260
+ it('does not affect nodes without overrides', () => {
261
+ const overrides = { a: { fill: '#ff0000', radius: 25 } };
262
+ const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
263
+
264
+ const nodeB = nodes.find((n) => n.id === 'b')!;
265
+ const nodeC = nodes.find((n) => n.id === 'c')!;
266
+ // Default radius
267
+ expect(nodeB.radius).toBe(5);
268
+ expect(nodeC.radius).toBe(5);
269
+ });
270
+ });
271
+
218
272
  // ---------------------------------------------------------------------------
219
273
  // resolveEdgeVisuals tests
220
274
  // ---------------------------------------------------------------------------
@@ -280,6 +334,53 @@ describe('resolveEdgeVisuals', () => {
280
334
  });
281
335
  });
282
336
 
337
+ describe('edge style mapping', () => {
338
+ it('maps field values to solid/dashed/dotted via ordinal mapping', () => {
339
+ const styledEdges: GraphEdge[] = [
340
+ { source: 'a', target: 'b', kind: 'friend' },
341
+ { source: 'b', target: 'c', kind: 'colleague' },
342
+ { source: 'a', target: 'c', kind: 'family' },
343
+ ];
344
+ const encoding: GraphEncoding = {
345
+ edgeStyle: { field: 'kind' },
346
+ };
347
+
348
+ const edges = resolveEdgeVisuals(styledEdges, encoding, theme);
349
+
350
+ // Three unique values should map to solid, dashed, dotted
351
+ const styles = edges.map((e) => e.style);
352
+ expect(styles).toContain('solid');
353
+ expect(styles).toContain('dashed');
354
+ expect(styles).toContain('dotted');
355
+ });
356
+
357
+ it('wraps around when more unique values than style options', () => {
358
+ const styledEdges: GraphEdge[] = [
359
+ { source: 'a', target: 'b', kind: 'one' },
360
+ { source: 'b', target: 'c', kind: 'two' },
361
+ { source: 'a', target: 'c', kind: 'three' },
362
+ { source: 'a', target: 'b', kind: 'four' },
363
+ ];
364
+ const encoding: GraphEncoding = {
365
+ edgeStyle: { field: 'kind' },
366
+ };
367
+
368
+ const edges = resolveEdgeVisuals(styledEdges, encoding, theme);
369
+
370
+ // 4th unique value wraps back to 'solid'
371
+ const fourthEdge = edges.find((e) => e.data.kind === 'four')!;
372
+ expect(fourthEdge.style).toBe('solid');
373
+ });
374
+
375
+ it('defaults to solid when no edgeStyle encoding', () => {
376
+ const edges = resolveEdgeVisuals(basicEdges, {}, theme);
377
+
378
+ for (const edge of edges) {
379
+ expect(edge.style).toBe('solid');
380
+ }
381
+ });
382
+ });
383
+
283
384
  describe('data preservation', () => {
284
385
  it('original edge data is preserved', () => {
285
386
  const edges = resolveEdgeVisuals(basicEdges, {}, theme);
@@ -204,6 +204,7 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
204
204
  graphSpec.encoding,
205
205
  graphSpec.edges,
206
206
  theme,
207
+ graphSpec.nodeOverrides,
207
208
  );
208
209
 
209
210
  // 4. Assign communities
@@ -243,6 +244,7 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
243
244
  };
244
245
 
245
246
  // 10. Build simulation config
247
+ const collisionPadding = graphSpec.layout.collisionPadding ?? 2;
246
248
  const maxRadius =
247
249
  compiledNodes.length > 0
248
250
  ? Math.max(...compiledNodes.map((n) => n.radius))
@@ -253,7 +255,10 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
253
255
  clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
254
256
  alphaDecay: 0.0228,
255
257
  velocityDecay: 0.4,
256
- collisionRadius: maxRadius + 2,
258
+ collisionRadius: maxRadius + collisionPadding,
259
+ collisionPadding,
260
+ linkStrength: graphSpec.layout.linkStrength,
261
+ centerForce: graphSpec.layout.centerForce,
257
262
  };
258
263
 
259
264
  // 11. Build chrome
@@ -11,6 +11,7 @@ import type {
11
11
  GraphEdge,
12
12
  GraphEncoding,
13
13
  GraphNode,
14
+ NodeOverride,
14
15
  ResolvedTheme,
15
16
  } from '@opendata-ai/openchart-core';
16
17
  import { max, min } from 'd3-array';
@@ -24,7 +25,7 @@ import type { CompiledGraphEdge, CompiledGraphNode } from './types';
24
25
 
25
26
  const DEFAULT_NODE_RADIUS = 5;
26
27
  const MIN_NODE_RADIUS = 3;
27
- const MAX_NODE_RADIUS = 20;
28
+ const MAX_NODE_RADIUS = 12;
28
29
 
29
30
  const DEFAULT_EDGE_WIDTH = 1;
30
31
  const MIN_EDGE_WIDTH = 0.5;
@@ -119,6 +120,7 @@ export function resolveNodeVisuals(
119
120
  encoding: GraphEncoding,
120
121
  edges: GraphEdge[],
121
122
  theme: ResolvedTheme,
123
+ nodeOverrides?: Record<string, NodeOverride>,
122
124
  ): CompiledGraphNode[] {
123
125
  const degrees = computeDegrees(nodes, edges);
124
126
  const maxDegree = Math.max(1, ...degrees.values());
@@ -203,14 +205,22 @@ export function resolveNodeVisuals(
203
205
  const { id: _id, ...rest } = node;
204
206
  const data: Record<string, unknown> = { id: node.id, ...rest };
205
207
 
208
+ // Apply per-node overrides if present
209
+ const override = nodeOverrides?.[node.id];
210
+ const finalFill = override?.fill ?? fill;
211
+ const finalRadius = override?.radius ?? radius;
212
+ const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH;
213
+ const finalStroke = override?.stroke ?? stroke;
214
+ const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
215
+
206
216
  return {
207
217
  id: node.id,
208
- radius,
209
- fill,
210
- stroke,
211
- strokeWidth: DEFAULT_STROKE_WIDTH,
218
+ radius: finalRadius,
219
+ fill: finalFill,
220
+ stroke: finalStroke,
221
+ strokeWidth: finalStrokeWidth,
212
222
  label,
213
- labelPriority,
223
+ labelPriority: finalLabelPriority,
214
224
  community: undefined,
215
225
  data,
216
226
  };
@@ -277,6 +287,19 @@ export function resolveEdgeVisuals(
277
287
 
278
288
  const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
279
289
 
290
+ // Edge style mapping (ordinal: map unique field values to solid/dashed/dotted)
291
+ const EDGE_STYLES: Array<'solid' | 'dashed' | 'dotted'> = ['solid', 'dashed', 'dotted'];
292
+ let styleFn: ((edge: GraphEdge) => 'solid' | 'dashed' | 'dotted') | undefined;
293
+ if (encoding.edgeStyle?.field) {
294
+ const field = encoding.edgeStyle.field;
295
+ const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? '')))];
296
+ const styleMap = new Map<string, 'solid' | 'dashed' | 'dotted'>();
297
+ for (let i = 0; i < uniqueValues.length; i++) {
298
+ styleMap.set(uniqueValues[i], EDGE_STYLES[i % EDGE_STYLES.length]);
299
+ }
300
+ styleFn = (edge: GraphEdge) => styleMap.get(String(edge[field] ?? '')) ?? 'solid';
301
+ }
302
+
280
303
  return edges.map((edge) => {
281
304
  const { source, target, ...rest } = edge;
282
305
 
@@ -289,13 +312,14 @@ export function resolveEdgeVisuals(
289
312
  }
290
313
 
291
314
  const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
315
+ const style = styleFn ? styleFn(edge) : ('solid' as const);
292
316
 
293
317
  return {
294
318
  source,
295
319
  target,
296
320
  stroke,
297
321
  strokeWidth,
298
- style: 'solid' as const,
322
+ style,
299
323
  data: { source, target, ...rest } as Record<string, unknown>,
300
324
  };
301
325
  });
@@ -67,6 +67,12 @@ export interface SimulationConfig {
67
67
  velocityDecay: number;
68
68
  /** Collision radius: max node radius + padding. */
69
69
  collisionRadius: number;
70
+ /** Extra px added to node radius for collision (default 2). */
71
+ collisionPadding?: number;
72
+ /** Link force strength override. */
73
+ linkStrength?: number;
74
+ /** Whether to apply center force (default true). */
75
+ centerForce?: boolean;
70
76
  }
71
77
 
72
78
  /**