@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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick generation: produces raw AxisTick[] from a resolved scale.
|
|
3
|
+
*
|
|
4
|
+
* Pure with respect to layout dimensions — positions come from the scale,
|
|
5
|
+
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AxisLabelDensity, AxisTick } from '@opendata-ai/openchart-core';
|
|
9
|
+
import {
|
|
10
|
+
abbreviateNumber,
|
|
11
|
+
buildD3Formatter,
|
|
12
|
+
buildTemporalFormatter,
|
|
13
|
+
formatDate,
|
|
14
|
+
formatNumber,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
16
|
+
import type { ScaleBand } from 'd3-scale';
|
|
17
|
+
import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../scales';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Target pixels-per-tick for continuous axes. The target count is computed as
|
|
21
|
+
* `axisLength / PX_PER_TICK[density]` and then clamped into the count range.
|
|
22
|
+
*
|
|
23
|
+
* Rationale:
|
|
24
|
+
* - Observable Plot uses 50px/tick on y, 80px/tick on x as its baseline.
|
|
25
|
+
* - ONS editorial guidance recommends 6-10 y-gridlines at desktop, 3-6 mobile.
|
|
26
|
+
* - The Economist / FT / NYT typically show 4-6 labeled y-ticks on finished charts.
|
|
27
|
+
*
|
|
28
|
+
* Y gets tighter spacing than X because vertical label extent is the font height
|
|
29
|
+
* (~14px) versus horizontal label extent which can be 60-100px for dates/abbreviated
|
|
30
|
+
* numbers. X uses wider spacing so labels don't need aggressive rotation or thinning.
|
|
31
|
+
*
|
|
32
|
+
* "full" is the publication-ready default; "reduced" and "minimal" step down as the
|
|
33
|
+
* responsive breakpoint system shifts to smaller containers.
|
|
34
|
+
*
|
|
35
|
+
* @internal — these are tuning constants, not part of the configuration API.
|
|
36
|
+
* Consumers should configure tick density through `axis.tickCount` on the spec.
|
|
37
|
+
*/
|
|
38
|
+
const Y_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
39
|
+
full: 55,
|
|
40
|
+
reduced: 90,
|
|
41
|
+
minimal: 140,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const X_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
45
|
+
full: 110,
|
|
46
|
+
reduced: 160,
|
|
47
|
+
minimal: 220,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Count clamps per density. The lower bound keeps a chart from collapsing to
|
|
52
|
+
* a single label on very short axes; the upper bound stops tall/wide charts
|
|
53
|
+
* from growing a ladder of ticks past the point of editorial usefulness.
|
|
54
|
+
*
|
|
55
|
+
* The upper bound is deliberately <=6 for y on standard tiers: D3's
|
|
56
|
+
* `scale.ticks(n)` only produces "nice" step sizes (1, 2, 5 × 10^k), and for
|
|
57
|
+
* many domains the jump from step=10 to step=5 happens between count 6 and 7.
|
|
58
|
+
* Requesting 7 can give back 10, which reads as visually dense. Capping at 6
|
|
59
|
+
* keeps the editorial ~5 gridline average regardless of domain shape.
|
|
60
|
+
*
|
|
61
|
+
* @internal — see PX_PER_TICK comment.
|
|
62
|
+
*/
|
|
63
|
+
const Y_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
|
|
64
|
+
full: [4, 6],
|
|
65
|
+
reduced: [3, 5],
|
|
66
|
+
minimal: [2, 3],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const X_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
|
|
70
|
+
full: [3, 6],
|
|
71
|
+
reduced: [3, 5],
|
|
72
|
+
minimal: [2, 3],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fallback tick counts for callers that don't have an axis length handy
|
|
77
|
+
* (categorical band-scale thinning uses this as a cap, and `continuousTicks`
|
|
78
|
+
* uses it when no `targetCount` is provided).
|
|
79
|
+
*
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
const TICK_COUNTS: Record<AxisLabelDensity, number> = {
|
|
83
|
+
full: 7,
|
|
84
|
+
reduced: 5,
|
|
85
|
+
minimal: 3,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute a target tick count for a continuous axis from its pixel length and
|
|
90
|
+
* density tier. Uses the Plot-style pixels-per-tick heuristic, then clamps
|
|
91
|
+
* into the density's count range.
|
|
92
|
+
*/
|
|
93
|
+
export function targetTickCount(
|
|
94
|
+
axisLength: number,
|
|
95
|
+
density: AxisLabelDensity,
|
|
96
|
+
orientation: 'x' | 'y',
|
|
97
|
+
): number {
|
|
98
|
+
const pxPerTick = orientation === 'y' ? Y_PX_PER_TICK[density] : X_PX_PER_TICK[density];
|
|
99
|
+
const [min, max] =
|
|
100
|
+
orientation === 'y' ? Y_TICK_COUNT_RANGE[density] : X_TICK_COUNT_RANGE[density];
|
|
101
|
+
const raw = Math.round(axisLength / pxPerTick);
|
|
102
|
+
return Math.max(min, Math.min(max, raw));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Set of continuous numeric scale types that should format as numbers. */
|
|
106
|
+
const NUMERIC_SCALE_TYPES = new Set([
|
|
107
|
+
'linear',
|
|
108
|
+
'log',
|
|
109
|
+
'pow',
|
|
110
|
+
'sqrt',
|
|
111
|
+
'symlog',
|
|
112
|
+
'quantile',
|
|
113
|
+
'quantize',
|
|
114
|
+
'threshold',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/** Set of temporal scale types. */
|
|
118
|
+
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
119
|
+
|
|
120
|
+
/** Format a tick value based on the scale type. */
|
|
121
|
+
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
122
|
+
const formatStr = resolvedScale.channel.axis?.format;
|
|
123
|
+
|
|
124
|
+
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
125
|
+
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
126
|
+
if (temporalFmt) return temporalFmt(value as Date);
|
|
127
|
+
const useUtc = resolvedScale.type === 'utc';
|
|
128
|
+
return formatDate(value as Date, undefined, undefined, useUtc);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
132
|
+
const num = value as number;
|
|
133
|
+
if (formatStr) {
|
|
134
|
+
const fmt = buildD3Formatter(formatStr);
|
|
135
|
+
if (fmt) return fmt(num);
|
|
136
|
+
}
|
|
137
|
+
// Abbreviate large numbers for axis labels
|
|
138
|
+
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
|
|
139
|
+
return formatNumber(num);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return String(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog).
|
|
147
|
+
*
|
|
148
|
+
* `targetCount` lets callers that know the axis pixel length pass a
|
|
149
|
+
* density-appropriate count (see `targetTickCount`). When omitted, falls back
|
|
150
|
+
* to the coarse `TICK_COUNTS` tier, which is only used by tests and callers
|
|
151
|
+
* that don't have an axis length.
|
|
152
|
+
*/
|
|
153
|
+
export function continuousTicks(
|
|
154
|
+
resolvedScale: ResolvedScale,
|
|
155
|
+
density: AxisLabelDensity,
|
|
156
|
+
targetCount?: number,
|
|
157
|
+
): AxisTick[] {
|
|
158
|
+
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
159
|
+
|
|
160
|
+
// Discretizing scales (quantile, quantize, threshold) don't have .ticks().
|
|
161
|
+
// Use their domain thresholds as ticks instead.
|
|
162
|
+
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
163
|
+
const domain = scale.domain() as unknown[];
|
|
164
|
+
return domain.map((value: unknown) => ({
|
|
165
|
+
value,
|
|
166
|
+
position: (scale as D3ContinuousScale)(value as number & Date) as number,
|
|
167
|
+
label: formatTickLabel(value, resolvedScale),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
172
|
+
const count = explicitCount ?? targetCount ?? TICK_COUNTS[density];
|
|
173
|
+
return buildContinuousTicks(resolvedScale, count);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build positioned, labeled ticks for a continuous scale at an exact count.
|
|
178
|
+
* Exposed so callers that need to re-request ticks at a lower count (for
|
|
179
|
+
* overlap-driven density adaptation) can regenerate without manual pruning.
|
|
180
|
+
* D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
181
|
+
* requesting a smaller `n` never produces squished neighbors — unlike
|
|
182
|
+
* "keep first+last, drop middle" pruning which can stack the last tick
|
|
183
|
+
* next to an endpoint and cascade to 2 ticks.
|
|
184
|
+
*/
|
|
185
|
+
export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number): AxisTick[] {
|
|
186
|
+
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
187
|
+
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
188
|
+
return continuousTicks(resolvedScale, 'full');
|
|
189
|
+
}
|
|
190
|
+
const raw: unknown[] = scale.ticks(count);
|
|
191
|
+
return raw.map((value: unknown) => ({
|
|
192
|
+
value,
|
|
193
|
+
position: scale(value as number & Date) as number,
|
|
194
|
+
label: formatTickLabel(value, resolvedScale),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** True if this scale supports regenerating ticks at an arbitrary count. */
|
|
199
|
+
export function scaleSupportsTickCount(resolvedScale: ResolvedScale): boolean {
|
|
200
|
+
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
201
|
+
return 'ticks' in scale && typeof scale.ticks === 'function';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Generate ticks for a band/point/ordinal scale. */
|
|
205
|
+
export function categoricalTicks(
|
|
206
|
+
resolvedScale: ResolvedScale,
|
|
207
|
+
density: AxisLabelDensity,
|
|
208
|
+
): AxisTick[] {
|
|
209
|
+
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
210
|
+
const domain: string[] = scale.domain();
|
|
211
|
+
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
212
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
213
|
+
|
|
214
|
+
// Band scales (bar charts) show all category labels by default.
|
|
215
|
+
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
|
|
216
|
+
let selectedValues = domain;
|
|
217
|
+
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
|
|
218
|
+
const step = Math.ceil(domain.length / maxTicks);
|
|
219
|
+
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ticks = selectedValues.map((value: string) => {
|
|
223
|
+
// Band scales: use the center of the band
|
|
224
|
+
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
225
|
+
const pos = bandScale
|
|
226
|
+
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
227
|
+
: ((scale(value) as number | undefined) ?? 0);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
value,
|
|
231
|
+
position: pos,
|
|
232
|
+
label: value,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return ticks;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve explicit tick values from axis config into positioned ticks. */
|
|
240
|
+
export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
|
|
241
|
+
const scale = resolvedScale.scale;
|
|
242
|
+
return values.map((value) => {
|
|
243
|
+
let position: number;
|
|
244
|
+
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
245
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
246
|
+
position = (scale as D3ContinuousScale)(d as number & Date) as number;
|
|
247
|
+
} else if (
|
|
248
|
+
resolvedScale.type === 'band' ||
|
|
249
|
+
resolvedScale.type === 'point' ||
|
|
250
|
+
resolvedScale.type === 'ordinal'
|
|
251
|
+
) {
|
|
252
|
+
const s = String(value);
|
|
253
|
+
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
254
|
+
position = bandScale
|
|
255
|
+
? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
|
|
256
|
+
: ((scale(s as string & number) as number | undefined) ?? 0);
|
|
257
|
+
} else {
|
|
258
|
+
position = (scale as D3ContinuousScale)(value as number & Date) as number;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
value,
|
|
262
|
+
position,
|
|
263
|
+
label: formatTickLabel(value, resolvedScale),
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|