@opendata-ai/openchart-engine 6.20.0 → 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 +756 -3592
- 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/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/bar/labels.ts +2 -6
- package/src/charts/builtin.ts +64 -0
- package/src/charts/column/labels.ts +2 -6
- package/src/charts/dot/labels.ts +2 -6
- 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
|
@@ -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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import
|
|
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 {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
407
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
448
|
-
|
|
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
|
+
}
|