@opendata-ai/openchart-engine 6.19.3 → 6.21.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.
Files changed (36) hide show
  1. package/dist/index.d.ts +6 -0
  2. package/dist/index.js +865 -3729
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
  6. package/src/__tests__/axes.test.ts +65 -0
  7. package/src/__tests__/compile-snapshot.test.ts +156 -0
  8. package/src/__tests__/legend.test.ts +39 -0
  9. package/src/charts/__tests__/registry.test.ts +6 -0
  10. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  11. package/src/charts/_shared/density-filter.ts +26 -0
  12. package/src/charts/_shared/format-label-value.ts +15 -0
  13. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  14. package/src/charts/bar/compute.ts +6 -11
  15. package/src/charts/bar/labels.ts +4 -15
  16. package/src/charts/builtin.ts +64 -0
  17. package/src/charts/column/compute.ts +6 -11
  18. package/src/charts/column/labels.ts +4 -19
  19. package/src/charts/dot/labels.ts +4 -19
  20. package/src/charts/pie/labels.ts +4 -6
  21. package/src/charts/registry.ts +6 -0
  22. package/src/compile/__tests__/color-scale-range.test.ts +79 -0
  23. package/src/compile/__tests__/data-clip.test.ts +59 -0
  24. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  25. package/src/compile/color-scale-range.ts +38 -0
  26. package/src/compile/data-clip.ts +33 -0
  27. package/src/compile/watermark-obstacle.ts +54 -0
  28. package/src/compile.ts +20 -97
  29. package/src/layout/axes/thinning.ts +96 -0
  30. package/src/layout/axes/ticks.ts +266 -0
  31. package/src/layout/axes.ts +148 -249
  32. package/src/legend/compute.ts +6 -51
  33. package/src/legend/wrap.ts +94 -0
  34. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  35. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  36. package/src/sankey/compile-sankey.ts +5 -20
@@ -20,11 +20,12 @@ import type {
20
20
  Rect,
21
21
  RectMark,
22
22
  } from '@opendata-ai/openchart-core';
23
- import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
23
+ import { isGradientDef } from '@opendata-ai/openchart-core';
24
24
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
25
25
  import type { NormalizedChartSpec } from '../../compiler/types';
26
26
  import type { ResolvedScales } from '../../layout/scales';
27
27
  import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
28
+ import { formatLabelValue } from '../_shared/format-label-value';
28
29
  import { getColor, getSequentialColor, groupByField } from '../utils';
29
30
 
30
31
  // ---------------------------------------------------------------------------
@@ -33,12 +34,6 @@ import { getColor, getSequentialColor, groupByField } from '../utils';
33
34
 
34
35
  const MIN_COLUMN_HEIGHT = 1;
35
36
 
36
- /** Format a column value for display (abbreviate large numbers). */
37
- function formatColumnValue(value: number): string {
38
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
39
- return formatNumber(value);
40
- }
41
-
42
37
  // ---------------------------------------------------------------------------
43
38
  // Public API
44
39
  // ---------------------------------------------------------------------------
@@ -199,7 +194,7 @@ function computeSimpleColumns(
199
194
  const y = value >= 0 ? yPos : baseline;
200
195
 
201
196
  const aria: MarkAria = {
202
- label: `${category}: ${formatColumnValue(value)}`,
197
+ label: `${category}: ${formatLabelValue(value)}`,
203
198
  };
204
199
 
205
200
  marks.push({
@@ -250,7 +245,7 @@ function computeColoredColumns(
250
245
  const y = value >= 0 ? yPos : baseline;
251
246
 
252
247
  const aria: MarkAria = {
253
- label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
248
+ label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
254
249
  };
255
250
 
256
251
  marks.push({
@@ -320,7 +315,7 @@ function computeGroupedColumns(
320
315
  const subX = bandX + groupIndex * (subBandWidth + gap);
321
316
 
322
317
  const aria: MarkAria = {
323
- label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
318
+ label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
324
319
  };
325
320
 
326
321
  marks.push({
@@ -389,7 +384,7 @@ function computeStackedColumns(
389
384
  const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
390
385
 
391
386
  const aria: MarkAria = {
392
- label: `${category}, ${groupKey}: ${formatColumnValue(rawValue)}`,
387
+ label: `${category}, ${groupKey}: ${formatLabelValue(rawValue)}`,
393
388
  };
394
389
 
395
390
  marks.push({
@@ -18,23 +18,13 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
- abbreviateNumber,
22
21
  buildD3Formatter,
23
22
  estimateTextWidth,
24
- formatNumber,
25
23
  getRepresentativeColor,
26
24
  resolveCollisions,
27
25
  } from '@opendata-ai/openchart-core';
28
-
29
- // ---------------------------------------------------------------------------
30
- // Helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- /** Format a column value for display (abbreviate large numbers). */
34
- function formatColumnValue(value: number): string {
35
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
- return formatNumber(value);
37
- }
26
+ import { filterByDensity } from '../_shared/density-filter';
27
+ import { formatLabelValue } from '../_shared/format-label-value';
38
28
 
39
29
  // ---------------------------------------------------------------------------
40
30
  // Constants
@@ -61,12 +51,7 @@ export function computeColumnLabels(
61
51
  labelPrefix?: string,
62
52
  valueField?: string,
63
53
  ): ResolvedLabel[] {
64
- // 'none': no labels at all
65
- if (density === 'none') return [];
66
-
67
- // Filter marks for 'endpoints' density
68
- const targetMarks =
69
- density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
54
+ const targetMarks = filterByDensity(marks, density);
70
55
 
71
56
  const formatter = buildD3Formatter(labelFormat);
72
57
 
@@ -82,7 +67,7 @@ export function computeColumnLabels(
82
67
  if (formatter && Number.isFinite(rawNum)) {
83
68
  valuePart = formatter(rawNum);
84
69
  } else if (Number.isFinite(rawNum)) {
85
- valuePart = formatColumnValue(rawNum);
70
+ valuePart = formatLabelValue(rawNum);
86
71
  } else {
87
72
  // Fallback: extract from aria label
88
73
  const ariaLabel = mark.aria.label;
@@ -18,23 +18,13 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
- abbreviateNumber,
22
21
  buildD3Formatter,
23
22
  estimateTextWidth,
24
- formatNumber,
25
23
  getRepresentativeColor,
26
24
  resolveCollisions,
27
25
  } from '@opendata-ai/openchart-core';
28
-
29
- // ---------------------------------------------------------------------------
30
- // Helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- /** Format a dot value for display (abbreviate large numbers). */
34
- function formatDotValue(value: number): string {
35
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
- return formatNumber(value);
37
- }
26
+ import { filterByDensity } from '../_shared/density-filter';
27
+ import { formatLabelValue } from '../_shared/format-label-value';
38
28
 
39
29
  // ---------------------------------------------------------------------------
40
30
  // Constants
@@ -61,12 +51,7 @@ export function computeDotLabels(
61
51
  labelFormat?: string,
62
52
  valueField?: string,
63
53
  ): ResolvedLabel[] {
64
- // 'none': no labels at all
65
- if (density === 'none') return [];
66
-
67
- // Filter marks for 'endpoints' density
68
- const targetMarks =
69
- density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
54
+ const targetMarks = filterByDensity(marks, density);
70
55
 
71
56
  const formatter = buildD3Formatter(labelFormat);
72
57
  const candidates: LabelCandidate[] = [];
@@ -81,7 +66,7 @@ export function computeDotLabels(
81
66
  if (formatter && Number.isFinite(rawNum)) {
82
67
  valuePart = formatter(rawNum);
83
68
  } else if (Number.isFinite(rawNum)) {
84
- valuePart = formatDotValue(rawNum);
69
+ valuePart = formatLabelValue(rawNum);
85
70
  } else {
86
71
  // Fallback: extract from aria label
87
72
  const ariaLabel = mark.aria.label;
@@ -20,6 +20,7 @@ import type {
20
20
  ResolvedLabel,
21
21
  } from '@opendata-ai/openchart-core';
22
22
  import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
23
+ import { filterByDensity } from '../_shared/density-filter';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Constants
@@ -48,16 +49,13 @@ export function computePieLabels(
48
49
  ): ResolvedLabel[] {
49
50
  if (marks.length === 0) return [];
50
51
 
51
- // 'none': no labels at all
52
- if (density === 'none') return [];
53
-
54
52
  // Get the pie center from the first mark's center property
53
+ // (read before filtering — 'endpoints' still needs the original center)
55
54
  const centerX = marks[0].center.x;
56
55
  const centerY = marks[0].center.y;
57
56
 
58
- // Filter marks for 'endpoints' density
59
- const targetMarks =
60
- density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
57
+ const targetMarks = filterByDensity(marks, density);
58
+ if (targetMarks.length === 0) return [];
61
59
 
62
60
  const candidates: LabelCandidate[] = [];
63
61
  const targetMarkIndices: number[] = [];
@@ -58,6 +58,12 @@ export function getChartRenderer(type: string): ChartRenderer | undefined {
58
58
 
59
59
  /**
60
60
  * Clear all registered renderers. Useful for testing.
61
+ *
62
+ * Note: the built-in renderers (line, bar, column, ...) register as a
63
+ * side-effect of importing `./builtin`. Once cleared, they do not come
64
+ * back on their own — subsequent `compileChart(...)` calls will return
65
+ * empty marks. Tests that call `clearRenderers()` should follow up with
66
+ * `registerBuiltinRenderers()` from `./builtin` to restore the defaults.
61
67
  */
62
68
  export function clearRenderers(): void {
63
69
  renderers.clear();
@@ -0,0 +1,79 @@
1
+ import type { Encoding, ResolvedTheme } from '@opendata-ai/openchart-core';
2
+ import { resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { scaleLinear, scaleOrdinal } from 'd3-scale';
4
+ import { describe, expect, it } from 'vitest';
5
+ import type { ResolvedScales } from '../../layout/scales';
6
+ import { applyColorScaleRange } from '../color-scale-range';
7
+
8
+ const theme: ResolvedTheme = resolveTheme();
9
+
10
+ describe('applyColorScaleRange', () => {
11
+ it('is a no-op when no color scale is present', () => {
12
+ const scales: ResolvedScales = {};
13
+ const encoding: Encoding = {
14
+ x: { field: 'x', type: 'quantitative' },
15
+ y: { field: 'y', type: 'quantitative' },
16
+ };
17
+ expect(() => applyColorScaleRange(scales, encoding, theme)).not.toThrow();
18
+ expect(scales.color).toBeUndefined();
19
+ });
20
+
21
+ it('does not overwrite the range when the encoding declares an explicit palette', () => {
22
+ // computeScales has already applied the explicit palette to the scale.
23
+ // The helper must leave it untouched (not replace it with the theme palette).
24
+ const explicit = ['#111111', '#222222', '#333333'];
25
+ const ordinal = scaleOrdinal<string, string>().domain(['a', 'b', 'c']).range(explicit);
26
+ const scales: ResolvedScales = {
27
+ color: { scale: ordinal, type: 'ordinal', channel: 'color' },
28
+ };
29
+ const encoding: Encoding = {
30
+ x: { field: 'x', type: 'nominal' },
31
+ y: { field: 'y', type: 'quantitative' },
32
+ color: {
33
+ field: 'c',
34
+ type: 'nominal',
35
+ scale: { range: explicit },
36
+ },
37
+ };
38
+ applyColorScaleRange(scales, encoding, theme);
39
+ expect(ordinal.range()).toEqual(explicit);
40
+ expect(ordinal.range()).not.toEqual(theme.colors.categorical);
41
+ });
42
+
43
+ it('assigns the theme categorical palette when no range is set', () => {
44
+ const ordinal = scaleOrdinal<string, string>().domain(['a', 'b', 'c']);
45
+ const scales: ResolvedScales = {
46
+ color: { scale: ordinal, type: 'ordinal', channel: 'color' },
47
+ };
48
+ const encoding: Encoding = {
49
+ x: { field: 'x', type: 'nominal' },
50
+ y: { field: 'y', type: 'quantitative' },
51
+ color: { field: 'c', type: 'nominal' },
52
+ };
53
+ applyColorScaleRange(scales, encoding, theme);
54
+ expect(ordinal.range()).toEqual(theme.colors.categorical);
55
+ });
56
+
57
+ it('uses the first sequential palette endpoints for sequential color scales', () => {
58
+ const linear = scaleLinear<string, string>().domain([0, 100]);
59
+ const scales: ResolvedScales = {
60
+ color: {
61
+ scale: linear as unknown as ResolvedScales['color'] extends infer T
62
+ ? T extends { scale: infer S }
63
+ ? S
64
+ : never
65
+ : never,
66
+ type: 'sequential',
67
+ channel: 'color',
68
+ } as NonNullable<ResolvedScales['color']>,
69
+ };
70
+ const encoding: Encoding = {
71
+ x: { field: 'x', type: 'quantitative' },
72
+ y: { field: 'y', type: 'quantitative' },
73
+ color: { field: 'v', type: 'quantitative' },
74
+ };
75
+ applyColorScaleRange(scales, encoding, theme);
76
+ const firstSeq = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
77
+ expect(linear.range()).toEqual([firstSeq[0], firstSeq[firstSeq.length - 1]]);
78
+ });
79
+ });
@@ -0,0 +1,59 @@
1
+ import type { Encoding } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { filterClippedDomains } from '../data-clip';
4
+
5
+ describe('filterClippedDomains', () => {
6
+ const data = [
7
+ { x: -5, y: 10 },
8
+ { x: 10, y: 20 },
9
+ { x: 25, y: 80 },
10
+ { x: 50, y: 110 },
11
+ { x: 80, y: 200 },
12
+ ];
13
+
14
+ it('returns data unchanged when no channel declares scale.clip', () => {
15
+ const encoding: Encoding = {
16
+ x: { field: 'x', type: 'quantitative' },
17
+ y: { field: 'y', type: 'quantitative' },
18
+ };
19
+ const result = filterClippedDomains(data, encoding);
20
+ expect(result).toBe(data);
21
+ expect(result).toHaveLength(5);
22
+ });
23
+
24
+ it('filters rows outside the x-axis clipped domain', () => {
25
+ const encoding: Encoding = {
26
+ x: {
27
+ field: 'x',
28
+ type: 'quantitative',
29
+ scale: { domain: [0, 30], clip: true },
30
+ },
31
+ y: { field: 'y', type: 'quantitative' },
32
+ };
33
+ const result = filterClippedDomains(data, encoding);
34
+ expect(result).toEqual([
35
+ { x: 10, y: 20 },
36
+ { x: 25, y: 80 },
37
+ ]);
38
+ });
39
+
40
+ it('filters rows outside both x- and y-axis clipped domains', () => {
41
+ const encoding: Encoding = {
42
+ x: {
43
+ field: 'x',
44
+ type: 'quantitative',
45
+ scale: { domain: [0, 60], clip: true },
46
+ },
47
+ y: {
48
+ field: 'y',
49
+ type: 'quantitative',
50
+ scale: { domain: [0, 100], clip: true },
51
+ },
52
+ };
53
+ const result = filterClippedDomains(data, encoding);
54
+ expect(result).toEqual([
55
+ { x: 10, y: 20 },
56
+ { x: 25, y: 80 },
57
+ ]);
58
+ });
59
+ });
@@ -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
+ }