@opendata-ai/openchart-engine 6.20.0 → 6.22.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.
@@ -0,0 +1,93 @@
1
+ import type { AxisLayout, Rect, ResolvedChrome, ResolvedTheme } from '@opendata-ai/openchart-core';
2
+ import { BRAND_RESERVE_WIDTH, resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { AxesResult } from '../../layout/axes';
5
+ import type { LayoutDimensions } from '../../layout/dimensions';
6
+ import { computeWatermarkObstacle } from '../watermark-obstacle';
7
+
8
+ const theme: ResolvedTheme = resolveTheme();
9
+
10
+ function makeDims(overrides: Partial<LayoutDimensions> = {}): LayoutDimensions {
11
+ const total: Rect = { x: 0, y: 0, width: 800, height: 500 };
12
+ const chartArea: Rect = { x: 60, y: 80, width: 700, height: 360 };
13
+ const chrome: ResolvedChrome = {
14
+ topHeight: 80,
15
+ bottomHeight: 40,
16
+ } as ResolvedChrome;
17
+ return {
18
+ total,
19
+ chartArea,
20
+ chrome,
21
+ margins: { top: 80, right: 40, bottom: 40, left: 60 },
22
+ theme,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function makeAxis(label?: string): AxisLayout {
28
+ return {
29
+ ticks: [],
30
+ gridlines: [],
31
+ label,
32
+ tickLabelStyle: {
33
+ fontFamily: theme.fonts.family,
34
+ fontSize: 12,
35
+ fontWeight: 400,
36
+ fill: '#000',
37
+ lineHeight: 1.2,
38
+ },
39
+ start: { x: 0, y: 0 },
40
+ end: { x: 100, y: 0 },
41
+ };
42
+ }
43
+
44
+ describe('computeWatermarkObstacle', () => {
45
+ it('returns null when watermark is disabled', () => {
46
+ const dims = makeDims();
47
+ const axes: AxesResult = { x: makeAxis(), y: makeAxis() };
48
+ expect(computeWatermarkObstacle(dims, false, axes, theme)).toBeNull();
49
+ });
50
+
51
+ it('sits below the x-axis when no bottom chrome is present', () => {
52
+ const dims = makeDims();
53
+ const axes: AxesResult = { x: makeAxis('value'), y: makeAxis() };
54
+ const rect = computeWatermarkObstacle(dims, true, axes, theme);
55
+ expect(rect).not.toBeNull();
56
+ // Right-aligned: total.width - padding - BRAND_RESERVE_WIDTH
57
+ expect(rect!.x).toBe(dims.total.width - theme.spacing.padding - BRAND_RESERVE_WIDTH);
58
+ expect(rect!.width).toBe(BRAND_RESERVE_WIDTH);
59
+ expect(rect!.height).toBe(30);
60
+ // Watermark sits below chart area, offset by x-axis extent + chartToFooter
61
+ const expectedY = dims.chartArea.y + dims.chartArea.height + 48 + theme.spacing.chartToFooter;
62
+ expect(rect!.y).toBe(expectedY);
63
+ });
64
+
65
+ it('aligns with bottom chrome when a source element is present', () => {
66
+ const sourceY = 20;
67
+ const dims = makeDims({
68
+ chrome: {
69
+ topHeight: 80,
70
+ bottomHeight: 40,
71
+ source: {
72
+ text: 'Source: Test',
73
+ x: 10,
74
+ y: sourceY,
75
+ maxWidth: 500,
76
+ style: {
77
+ fontFamily: theme.fonts.family,
78
+ fontSize: 10,
79
+ fontWeight: 400,
80
+ fill: '#666',
81
+ lineHeight: 1.2,
82
+ },
83
+ },
84
+ } as ResolvedChrome,
85
+ });
86
+ const axes: AxesResult = { x: makeAxis(), y: makeAxis() };
87
+ const rect = computeWatermarkObstacle(dims, true, axes, theme);
88
+ expect(rect).not.toBeNull();
89
+ // With axis present but no label, extent is 26.
90
+ // Y is chartArea.y + chartArea.height + 26 + sourceY
91
+ expect(rect!.y).toBe(dims.chartArea.y + dims.chartArea.height + 26 + sourceY);
92
+ });
93
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Apply the theme palette as the color scale range when no explicit range was provided.
3
+ *
4
+ * Sequential scales take the first/last stops of the first sequential palette
5
+ * (or the categorical endpoints as a fallback). Categorical scales get the
6
+ * full categorical palette. A user-provided `encoding.color.scale.range`
7
+ * always wins.
8
+ */
9
+
10
+ import type { Encoding, ResolvedTheme } from '@opendata-ai/openchart-core';
11
+ import type { ScaleLinear, ScaleOrdinal } from 'd3-scale';
12
+ import type { ResolvedScales } from '../layout/scales';
13
+
14
+ /** Mutates `scales.color.scale.range` in place when no explicit palette was set. */
15
+ export function applyColorScaleRange(
16
+ scales: ResolvedScales,
17
+ encoding: Encoding,
18
+ theme: ResolvedTheme,
19
+ ): void {
20
+ if (!scales.color) return;
21
+
22
+ const hasExplicitRange = !!(
23
+ encoding.color &&
24
+ 'field' in encoding.color &&
25
+ (encoding.color.scale?.range as string[] | undefined)?.length
26
+ );
27
+ if (hasExplicitRange) return;
28
+
29
+ if (scales.color.type === 'sequential') {
30
+ const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
31
+ (scales.color.scale as unknown as ScaleLinear<string, string>).range([
32
+ seqStops[0],
33
+ seqStops[seqStops.length - 1],
34
+ ]);
35
+ } else {
36
+ (scales.color.scale as ScaleOrdinal<string, string>).range(theme.colors.categorical);
37
+ }
38
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Data filter for clipped scale domains.
3
+ *
4
+ * When an x or y encoding declares `scale.clip: true` with a numeric
5
+ * [lo, hi] domain, rows whose field value falls outside that range are
6
+ * dropped before scales and marks are computed. Pure and side-effect free.
7
+ */
8
+
9
+ import type { DataRow, Encoding } from '@opendata-ai/openchart-core';
10
+
11
+ /**
12
+ * Return a new data array with rows outside any clipped scale domain removed.
13
+ *
14
+ * Only inspects the `x` and `y` channels. Non-numeric domains and channels
15
+ * without `scale.clip` are passed through unchanged.
16
+ */
17
+ export function filterClippedDomains(data: DataRow[], encoding: Encoding): DataRow[] {
18
+ let result = data;
19
+ for (const channel of ['x', 'y'] as const) {
20
+ const enc = encoding[channel];
21
+ if (!enc?.scale?.clip || !enc.scale.domain) continue;
22
+ const domain = enc.scale.domain;
23
+ const field = enc.field;
24
+ if (Array.isArray(domain) && domain.length === 2 && typeof domain[0] === 'number') {
25
+ const [lo, hi] = domain as [number, number];
26
+ result = result.filter((row) => {
27
+ const v = Number(row[field]);
28
+ return Number.isFinite(v) && v >= lo && v <= hi;
29
+ });
30
+ }
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Compute the brand watermark obstacle rect.
3
+ *
4
+ * The watermark is right-aligned on the same baseline as the first bottom
5
+ * chrome element (source, byline, or footer), offset below the chart area
6
+ * by the x-axis extent (tick labels + axis title). Returns null when the
7
+ * watermark is disabled so callers can skip obstacle collection entirely.
8
+ */
9
+
10
+ import type { Rect, ResolvedTheme } from '@opendata-ai/openchart-core';
11
+ import { BRAND_RESERVE_WIDTH } from '@opendata-ai/openchart-core';
12
+ import type { AxesResult } from '../layout/axes';
13
+ import type { LayoutDimensions } from '../layout/dimensions';
14
+
15
+ /** Height of the watermark element used for obstacle avoidance. */
16
+ const WATERMARK_HEIGHT = 30;
17
+
18
+ /** Vertical padding below the x-axis label when an axis title is present. */
19
+ const X_AXIS_EXTENT_WITH_LABEL = 48;
20
+
21
+ /** Vertical padding below the x-axis ticks when no axis title is present. */
22
+ const X_AXIS_EXTENT_TICKS_ONLY = 26;
23
+
24
+ /**
25
+ * Compute the rect occupied by the watermark, or null when it is disabled.
26
+ *
27
+ * @param dims - Layout dimensions (for total width and chrome positions).
28
+ * @param watermark - Whether the watermark is enabled for this chart.
29
+ * @param axes - Computed axes (the x-axis determines how far below the chart the watermark sits).
30
+ * @param theme - Resolved theme (padding + fallback spacing).
31
+ */
32
+ export function computeWatermarkObstacle(
33
+ dims: LayoutDimensions,
34
+ watermark: boolean,
35
+ axes: AxesResult,
36
+ theme: ResolvedTheme,
37
+ ): Rect | null {
38
+ if (!watermark) return null;
39
+
40
+ const chartArea = dims.chartArea;
41
+ const brandPadding = theme.spacing.padding;
42
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
43
+ const xAxisExtent = axes.x?.label
44
+ ? X_AXIS_EXTENT_WITH_LABEL
45
+ : axes.x
46
+ ? X_AXIS_EXTENT_TICKS_ONLY
47
+ : 0;
48
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
49
+ const brandY = firstBottomChrome
50
+ ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
51
+ : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
52
+
53
+ return { x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: WATERMARK_HEIGHT };
54
+ }
package/src/compile.ts CHANGED
@@ -32,7 +32,6 @@ import type {
32
32
  } from '@opendata-ai/openchart-core';
33
33
  import {
34
34
  adaptTheme,
35
- BRAND_RESERVE_WIDTH,
36
35
  computeLabelBounds,
37
36
  generateAltText,
38
37
  generateDataTable,
@@ -42,57 +41,21 @@ import {
42
41
  resolveTheme,
43
42
  } from '@opendata-ai/openchart-core';
44
43
  import { computeAnnotations } from './annotations/compute';
45
- import { barRenderer } from './charts/bar';
46
- import { columnRenderer } from './charts/column';
47
- import { dotRenderer } from './charts/dot';
48
- import { areaRenderer, lineRenderer } from './charts/line';
49
- import { donutRenderer, pieRenderer } from './charts/pie';
44
+ // Side-effect import: registers all built-in chart renderers with the
45
+ // registry on module load. Tests that clear the registry can import
46
+ // `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
47
+ import './charts/builtin';
50
48
  import {
51
49
  assignAnimationIndices,
52
50
  computeMarkObstacles,
53
51
  resolveRendererKey,
54
52
  } from './charts/post-process';
55
- import { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
56
- import { ruleRenderer } from './charts/rule';
57
- import { scatterRenderer } from './charts/scatter';
58
- import { textRenderer } from './charts/text';
59
- import { tickRenderer } from './charts/tick';
60
- import { compile as compileSpec, flattenLayers } from './compiler/index';
61
-
62
- // Register all built-in chart renderers under the new Vega-Lite mark type names.
63
- // Explicit imports ensure bundlers cannot tree-shake the registrations away.
64
- //
65
- // Mark type mapping from old chart types:
66
- // - 'bar' -> barRenderer (horizontal bars, old 'bar')
67
- // - 'bar:vertical' is handled by columnRenderer (old 'column')
68
- // - 'arc' -> pieRenderer (old 'pie'); donutRenderer is also registered
69
- // - 'point' -> scatterRenderer (old 'scatter')
70
- // - 'circle' -> dotRenderer (old 'dot')
71
- // - 'line' and 'area' unchanged
72
- // - 'text', 'rule', 'tick' are new Vega-Lite mark types
73
- //
74
- // For 'bar', orientation is resolved at compile time to dispatch to the right renderer.
75
- // We register both barRenderer and columnRenderer; the compile function picks based on orientation.
76
- const builtinRenderers: Record<string, ChartRenderer> = {
77
- line: lineRenderer,
78
- area: areaRenderer,
79
- bar: barRenderer, // horizontal bars
80
- 'bar:vertical': columnRenderer, // vertical bars (old 'column')
81
- point: scatterRenderer, // old 'scatter'
82
- arc: pieRenderer, // old 'pie' (donut handled via innerRadius)
83
- 'arc:donut': donutRenderer, // old 'donut'
84
- circle: dotRenderer, // old 'dot'
85
- lollipop: dotRenderer, // semantic alias for dot/circle
86
- text: textRenderer,
87
- rule: ruleRenderer,
88
- tick: tickRenderer,
89
- rect: columnRenderer, // rect uses column renderer (RectMark output) as baseline for heatmaps
90
- };
91
- for (const [type, renderer] of Object.entries(builtinRenderers)) {
92
- registerChartRenderer(type, renderer);
93
- }
94
-
53
+ import { getChartRenderer } from './charts/registry';
54
+ import { applyColorScaleRange } from './compile/color-scale-range';
55
+ import { filterClippedDomains } from './compile/data-clip';
56
+ import { computeWatermarkObstacle } from './compile/watermark-obstacle';
95
57
  import { resolveAnimation } from './compiler/animation';
58
+ import { compile as compileSpec, flattenLayers } from './compiler/index';
96
59
  import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
97
60
  import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
98
61
  import type { GraphCompilation } from './graphs/types';
@@ -307,6 +270,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
307
270
  theme = adaptTheme(theme);
308
271
  }
309
272
 
273
+ // INVARIANT 1 — double legend pass: preliminaryArea → computeDimensions → legendArea → final
274
+ // legend. Breaks a dims/legend dependency cycle. Do not collapse into one call.
310
275
  // Compute legend first (needs to reserve space)
311
276
  const preliminaryArea: Rect = {
312
277
  x: 0,
@@ -358,19 +323,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
358
323
  }
359
324
 
360
325
  // Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
361
- for (const channel of ['x', 'y'] as const) {
362
- const enc = chartSpec.encoding[channel];
363
- if (!enc?.scale?.clip || !enc.scale.domain) continue;
364
- const domain = enc.scale.domain;
365
- const field = enc.field;
366
- if (Array.isArray(domain) && domain.length === 2 && typeof domain[0] === 'number') {
367
- const [lo, hi] = domain as [number, number];
368
- renderData = renderData.filter((row) => {
369
- const v = Number(row[field]);
370
- return Number.isFinite(v) && v >= lo && v <= hi;
371
- });
372
- }
373
- }
326
+ renderData = filterClippedDomains(renderData, chartSpec.encoding);
374
327
 
375
328
  // Build a filtered spec for scales and marks, keeping all other properties intact
376
329
  const renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
@@ -379,32 +332,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
379
332
  const scales = computeScales(renderSpec, chartArea, renderSpec.data);
380
333
 
381
334
  // Update color scale to use theme palette (only when user hasn't provided an explicit range)
382
- if (scales.color) {
383
- const hasExplicitRange = !!(
384
- renderSpec.encoding.color &&
385
- 'field' in renderSpec.encoding.color &&
386
- (renderSpec.encoding.color.scale?.range as string[] | undefined)?.length
387
- );
388
- if (scales.color.type === 'sequential') {
389
- // Sequential: use first sequential palette (or fall back to categorical endpoints)
390
- if (!hasExplicitRange) {
391
- const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
392
- (scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
393
- seqStops[0],
394
- seqStops[seqStops.length - 1],
395
- ]);
396
- }
397
- } else {
398
- if (!hasExplicitRange) {
399
- (scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
400
- theme.colors.categorical,
401
- );
402
- }
403
- }
404
- }
335
+ applyColorScaleRange(scales, renderSpec.encoding, theme);
405
336
 
406
- // Set default color for single-series charts. If the user set a fill on the mark def
407
- // (string or gradient), that takes priority over the theme's first categorical color.
337
+ // INVARIANT 3 post-hoc defaultColor: must run AFTER computeScales since resolution needs
338
+ // theme context. Do not move into computeScales (would require threading theme through).
339
+ // If the user set a fill on the mark def, it takes priority over the theme's first categorical.
408
340
  scales.defaultColor = chartSpec.markDef.fill ?? theme.colors.categorical[0];
409
341
 
410
342
  // Arc charts (pie/donut) don't use axes or gridlines
@@ -415,7 +347,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
415
347
  ? { x: undefined, y: undefined }
416
348
  : computeAxes(scales, chartArea, strategy, theme, options.measureText);
417
349
 
418
- // Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
350
+ // INVARIANT 2 computeGridlines mutates `axes` in place. Downstream consumers read
351
+ // axes.y.gridlines off the same object. Do not introduce a copy-on-write.
419
352
  if (!isRadial) {
420
353
  computeGridlines(axes, chartArea);
421
354
  }
@@ -444,18 +377,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
444
377
  }
445
378
 
446
379
  // Add brand watermark as an obstacle so annotations avoid overlapping it.
447
- // The brand is right-aligned on the same baseline as the first bottom chrome element,
448
- // offset below the chart area by x-axis extent (tick labels + axis title).
449
- if (watermark) {
450
- const brandPadding = theme.spacing.padding;
451
- const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
452
- const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
453
- const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
454
- const brandY = firstBottomChrome
455
- ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
456
- : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
457
- obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
458
- }
380
+ const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
381
+ if (watermarkRect) obstacles.push(watermarkRect);
459
382
  const annotations: ResolvedAnnotation[] = computeAnnotations(
460
383
  chartSpec,
461
384
  scales,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tick label overlap detection and density thinning.
3
+ *
4
+ * Horizontal orientation (x-axis): checks label width against adjacent
5
+ * positions. Vertical orientation (y-axis): checks font-based label height
6
+ * against adjacent positions, ignoring text width so wide numeric labels
7
+ * don't trigger aggressive thinning.
8
+ */
9
+
10
+ import type { AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
11
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
+
13
+ /**
14
+ * Minimum gap between adjacent tick labels as a multiple of font size.
15
+ * At the default 12px axis font, this yields ~12px of breathing room.
16
+ */
17
+ const MIN_TICK_GAP_FACTOR = 1.0;
18
+
19
+ /** Always show at least this many ticks, even if they overlap. */
20
+ const MIN_TICK_COUNT = 2;
21
+
22
+ /** Measure a single label's width using real measurement or heuristic fallback. */
23
+ export function measureLabel(
24
+ text: string,
25
+ fontSize: number,
26
+ fontWeight: number,
27
+ measureText?: MeasureTextFn,
28
+ ): number {
29
+ return measureText
30
+ ? measureText(text, fontSize, fontWeight).width
31
+ : estimateTextWidth(text, fontSize, fontWeight);
32
+ }
33
+
34
+ /** Check whether any adjacent tick labels overlap along the axis direction. */
35
+ export function ticksOverlap(
36
+ ticks: AxisTick[],
37
+ fontSize: number,
38
+ fontWeight: number,
39
+ measureText?: MeasureTextFn,
40
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
41
+ ): boolean {
42
+ if (ticks.length < 2) return false;
43
+ const minGap = fontSize * MIN_TICK_GAP_FACTOR;
44
+
45
+ if (orientation === 'vertical') {
46
+ // Y-axis: labels are stacked vertically. Check if vertical extent
47
+ // (based on font height) overlaps between adjacent ticks.
48
+ // Positions decrease going up in SVG coords, so sort ascending.
49
+ const sorted = [...ticks].sort((a, b) => a.position - b.position);
50
+ const labelHeight = fontSize * 1.2; // lineHeight
51
+ for (let i = 0; i < sorted.length - 1; i++) {
52
+ const aBottom = sorted[i].position + labelHeight / 2;
53
+ const bTop = sorted[i + 1].position - labelHeight / 2;
54
+ if (aBottom + minGap > bTop) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ for (let i = 0; i < ticks.length - 1; i++) {
60
+ const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
61
+ const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
62
+ const aRight = ticks[i].position + aWidth / 2;
63
+ const bLeft = ticks[i + 1].position - bWidth / 2;
64
+ if (aRight + minGap > bLeft) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * Thin a tick array by removing every other tick until labels don't overlap.
71
+ * Always keeps first and last tick. O(log n) iterations max.
72
+ * Returns the original array if no thinning is needed.
73
+ */
74
+ export function thinTicksUntilFit(
75
+ ticks: AxisTick[],
76
+ fontSize: number,
77
+ fontWeight: number,
78
+ measureText?: MeasureTextFn,
79
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
80
+ ): AxisTick[] {
81
+ if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
82
+
83
+ let current = ticks;
84
+ while (current.length > MIN_TICK_COUNT) {
85
+ // Keep first, last, and every other tick in between
86
+ const thinned = [current[0]];
87
+ for (let i = 2; i < current.length - 1; i += 2) {
88
+ thinned.push(current[i]);
89
+ }
90
+ if (current.length > 1) thinned.push(current[current.length - 1]);
91
+ current = thinned;
92
+
93
+ if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
94
+ }
95
+ return current;
96
+ }