@opendata-ai/openchart-engine 7.9.2 → 7.10.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.js +109 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +271 -35
- package/src/__tests__/rotation.test.ts +83 -0
- package/src/layout/axes/rotation.ts +130 -14
- package/src/layout/axes/ticks.ts +22 -7
- package/src/layout/axes.ts +77 -113
- package/src/layout/plan.ts +18 -3
- package/src/layout/scales.ts +29 -5
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -123,16 +123,17 @@ const NUMERIC_SCALE_TYPES = new Set([
|
|
|
123
123
|
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
124
124
|
|
|
125
125
|
/** Format a tick value based on the scale type. */
|
|
126
|
-
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
126
|
+
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact = false): string {
|
|
127
127
|
const axisConfig = resolvedScale.channel.axis || undefined;
|
|
128
128
|
const formatStr = axisConfig?.format;
|
|
129
129
|
const suffix = axisConfig?.labelSuffix ?? '';
|
|
130
130
|
|
|
131
131
|
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
132
|
+
// Explicit user axis.format always wins, compact or not.
|
|
132
133
|
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
133
134
|
if (temporalFmt) return temporalFmt(value as Date) + suffix;
|
|
134
135
|
const useUtc = resolvedScale.type === 'utc';
|
|
135
|
-
return formatDate(value as Date, undefined, undefined, useUtc) + suffix;
|
|
136
|
+
return formatDate(value as Date, undefined, undefined, useUtc, compact) + suffix;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
@@ -188,8 +189,15 @@ export function continuousTicks(
|
|
|
188
189
|
* requesting a smaller `n` never produces squished neighbors — unlike
|
|
189
190
|
* "keep first+last, drop middle" pruning which can stack the last tick
|
|
190
191
|
* next to an endpoint and cascade to 2 ticks.
|
|
192
|
+
*
|
|
193
|
+
* `compact` switches temporal labels to the compact format table (bare '%b'
|
|
194
|
+
* months etc.); numeric and string labels ignore it.
|
|
191
195
|
*/
|
|
192
|
-
export function buildContinuousTicks(
|
|
196
|
+
export function buildContinuousTicks(
|
|
197
|
+
resolvedScale: ResolvedScale,
|
|
198
|
+
count: number,
|
|
199
|
+
compact = false,
|
|
200
|
+
): AxisTick[] {
|
|
193
201
|
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
194
202
|
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
195
203
|
return continuousTicks(resolvedScale, 'full');
|
|
@@ -220,7 +228,7 @@ export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number
|
|
|
220
228
|
return ticks.map((value: unknown) => ({
|
|
221
229
|
value,
|
|
222
230
|
position: scale(value as number & Date) as number,
|
|
223
|
-
label: formatTickLabel(value, resolvedScale),
|
|
231
|
+
label: formatTickLabel(value, resolvedScale, compact),
|
|
224
232
|
}));
|
|
225
233
|
}
|
|
226
234
|
|
|
@@ -256,8 +264,8 @@ export function categoricalTicks(
|
|
|
256
264
|
if (resolvedScale.type === 'band' && orientation === 'horizontal') {
|
|
257
265
|
// Horizontal band scales delegate thinning to the caller (computeAxes)
|
|
258
266
|
// which knows the effective label angle after auto-rotation. Only apply
|
|
259
|
-
// an explicit tickCount cap here;
|
|
260
|
-
//
|
|
267
|
+
// an explicit tickCount cap here; the angle ladder + stride safety net
|
|
268
|
+
// (see ./rotation) handle collisions downstream.
|
|
261
269
|
if (explicitTickCount && domain.length > explicitTickCount) {
|
|
262
270
|
const step = Math.ceil(domain.length / explicitTickCount);
|
|
263
271
|
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
@@ -315,7 +323,14 @@ export function categoricalTicks(
|
|
|
315
323
|
return ticks;
|
|
316
324
|
}
|
|
317
325
|
|
|
318
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Resolve explicit tick values from axis config into positioned ticks.
|
|
328
|
+
*
|
|
329
|
+
* Deliberately never compact: this renders the layout planner's precomputed
|
|
330
|
+
* ticks, and planner/renderer gutter agreement holds only because both sides
|
|
331
|
+
* format identically. Making this compact without making the planner compact
|
|
332
|
+
* recreates the reserved-space divergence bug class behind the 7.9.x saga.
|
|
333
|
+
*/
|
|
319
334
|
export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
|
|
320
335
|
const scale = resolvedScale.scale;
|
|
321
336
|
return values.map((value) => {
|
package/src/layout/axes.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
X_AXIS_TITLE_BAND_ROTATED,
|
|
28
28
|
} from '@opendata-ai/openchart-core';
|
|
29
29
|
import type { ScaleBand } from 'd3-scale';
|
|
30
|
-
import { resolveBandTickAngle } from './axes/rotation';
|
|
30
|
+
import { bandLabelStride, resolveBandTickAngle } from './axes/rotation';
|
|
31
31
|
import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
32
32
|
import {
|
|
33
33
|
buildContinuousTicks,
|
|
@@ -208,13 +208,14 @@ function ensureMinContinuousTicks(
|
|
|
208
208
|
scale: ResolvedScales['x' | 'y'],
|
|
209
209
|
ticks: AxisTick[],
|
|
210
210
|
requestedCount: number,
|
|
211
|
+
compact = false,
|
|
211
212
|
): AxisTick[] {
|
|
212
213
|
if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
|
|
213
214
|
if (!scale || !scaleSupportsTickCount(scale)) return ticks;
|
|
214
215
|
|
|
215
216
|
let best = ticks;
|
|
216
217
|
for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
|
|
217
|
-
const candidate = buildContinuousTicks(scale, n);
|
|
218
|
+
const candidate = buildContinuousTicks(scale, n, compact);
|
|
218
219
|
if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
|
|
219
220
|
if (candidate.length > best.length) best = candidate;
|
|
220
221
|
}
|
|
@@ -260,6 +261,27 @@ function fitContinuousTicks(
|
|
|
260
261
|
const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
|
|
261
262
|
if (!overshoots && !overlaps) return flooredTicks;
|
|
262
263
|
|
|
264
|
+
// Compact temporal formats are a rung between "full labels overlap" and
|
|
265
|
+
// "drop a tick": at each candidate count, prefer the full format, then try
|
|
266
|
+
// the compact table at the SAME count before stepping the count down. Every
|
|
267
|
+
// returned set is internally consistent — one format family per layout pass.
|
|
268
|
+
// X-axes only: the planner/renderer y pair must keep formatting identically
|
|
269
|
+
// (see resolveExplicitTicks), so vertical axes never compact — including
|
|
270
|
+
// the fallback path below.
|
|
271
|
+
const useCompact =
|
|
272
|
+
(scale.type === 'time' || scale.type === 'utc') && orientation === 'horizontal';
|
|
273
|
+
|
|
274
|
+
const fitsCleanly = (candidate: AxisTick[]): boolean =>
|
|
275
|
+
candidate.length <= tolerance &&
|
|
276
|
+
!ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
|
|
277
|
+
|
|
278
|
+
// Compact-saves-the-count case: full format overlapped at the initial
|
|
279
|
+
// count, but compact labels may fit without giving up a single tick.
|
|
280
|
+
if (useCompact) {
|
|
281
|
+
const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
|
|
282
|
+
if (fitsCleanly(compactAtInitial)) return compactAtInitial;
|
|
283
|
+
}
|
|
284
|
+
|
|
263
285
|
// Enforce the floor only when the axis is long enough to fit that many
|
|
264
286
|
// labels without overlap. Very short axes can fall below.
|
|
265
287
|
const minThreshold =
|
|
@@ -268,123 +290,45 @@ function fitContinuousTicks(
|
|
|
268
290
|
|
|
269
291
|
// Track the smallest candidate that meets the floor. If no candidate fits
|
|
270
292
|
// cleanly, we thin this instead of the overshot `initialTicks` so the
|
|
271
|
-
// fallback doesn't reintroduce the cascading-to-2-ticks bug.
|
|
293
|
+
// fallback doesn't reintroduce the cascading-to-2-ticks bug. Bookkeeping
|
|
294
|
+
// runs against the full-format candidate, exactly as before the compact rung.
|
|
272
295
|
let bestWithinFloor: AxisTick[] | undefined;
|
|
296
|
+
let bestWithinFloorCount: number | undefined;
|
|
273
297
|
for (let n = initialCount - 1; n >= 2; n--) {
|
|
274
298
|
const candidate = buildContinuousTicks(scale, n);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (!candidateOvershoots && !candidateOverlaps) {
|
|
284
|
-
return candidate;
|
|
299
|
+
if (fitsCleanly(candidate)) return candidate;
|
|
300
|
+
if (useCompact) {
|
|
301
|
+
const compact = buildContinuousTicks(scale, n, true);
|
|
302
|
+
if (fitsCleanly(compact)) return compact;
|
|
303
|
+
}
|
|
304
|
+
if (candidate.length >= floor) {
|
|
305
|
+
bestWithinFloor = candidate;
|
|
306
|
+
bestWithinFloorCount = n;
|
|
285
307
|
}
|
|
286
|
-
if (candidate.length >= floor) bestWithinFloor = candidate;
|
|
287
308
|
}
|
|
288
309
|
|
|
289
310
|
// No candidate fit cleanly. Thin whatever most recently met the floor; if
|
|
290
311
|
// nothing did, synthesize a floor-count set directly from the scale so we
|
|
291
312
|
// never hand the overshot initialTicks to the middle-pruning thinner.
|
|
292
|
-
|
|
293
|
-
|
|
313
|
+
// Compacting axes rebuild the fallback compact: if the axis is so starved
|
|
314
|
+
// that we're thinning below the floor, shorter labels are strictly better.
|
|
315
|
+
const rawFallback = useCompact
|
|
316
|
+
? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true)
|
|
317
|
+
: (bestWithinFloor ?? buildContinuousTicks(scale, floor));
|
|
318
|
+
const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
|
|
294
319
|
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
295
320
|
}
|
|
296
321
|
|
|
297
322
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* the
|
|
302
|
-
* so it extends right. The horizontal projection is `width * cos(angle)`.
|
|
303
|
-
*
|
|
304
|
-
* Modeling the footprint as centered (the old behavior) over-estimates each
|
|
305
|
-
* label's reach by half its width in the wrong direction, which made a single
|
|
306
|
-
* wide label (e.g. "2026 (to wk 17)") report overlaps against neighbors that
|
|
307
|
-
* actually clear it — triggering needless thinning of the whole axis.
|
|
323
|
+
* Apply the uniform band-label stride (see bandLabelStride in ./axes/rotation):
|
|
324
|
+
* keep every Nth label counting back from the LAST one, so the most recent
|
|
325
|
+
* category is always labeled. Stride 1 — the overwhelmingly common case once
|
|
326
|
+
* the angle ladder has run — keeps everything.
|
|
308
327
|
*/
|
|
309
|
-
function
|
|
310
|
-
|
|
311
|
-
angleDeg: number,
|
|
312
|
-
fontSize: number,
|
|
313
|
-
fontWeight: number,
|
|
314
|
-
measureText?: MeasureTextFn,
|
|
315
|
-
): { left: number; right: number } {
|
|
316
|
-
const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
|
|
317
|
-
const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
|
|
318
|
-
const projected = measureLabel(tick.label, fontSize, fontWeight, measureText) * cosA;
|
|
319
|
-
if (angleDeg === 0) {
|
|
320
|
-
// Horizontal labels are centered on the tick.
|
|
321
|
-
return { left: tick.position - projected / 2, right: tick.position + projected / 2 };
|
|
322
|
-
}
|
|
323
|
-
if (angleDeg < 0) {
|
|
324
|
-
// Right-anchored: pivot is the right edge, text extends left.
|
|
325
|
-
return { left: tick.position - projected, right: tick.position };
|
|
326
|
-
}
|
|
327
|
-
// Left-anchored: pivot is the left edge, text extends right.
|
|
328
|
-
return { left: tick.position, right: tick.position + projected };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Thin band-scale tick labels only where they actually collide at their
|
|
333
|
-
* effective angle, greedily keeping every label that clears the last one
|
|
334
|
-
* retained. This preserves narrow labels that have room even when a single
|
|
335
|
-
* wide label elsewhere on the axis forces some thinning near it, instead of
|
|
336
|
-
* decimating the whole axis every-other. First and last labels are always
|
|
337
|
-
* kept. Most grouped bar charts keep every label even at -45°.
|
|
338
|
-
*/
|
|
339
|
-
function thinBandTicksIfNeeded(
|
|
340
|
-
ticks: AxisTick[],
|
|
341
|
-
angleDeg: number,
|
|
342
|
-
fontSize: number,
|
|
343
|
-
fontWeight: number,
|
|
344
|
-
measureText?: MeasureTextFn,
|
|
345
|
-
): AxisTick[] {
|
|
346
|
-
if (ticks.length < 3) return ticks;
|
|
347
|
-
const minGap = fontSize * 0.5;
|
|
348
|
-
|
|
349
|
-
const spans = ticks.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
|
|
350
|
-
|
|
351
|
-
// Greedy left-to-right retention: keep a label only if its footprint clears
|
|
352
|
-
// the last kept label's right edge by minGap. Always keep the first label.
|
|
353
|
-
const keep = new Array<boolean>(ticks.length).fill(false);
|
|
354
|
-
keep[0] = true;
|
|
355
|
-
let lastRight = spans[0].right;
|
|
356
|
-
for (let i = 1; i < ticks.length; i++) {
|
|
357
|
-
if (spans[i].left >= lastRight + minGap) {
|
|
358
|
-
keep[i] = true;
|
|
359
|
-
lastRight = spans[i].right;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Always keep the last label. If keeping it collides with previously kept
|
|
364
|
-
// labels, drop each colliding neighbor so the endpoint stays labeled. A very
|
|
365
|
-
// wide final label (long category name) projects across several bands at an
|
|
366
|
-
// angle, so clear every kept label its footprint overlaps, not just the
|
|
367
|
-
// nearest one — otherwise an earlier kept label still overlaps the endpoint.
|
|
368
|
-
//
|
|
369
|
-
// Use true pixel overlap here (no minGap comfort buffer) so a neighbor that
|
|
370
|
-
// merely falls a few pixels short of the comfort gap — but does not actually
|
|
371
|
-
// touch the endpoint label — is kept rather than sacrificed. Dropping a
|
|
372
|
-
// readable short label to seat a near-miss wide endpoint reads as arbitrary
|
|
373
|
-
// (e.g. losing "2025" beside "2026 (to wk 17)" that clears it by 2px).
|
|
328
|
+
function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
|
|
329
|
+
if (stride <= 1 || ticks.length < 3) return ticks;
|
|
374
330
|
const lastIdx = ticks.length - 1;
|
|
375
|
-
|
|
376
|
-
keep[lastIdx] = true;
|
|
377
|
-
for (let i = lastIdx - 1; i > 0; i--) {
|
|
378
|
-
if (!keep[i]) continue;
|
|
379
|
-
if (spans[i].right > spans[lastIdx].left) {
|
|
380
|
-
keep[i] = false;
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return ticks.filter((_, i) => keep[i]);
|
|
331
|
+
return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
|
|
388
332
|
}
|
|
389
333
|
|
|
390
334
|
// ---------------------------------------------------------------------------
|
|
@@ -511,17 +455,40 @@ export function computeAxes(
|
|
|
511
455
|
major: true,
|
|
512
456
|
}));
|
|
513
457
|
|
|
514
|
-
//
|
|
515
|
-
//
|
|
458
|
+
// Band-label policy: choose the shallowest angle at which every label
|
|
459
|
+
// fits (flat → -45° → -90°), then apply the uniform stride safety net —
|
|
460
|
+
// stride 1 (keep everything) except at extreme category counts. See
|
|
461
|
+
// ./axes/rotation for the geometry.
|
|
516
462
|
let tickAngle = axisConfig?.labelAngle;
|
|
517
|
-
|
|
518
|
-
|
|
463
|
+
let bandStride = 1;
|
|
464
|
+
if (scales.x.type === 'band' && allTicks.length > 1) {
|
|
465
|
+
const bandScale = scales.x.scale as ScaleBand<string>;
|
|
466
|
+
const bandwidth = bandScale.bandwidth();
|
|
467
|
+
// Anchor spacing between the ticks actually drawn — not the raw band
|
|
468
|
+
// step. categoricalTicks may have applied an explicit tickCount cap, so
|
|
469
|
+
// retained ticks can sit a multiple of the step apart, and explicit
|
|
470
|
+
// axis.values subsets can be non-uniform: collision geometry cares
|
|
471
|
+
// about the closest adjacent pair.
|
|
472
|
+
let anchorSpacing = Number.POSITIVE_INFINITY;
|
|
473
|
+
for (let i = 1; i < allTicks.length; i++) {
|
|
474
|
+
const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
|
|
475
|
+
if (d > 0 && d < anchorSpacing) anchorSpacing = d;
|
|
476
|
+
}
|
|
477
|
+
if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
|
|
519
478
|
let maxLabelWidth = 0;
|
|
520
479
|
for (const t of allTicks) {
|
|
521
480
|
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
522
481
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
523
482
|
}
|
|
524
|
-
tickAngle = resolveBandTickAngle(
|
|
483
|
+
tickAngle = resolveBandTickAngle(
|
|
484
|
+
axisConfig?.labelAngle,
|
|
485
|
+
maxLabelWidth,
|
|
486
|
+
bandwidth,
|
|
487
|
+
allTicks.length,
|
|
488
|
+
anchorSpacing,
|
|
489
|
+
fontSize,
|
|
490
|
+
);
|
|
491
|
+
bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
|
|
525
492
|
}
|
|
526
493
|
|
|
527
494
|
// Thin tick labels to prevent overlap (skip for explicit tick values).
|
|
@@ -530,10 +497,7 @@ export function computeAxes(
|
|
|
530
497
|
if (hasExplicitValues) {
|
|
531
498
|
ticks = allTicks;
|
|
532
499
|
} else if (scales.x.type === 'band') {
|
|
533
|
-
|
|
534
|
-
// effective angle. After rotation, most charts have room for every label.
|
|
535
|
-
const effectiveAngle = tickAngle ?? 0;
|
|
536
|
-
ticks = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
|
|
500
|
+
ticks = applyBandLabelStride(allTicks, bandStride);
|
|
537
501
|
} else if (isContinuousX) {
|
|
538
502
|
ticks = fitContinuousTicks(
|
|
539
503
|
scales.x,
|
package/src/layout/plan.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { legendGap } from '../legend/wrap';
|
|
|
36
36
|
import { yTickPositionIsInline } from './axes';
|
|
37
37
|
import { resolveBandTickAngle } from './axes/rotation';
|
|
38
38
|
import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
|
|
39
|
-
import { computeScales, estimateBandwidth } from './scales';
|
|
39
|
+
import { computeScales, estimateBandStep, estimateBandwidth } from './scales';
|
|
40
40
|
import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
|
|
41
41
|
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
@@ -342,8 +342,21 @@ export function resolveLayoutPlan(
|
|
|
342
342
|
// bandwidth = step * (1 - paddingInner). Uses the same override resolution
|
|
343
343
|
// as buildBandScale so paddingInner/paddingOuter are honored here too.
|
|
344
344
|
const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
|
|
345
|
+
const step = estimateBandStep(encoding.x?.scale, plotWidth, xLabels.length);
|
|
346
|
+
// Mirror categoricalTicks' explicit tickCount cap: retained ticks sit a
|
|
347
|
+
// multiple of the band step apart, so the drawn labels and their anchor
|
|
348
|
+
// spacing — not the full category set — drive the angle here, matching
|
|
349
|
+
// what computeAxes measures from actual tick positions.
|
|
350
|
+
const xTickCount = xAxis?.tickCount as number | undefined;
|
|
351
|
+
let drawnLabels = xLabels;
|
|
352
|
+
let anchorSpacing = step;
|
|
353
|
+
if (xTickCount && xLabels.length > xTickCount) {
|
|
354
|
+
const capStride = Math.ceil(xLabels.length / xTickCount);
|
|
355
|
+
drawnLabels = xLabels.filter((_, i) => i % capStride === 0);
|
|
356
|
+
anchorSpacing = step * capStride;
|
|
357
|
+
}
|
|
345
358
|
let maxXLabelWidth = 0;
|
|
346
|
-
for (const label of
|
|
359
|
+
for (const label of drawnLabels) {
|
|
347
360
|
const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
348
361
|
if (w > maxXLabelWidth) maxXLabelWidth = w;
|
|
349
362
|
}
|
|
@@ -351,7 +364,9 @@ export function resolveLayoutPlan(
|
|
|
351
364
|
undefined,
|
|
352
365
|
maxXLabelWidth,
|
|
353
366
|
bandwidth,
|
|
354
|
-
|
|
367
|
+
drawnLabels.length,
|
|
368
|
+
anchorSpacing,
|
|
369
|
+
theme.fonts.sizes.axisTick,
|
|
355
370
|
);
|
|
356
371
|
}
|
|
357
372
|
|
package/src/layout/scales.ts
CHANGED
|
@@ -470,19 +470,43 @@ function evenRange(start: number, end: number, count: number): number[] {
|
|
|
470
470
|
* each independently. step = width / (n - paddingInner + 2*paddingOuter);
|
|
471
471
|
* bandwidth = step * (1 - paddingInner).
|
|
472
472
|
*/
|
|
473
|
+
/** d3 scaleBand padding resolution: `padding` sets both, overrides win. */
|
|
474
|
+
function resolveBandPadding(scaleConfig: EncodingChannel['scale'] | undefined): {
|
|
475
|
+
paddingInner: number;
|
|
476
|
+
paddingOuter: number;
|
|
477
|
+
} {
|
|
478
|
+
const padding = scaleConfig?.padding ?? 0.35;
|
|
479
|
+
return {
|
|
480
|
+
paddingInner: scaleConfig?.paddingInner ?? padding,
|
|
481
|
+
paddingOuter: scaleConfig?.paddingOuter ?? padding,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
473
485
|
export function estimateBandwidth(
|
|
474
486
|
scaleConfig: EncodingChannel['scale'] | undefined,
|
|
475
487
|
plotWidth: number,
|
|
476
488
|
n: number,
|
|
489
|
+
): number {
|
|
490
|
+
const { paddingInner } = resolveBandPadding(scaleConfig);
|
|
491
|
+
return estimateBandStep(scaleConfig, plotWidth, n) * (1 - paddingInner);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Estimate d3 scaleBand step (center-to-center band distance) for a given
|
|
496
|
+
* plot width and category count, using the same padding resolution as
|
|
497
|
+
* estimateBandwidth. The step — not the bandwidth — is the anchor spacing
|
|
498
|
+
* that drives rotated-label collision checks.
|
|
499
|
+
*/
|
|
500
|
+
export function estimateBandStep(
|
|
501
|
+
scaleConfig: EncodingChannel['scale'] | undefined,
|
|
502
|
+
plotWidth: number,
|
|
503
|
+
n: number,
|
|
477
504
|
): number {
|
|
478
505
|
if (n <= 0) return 0;
|
|
479
|
-
const
|
|
480
|
-
const paddingInner = scaleConfig?.paddingInner ?? padding;
|
|
481
|
-
const paddingOuter = scaleConfig?.paddingOuter ?? padding;
|
|
506
|
+
const { paddingInner, paddingOuter } = resolveBandPadding(scaleConfig);
|
|
482
507
|
const denom = n - paddingInner + 2 * paddingOuter;
|
|
483
508
|
if (denom <= 0) return 0;
|
|
484
|
-
|
|
485
|
-
return step * (1 - paddingInner);
|
|
509
|
+
return plotWidth / denom;
|
|
486
510
|
}
|
|
487
511
|
|
|
488
512
|
function buildBandScale(
|