@opendata-ai/openchart-engine 7.9.1 → 7.9.3
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 +92 -58
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +131 -28
- package/src/__tests__/dimensions.test.ts +41 -0
- package/src/__tests__/rotation.test.ts +83 -0
- package/src/layout/axes/rotation.ts +130 -14
- package/src/layout/axes/ticks.ts +2 -2
- package/src/layout/axes.ts +48 -93
- package/src/layout/plan.ts +18 -3
- package/src/layout/scales.ts +29 -5
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,
|
|
@@ -295,90 +295,15 @@ function fitContinuousTicks(
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
|
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.
|
|
298
|
+
* Apply the uniform band-label stride (see bandLabelStride in ./axes/rotation):
|
|
299
|
+
* keep every Nth label counting back from the LAST one, so the most recent
|
|
300
|
+
* category is always labeled. Stride 1 — the overwhelmingly common case once
|
|
301
|
+
* the angle ladder has run — keeps everything.
|
|
308
302
|
*/
|
|
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.
|
|
303
|
+
function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
|
|
304
|
+
if (stride <= 1 || ticks.length < 3) return ticks;
|
|
368
305
|
const lastIdx = ticks.length - 1;
|
|
369
|
-
|
|
370
|
-
keep[lastIdx] = true;
|
|
371
|
-
for (let i = lastIdx - 1; i > 0; i--) {
|
|
372
|
-
if (!keep[i]) continue;
|
|
373
|
-
if (spans[i].right + minGap > spans[lastIdx].left) {
|
|
374
|
-
keep[i] = false;
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
break;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return ticks.filter((_, i) => keep[i]);
|
|
306
|
+
return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
|
|
382
307
|
}
|
|
383
308
|
|
|
384
309
|
// ---------------------------------------------------------------------------
|
|
@@ -505,17 +430,40 @@ export function computeAxes(
|
|
|
505
430
|
major: true,
|
|
506
431
|
}));
|
|
507
432
|
|
|
508
|
-
//
|
|
509
|
-
//
|
|
433
|
+
// Band-label policy: choose the shallowest angle at which every label
|
|
434
|
+
// fits (flat → -45° → -90°), then apply the uniform stride safety net —
|
|
435
|
+
// stride 1 (keep everything) except at extreme category counts. See
|
|
436
|
+
// ./axes/rotation for the geometry.
|
|
510
437
|
let tickAngle = axisConfig?.labelAngle;
|
|
511
|
-
|
|
512
|
-
|
|
438
|
+
let bandStride = 1;
|
|
439
|
+
if (scales.x.type === 'band' && allTicks.length > 1) {
|
|
440
|
+
const bandScale = scales.x.scale as ScaleBand<string>;
|
|
441
|
+
const bandwidth = bandScale.bandwidth();
|
|
442
|
+
// Anchor spacing between the ticks actually drawn — not the raw band
|
|
443
|
+
// step. categoricalTicks may have applied an explicit tickCount cap, so
|
|
444
|
+
// retained ticks can sit a multiple of the step apart, and explicit
|
|
445
|
+
// axis.values subsets can be non-uniform: collision geometry cares
|
|
446
|
+
// about the closest adjacent pair.
|
|
447
|
+
let anchorSpacing = Number.POSITIVE_INFINITY;
|
|
448
|
+
for (let i = 1; i < allTicks.length; i++) {
|
|
449
|
+
const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
|
|
450
|
+
if (d > 0 && d < anchorSpacing) anchorSpacing = d;
|
|
451
|
+
}
|
|
452
|
+
if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
|
|
513
453
|
let maxLabelWidth = 0;
|
|
514
454
|
for (const t of allTicks) {
|
|
515
455
|
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
516
456
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
517
457
|
}
|
|
518
|
-
tickAngle = resolveBandTickAngle(
|
|
458
|
+
tickAngle = resolveBandTickAngle(
|
|
459
|
+
axisConfig?.labelAngle,
|
|
460
|
+
maxLabelWidth,
|
|
461
|
+
bandwidth,
|
|
462
|
+
allTicks.length,
|
|
463
|
+
anchorSpacing,
|
|
464
|
+
fontSize,
|
|
465
|
+
);
|
|
466
|
+
bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
|
|
519
467
|
}
|
|
520
468
|
|
|
521
469
|
// Thin tick labels to prevent overlap (skip for explicit tick values).
|
|
@@ -524,10 +472,7 @@ export function computeAxes(
|
|
|
524
472
|
if (hasExplicitValues) {
|
|
525
473
|
ticks = allTicks;
|
|
526
474
|
} else if (scales.x.type === 'band') {
|
|
527
|
-
|
|
528
|
-
// effective angle. After rotation, most charts have room for every label.
|
|
529
|
-
const effectiveAngle = tickAngle ?? 0;
|
|
530
|
-
ticks = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
|
|
475
|
+
ticks = applyBandLabelStride(allTicks, bandStride);
|
|
531
476
|
} else if (isContinuousX) {
|
|
532
477
|
ticks = fitContinuousTicks(
|
|
533
478
|
scales.x,
|
|
@@ -735,7 +680,17 @@ export function computeAxes(
|
|
|
735
680
|
);
|
|
736
681
|
return Math.max(max, w);
|
|
737
682
|
}, 0);
|
|
738
|
-
|
|
683
|
+
// Inline y-ticks live inside the plot (no left gutter), so the title
|
|
684
|
+
// clears only the chart edge — pass the inline flag so the offset drops
|
|
685
|
+
// the tick-label width and the viewport floor. Omitting it reserved a
|
|
686
|
+
// gutter-sized offset that pushed the rotated title off the left edge of
|
|
687
|
+
// the container on narrow/large-font charts.
|
|
688
|
+
const titleOff = axisTitleOffset(
|
|
689
|
+
maxTickLabelWidth,
|
|
690
|
+
result.y.labelStyle.fontSize,
|
|
691
|
+
totalWidth,
|
|
692
|
+
result.y.tickPosition === 'inline',
|
|
693
|
+
);
|
|
739
694
|
result.y.titlePosition = {
|
|
740
695
|
x: chartArea.x - titleOff,
|
|
741
696
|
y: chartArea.y + chartArea.height / 2,
|
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(
|