@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.
- package/dist/index.d.ts +6 -0
- package/dist/index.js +865 -3729
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
- package/src/__tests__/axes.test.ts +65 -0
- package/src/__tests__/compile-snapshot.test.ts +156 -0
- package/src/__tests__/legend.test.ts +39 -0
- package/src/charts/__tests__/registry.test.ts +6 -0
- package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
- package/src/charts/_shared/density-filter.ts +26 -0
- package/src/charts/_shared/format-label-value.ts +15 -0
- package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
- package/src/charts/bar/compute.ts +6 -11
- package/src/charts/bar/labels.ts +4 -15
- package/src/charts/builtin.ts +64 -0
- package/src/charts/column/compute.ts +6 -11
- package/src/charts/column/labels.ts +4 -19
- package/src/charts/dot/labels.ts +4 -19
- package/src/charts/pie/labels.ts +4 -6
- package/src/charts/registry.ts +6 -0
- package/src/compile/__tests__/color-scale-range.test.ts +79 -0
- package/src/compile/__tests__/data-clip.test.ts +59 -0
- package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
- package/src/compile/color-scale-range.ts +38 -0
- package/src/compile/data-clip.ts +33 -0
- package/src/compile/watermark-obstacle.ts +54 -0
- package/src/compile.ts +20 -97
- package/src/layout/axes/thinning.ts +96 -0
- package/src/layout/axes/ticks.ts +266 -0
- package/src/layout/axes.ts +148 -249
- package/src/legend/compute.ts +6 -51
- package/src/legend/wrap.ts +94 -0
- package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
- package/src/sankey/__tests__/node-sort.test.ts +45 -0
- 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 {
|
|
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}: ${
|
|
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}: ${
|
|
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}: ${
|
|
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}: ${
|
|
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
|
-
|
|
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 =
|
|
70
|
+
valuePart = formatLabelValue(rawNum);
|
|
86
71
|
} else {
|
|
87
72
|
// Fallback: extract from aria label
|
|
88
73
|
const ariaLabel = mark.aria.label;
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
69
|
+
valuePart = formatLabelValue(rawNum);
|
|
85
70
|
} else {
|
|
86
71
|
// Fallback: extract from aria label
|
|
87
72
|
const ariaLabel = mark.aria.label;
|
package/src/charts/pie/labels.ts
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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[] = [];
|
package/src/charts/registry.ts
CHANGED
|
@@ -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
|
+
}
|