@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
package/src/layout/axes.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Axis computation: tick positions, labels, and axis lines.
|
|
3
3
|
*
|
|
4
4
|
* Generates ticks manually (no d3-axis) so we have full control over
|
|
5
|
-
* responsive tick density and formatting.
|
|
5
|
+
* responsive tick density and formatting. Tick generation and label
|
|
6
|
+
* thinning live in sibling modules under ./axes/.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type {
|
|
@@ -16,33 +17,26 @@ import type {
|
|
|
16
17
|
ResolvedTheme,
|
|
17
18
|
TextStyle,
|
|
18
19
|
} from '@opendata-ai/openchart-core';
|
|
19
|
-
import {
|
|
20
|
-
abbreviateNumber,
|
|
21
|
-
buildD3Formatter,
|
|
22
|
-
buildTemporalFormatter,
|
|
23
|
-
estimateTextWidth,
|
|
24
|
-
formatDate,
|
|
25
|
-
formatNumber,
|
|
26
|
-
} from '@opendata-ai/openchart-core';
|
|
27
20
|
import type { ScaleBand } from 'd3-scale';
|
|
28
|
-
import
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
22
|
+
import {
|
|
23
|
+
buildContinuousTicks,
|
|
24
|
+
categoricalTicks,
|
|
25
|
+
continuousTicks,
|
|
26
|
+
resolveExplicitTicks,
|
|
27
|
+
scaleSupportsTickCount,
|
|
28
|
+
targetTickCount,
|
|
29
|
+
} from './axes/ticks';
|
|
30
|
+
import type { ResolvedScales } from './scales';
|
|
31
|
+
|
|
32
|
+
// Re-export pure helpers so external consumers (and tests) continue to import
|
|
33
|
+
// them from './layout/axes'.
|
|
34
|
+
export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
// Constants
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
|
|
39
|
-
/** Base tick counts by axis label density. */
|
|
40
|
-
const TICK_COUNTS: Record<AxisLabelDensity, number> = {
|
|
41
|
-
full: 12,
|
|
42
|
-
reduced: 8,
|
|
43
|
-
minimal: 4,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
40
|
/**
|
|
47
41
|
* Height thresholds for reducing y-axis tick density.
|
|
48
42
|
* Below these pixel heights, we step down the density regardless of the
|
|
@@ -59,15 +53,6 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
|
|
|
59
53
|
const WIDTH_MINIMAL_THRESHOLD = 150;
|
|
60
54
|
const WIDTH_REDUCED_THRESHOLD = 300;
|
|
61
55
|
|
|
62
|
-
/**
|
|
63
|
-
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
64
|
-
* At the default 12px axis font, this yields ~12px of breathing room.
|
|
65
|
-
*/
|
|
66
|
-
const MIN_TICK_GAP_FACTOR = 1.0;
|
|
67
|
-
|
|
68
|
-
/** Always show at least this many ticks, even if they overlap. */
|
|
69
|
-
const MIN_TICK_COUNT = 2;
|
|
70
|
-
|
|
71
56
|
/** Ordered densities from most to fewest ticks. */
|
|
72
57
|
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
|
|
73
58
|
|
|
@@ -106,217 +91,94 @@ export function effectiveDensity(
|
|
|
106
91
|
return density;
|
|
107
92
|
}
|
|
108
93
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
text: string,
|
|
116
|
-
fontSize: number,
|
|
117
|
-
fontWeight: number,
|
|
118
|
-
measureText?: MeasureTextFn,
|
|
119
|
-
): number {
|
|
120
|
-
return measureText
|
|
121
|
-
? measureText(text, fontSize, fontWeight).width
|
|
122
|
-
: estimateTextWidth(text, fontSize, fontWeight);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Check whether any adjacent tick labels overlap along the axis direction. */
|
|
126
|
-
export function ticksOverlap(
|
|
127
|
-
ticks: AxisTick[],
|
|
128
|
-
fontSize: number,
|
|
129
|
-
fontWeight: number,
|
|
130
|
-
measureText?: MeasureTextFn,
|
|
131
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
132
|
-
): boolean {
|
|
133
|
-
if (ticks.length < 2) return false;
|
|
134
|
-
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
135
|
-
|
|
136
|
-
if (orientation === 'vertical') {
|
|
137
|
-
// Y-axis: labels are stacked vertically. Check if vertical extent
|
|
138
|
-
// (based on font height) overlaps between adjacent ticks.
|
|
139
|
-
// Positions decrease going up in SVG coords, so sort ascending.
|
|
140
|
-
const sorted = [...ticks].sort((a, b) => a.position - b.position);
|
|
141
|
-
const labelHeight = fontSize * 1.2; // lineHeight
|
|
142
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
143
|
-
const aBottom = sorted[i].position + labelHeight / 2;
|
|
144
|
-
const bTop = sorted[i + 1].position - labelHeight / 2;
|
|
145
|
-
if (aBottom + minGap > bTop) return true;
|
|
146
|
-
}
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
94
|
+
/**
|
|
95
|
+
* Floor tick count for continuous axes when the axis is long enough to show
|
|
96
|
+
* more than a min/max pair. Keeps the editorial ~5 target when possible.
|
|
97
|
+
* Very short axes bypass this floor and can legitimately fall to 2.
|
|
98
|
+
*/
|
|
99
|
+
const CONTINUOUS_TICK_FLOOR = 4;
|
|
149
100
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
101
|
+
/**
|
|
102
|
+
* How much D3 is allowed to overshoot what we asked for before we treat the
|
|
103
|
+
* output as "too dense" and step down. D3's `scale.ticks(n)` only produces
|
|
104
|
+
* nice step sizes (1, 2, 5 × 10^k, or calendar units for time), so a request
|
|
105
|
+
* for n=6 can come back with 12 quarterly dates or 10 step-5 values. Accepting
|
|
106
|
+
* up to 1.5× target catches the obvious overshoots without trimming acceptable
|
|
107
|
+
* ones.
|
|
108
|
+
*
|
|
109
|
+
* The reference point stays fixed to the initial requested count even as we
|
|
110
|
+
* iterate downward — we're measuring "did this candidate land near the target
|
|
111
|
+
* the caller actually wanted?", not "is the candidate near what we just asked
|
|
112
|
+
* for on this iteration?". If a candidate at n=3 returns 8 ticks, it's still
|
|
113
|
+
* a 1.3× overshoot of the target-6, which is fine.
|
|
114
|
+
*/
|
|
115
|
+
const OVERSHOOT_TOLERANCE = 1.5;
|
|
159
116
|
|
|
160
117
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
118
|
+
* Fit continuous ticks by re-requesting progressively fewer ticks from the
|
|
119
|
+
* scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
120
|
+
* stepping `n` down keeps spacing uniform — unlike middle-pruning which can
|
|
121
|
+
* strand the last tick next to an endpoint and cascade to 2 ticks.
|
|
122
|
+
*
|
|
123
|
+
* Two conditions trigger a step-down:
|
|
124
|
+
* 1. The label heuristic detects overlap at the initial count.
|
|
125
|
+
* 2. D3 overshot the requested count by more than OVERSHOOT_TOLERANCE.
|
|
126
|
+
* (Time scales jump between calendar units; linear scales jump between
|
|
127
|
+
* nice step sizes. Either can return 2× what we asked for.)
|
|
128
|
+
*
|
|
129
|
+
* Falls back to overlap-safe thinning on the best-so-far candidate if no
|
|
130
|
+
* count produces a clean fit. The fallback starts from the smallest candidate
|
|
131
|
+
* that still meets the floor, so `thinTicksUntilFit` never receives the
|
|
132
|
+
* overshot initial set (which was the bug this function exists to avoid).
|
|
164
133
|
*/
|
|
165
|
-
|
|
166
|
-
|
|
134
|
+
function fitContinuousTicks(
|
|
135
|
+
scale: ResolvedScales['x' | 'y'],
|
|
136
|
+
initialTicks: AxisTick[],
|
|
137
|
+
initialCount: number,
|
|
167
138
|
fontSize: number,
|
|
168
139
|
fontWeight: number,
|
|
140
|
+
axisLength: number,
|
|
141
|
+
orientation: 'horizontal' | 'vertical',
|
|
169
142
|
measureText?: MeasureTextFn,
|
|
170
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
171
143
|
): AxisTick[] {
|
|
172
|
-
if (!
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return domain.map((value: unknown) => ({
|
|
202
|
-
value,
|
|
203
|
-
position: (scale as D3ContinuousScale)(value as number & Date) as number,
|
|
204
|
-
label: formatTickLabel(value, resolvedScale),
|
|
205
|
-
}));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
209
|
-
const count = explicitCount ?? TICK_COUNTS[density];
|
|
210
|
-
const rawTicks: unknown[] = scale.ticks(count);
|
|
211
|
-
|
|
212
|
-
const ticks = rawTicks.map((value: unknown) => ({
|
|
213
|
-
value,
|
|
214
|
-
position: scale(value as number & Date) as number,
|
|
215
|
-
label: formatTickLabel(value, resolvedScale),
|
|
216
|
-
}));
|
|
217
|
-
|
|
218
|
-
return ticks;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Generate ticks for a band/point/ordinal scale. */
|
|
222
|
-
function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
|
|
223
|
-
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
224
|
-
const domain: string[] = scale.domain();
|
|
225
|
-
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
226
|
-
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
227
|
-
|
|
228
|
-
// Band scales (bar charts) show all category labels by default.
|
|
229
|
-
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
|
|
230
|
-
let selectedValues = domain;
|
|
231
|
-
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
|
|
232
|
-
const step = Math.ceil(domain.length / maxTicks);
|
|
233
|
-
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const ticks = selectedValues.map((value: string) => {
|
|
237
|
-
// Band scales: use the center of the band
|
|
238
|
-
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
239
|
-
const pos = bandScale
|
|
240
|
-
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
241
|
-
: ((scale(value) as number | undefined) ?? 0);
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
value,
|
|
245
|
-
position: pos,
|
|
246
|
-
label: value,
|
|
247
|
-
};
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
return ticks;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Set of continuous numeric scale types that should format as numbers. */
|
|
254
|
-
const NUMERIC_SCALE_TYPES = new Set([
|
|
255
|
-
'linear',
|
|
256
|
-
'log',
|
|
257
|
-
'pow',
|
|
258
|
-
'sqrt',
|
|
259
|
-
'symlog',
|
|
260
|
-
'quantile',
|
|
261
|
-
'quantize',
|
|
262
|
-
'threshold',
|
|
263
|
-
]);
|
|
264
|
-
|
|
265
|
-
/** Set of temporal scale types. */
|
|
266
|
-
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
267
|
-
|
|
268
|
-
/** Format a tick value based on the scale type. */
|
|
269
|
-
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
270
|
-
const formatStr = resolvedScale.channel.axis?.format;
|
|
271
|
-
|
|
272
|
-
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
273
|
-
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
274
|
-
if (temporalFmt) return temporalFmt(value as Date);
|
|
275
|
-
const useUtc = resolvedScale.type === 'utc';
|
|
276
|
-
return formatDate(value as Date, undefined, undefined, useUtc);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
280
|
-
const num = value as number;
|
|
281
|
-
if (formatStr) {
|
|
282
|
-
const fmt = buildD3Formatter(formatStr);
|
|
283
|
-
if (fmt) return fmt(num);
|
|
144
|
+
if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
|
|
145
|
+
|
|
146
|
+
const tolerance = initialCount * OVERSHOOT_TOLERANCE;
|
|
147
|
+
const overshoots = initialTicks.length > tolerance;
|
|
148
|
+
const overlaps = ticksOverlap(initialTicks, fontSize, fontWeight, measureText, orientation);
|
|
149
|
+
if (!overshoots && !overlaps) return initialTicks;
|
|
150
|
+
|
|
151
|
+
// Enforce the floor only when the axis is long enough to fit that many
|
|
152
|
+
// labels without overlap. Very short axes can fall below.
|
|
153
|
+
const minThreshold =
|
|
154
|
+
orientation === 'vertical' ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
|
|
155
|
+
const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
|
|
156
|
+
|
|
157
|
+
// Track the smallest candidate that meets the floor. If no candidate fits
|
|
158
|
+
// cleanly, we thin this instead of the overshot `initialTicks` so the
|
|
159
|
+
// fallback doesn't reintroduce the cascading-to-2-ticks bug.
|
|
160
|
+
let bestWithinFloor: AxisTick[] | undefined;
|
|
161
|
+
for (let n = initialCount - 1; n >= 2; n--) {
|
|
162
|
+
const candidate = buildContinuousTicks(scale, n);
|
|
163
|
+
const candidateOvershoots = candidate.length > tolerance;
|
|
164
|
+
const candidateOverlaps = ticksOverlap(
|
|
165
|
+
candidate,
|
|
166
|
+
fontSize,
|
|
167
|
+
fontWeight,
|
|
168
|
+
measureText,
|
|
169
|
+
orientation,
|
|
170
|
+
);
|
|
171
|
+
if (!candidateOvershoots && !candidateOverlaps) {
|
|
172
|
+
return candidate;
|
|
284
173
|
}
|
|
285
|
-
|
|
286
|
-
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
|
|
287
|
-
return formatNumber(num);
|
|
174
|
+
if (candidate.length >= floor) bestWithinFloor = candidate;
|
|
288
175
|
}
|
|
289
176
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const scale = resolvedScale.scale;
|
|
296
|
-
return values.map((value) => {
|
|
297
|
-
let position: number;
|
|
298
|
-
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
299
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
300
|
-
position = (scale as D3ContinuousScale)(d as number & Date) as number;
|
|
301
|
-
} else if (
|
|
302
|
-
resolvedScale.type === 'band' ||
|
|
303
|
-
resolvedScale.type === 'point' ||
|
|
304
|
-
resolvedScale.type === 'ordinal'
|
|
305
|
-
) {
|
|
306
|
-
const s = String(value);
|
|
307
|
-
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
308
|
-
position = bandScale
|
|
309
|
-
? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
|
|
310
|
-
: ((scale(s as string & number) as number | undefined) ?? 0);
|
|
311
|
-
} else {
|
|
312
|
-
position = (scale as D3ContinuousScale)(value as number & Date) as number;
|
|
313
|
-
}
|
|
314
|
-
return {
|
|
315
|
-
value,
|
|
316
|
-
position,
|
|
317
|
-
label: formatTickLabel(value, resolvedScale),
|
|
318
|
-
};
|
|
319
|
-
});
|
|
177
|
+
// No candidate fit cleanly. Thin whatever most recently met the floor; if
|
|
178
|
+
// nothing did, synthesize a floor-count set directly from the scale so we
|
|
179
|
+
// never hand the overshot initialTicks to the middle-pruning thinner.
|
|
180
|
+
const fallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
|
|
181
|
+
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
320
182
|
}
|
|
321
183
|
|
|
322
184
|
// ---------------------------------------------------------------------------
|
|
@@ -385,19 +247,21 @@ export function computeAxes(
|
|
|
385
247
|
|
|
386
248
|
if (scales.x) {
|
|
387
249
|
const axisConfig = scales.x.channel.axis;
|
|
250
|
+
const isContinuousX =
|
|
251
|
+
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
252
|
+
|
|
253
|
+
const xTargetCount = isContinuousX
|
|
254
|
+
? targetTickCount(chartArea.width, xDensity, 'x')
|
|
255
|
+
: undefined;
|
|
388
256
|
|
|
389
257
|
// Use explicit tick values from axis config if provided
|
|
390
258
|
let allTicks: AxisTick[];
|
|
391
259
|
if (axisConfig?.values) {
|
|
392
260
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
|
|
393
|
-
} else if (
|
|
394
|
-
scales.x.type === 'band' ||
|
|
395
|
-
scales.x.type === 'point' ||
|
|
396
|
-
scales.x.type === 'ordinal'
|
|
397
|
-
) {
|
|
261
|
+
} else if (!isContinuousX) {
|
|
398
262
|
allTicks = categoricalTicks(scales.x, xDensity);
|
|
399
263
|
} else {
|
|
400
|
-
allTicks = continuousTicks(scales.x, xDensity);
|
|
264
|
+
allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
|
|
401
265
|
}
|
|
402
266
|
|
|
403
267
|
// Gridlines use the full tick set so they remain visible even when labels
|
|
@@ -410,9 +274,25 @@ export function computeAxes(
|
|
|
410
274
|
// Thin tick labels to prevent overlap (skip for band scales which use
|
|
411
275
|
// auto-rotation, and when the user set an explicit tickCount or values).
|
|
412
276
|
const shouldThin = scales.x.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
277
|
+
let ticks: AxisTick[];
|
|
278
|
+
if (!shouldThin) {
|
|
279
|
+
ticks = allTicks;
|
|
280
|
+
} else if (isContinuousX) {
|
|
281
|
+
// Continuous x-axis: re-request ticks at a lower count on overlap so
|
|
282
|
+
// time-scale quartile/monthly jumps don't leave a too-dense axis.
|
|
283
|
+
ticks = fitContinuousTicks(
|
|
284
|
+
scales.x,
|
|
285
|
+
allTicks,
|
|
286
|
+
xTargetCount ?? allTicks.length,
|
|
287
|
+
fontSize,
|
|
288
|
+
fontWeight,
|
|
289
|
+
chartArea.width,
|
|
290
|
+
'horizontal',
|
|
291
|
+
measureText,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
|
|
295
|
+
}
|
|
416
296
|
|
|
417
297
|
// Auto-rotate labels when band scale labels would overlap.
|
|
418
298
|
// Uses max label width (not average) since one long label is enough to overlap.
|
|
@@ -454,26 +334,45 @@ export function computeAxes(
|
|
|
454
334
|
|
|
455
335
|
if (scales.y) {
|
|
456
336
|
const axisConfig = scales.y.channel.axis;
|
|
337
|
+
const isContinuousY =
|
|
338
|
+
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
339
|
+
|
|
340
|
+
// Target count from pixels-per-tick when we have a continuous y-axis.
|
|
341
|
+
const yTargetCount = isContinuousY
|
|
342
|
+
? targetTickCount(chartArea.height, yDensity, 'y')
|
|
343
|
+
: undefined;
|
|
457
344
|
|
|
458
345
|
// Use explicit tick values from axis config if provided
|
|
459
346
|
let allTicks: AxisTick[];
|
|
460
347
|
if (axisConfig?.values) {
|
|
461
348
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
462
|
-
} else if (
|
|
463
|
-
scales.y.type === 'band' ||
|
|
464
|
-
scales.y.type === 'point' ||
|
|
465
|
-
scales.y.type === 'ordinal'
|
|
466
|
-
) {
|
|
349
|
+
} else if (!isContinuousY) {
|
|
467
350
|
allTicks = categoricalTicks(scales.y, yDensity);
|
|
468
351
|
} else {
|
|
469
|
-
allTicks = continuousTicks(scales.y, yDensity);
|
|
352
|
+
allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
|
|
470
353
|
}
|
|
471
354
|
|
|
472
355
|
// Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
|
|
473
356
|
const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
357
|
+
let ticks: AxisTick[];
|
|
358
|
+
if (!shouldThin) {
|
|
359
|
+
ticks = allTicks;
|
|
360
|
+
} else if (isContinuousY) {
|
|
361
|
+
// Continuous y-axis: re-request ticks at a lower count on overlap so
|
|
362
|
+
// spacing stays uniform and we don't collapse to min/max.
|
|
363
|
+
ticks = fitContinuousTicks(
|
|
364
|
+
scales.y,
|
|
365
|
+
allTicks,
|
|
366
|
+
yTargetCount ?? allTicks.length,
|
|
367
|
+
fontSize,
|
|
368
|
+
fontWeight,
|
|
369
|
+
chartArea.height,
|
|
370
|
+
'vertical',
|
|
371
|
+
measureText,
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical');
|
|
375
|
+
}
|
|
477
376
|
|
|
478
377
|
// Gridlines match the tick set so every gridline has a label.
|
|
479
378
|
const gridlines: Gridline[] = ticks.map((t) => ({
|