@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.10.0",
|
|
4
4
|
"description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@opendata-ai/openchart-core": "7.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.10.0",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -482,12 +482,11 @@ describe('text-aware tick density', () => {
|
|
|
482
482
|
expect(axes.x!.ticks.length).toBe(categories.length);
|
|
483
483
|
});
|
|
484
484
|
|
|
485
|
-
it('keeps
|
|
485
|
+
it('keeps all band labels when one label is much wider (no decimation)', () => {
|
|
486
486
|
// Regression: 5 rotated year labels where "2026 (to wk 17)" is much wider
|
|
487
|
-
// than the others.
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
// ones; only the neighbor immediately crowded by the wide final label drops.
|
|
487
|
+
// than the others. Label width cannot cause rotated-label collisions
|
|
488
|
+
// (parallel ribbons — see ./layout/axes/rotation), so every label is kept.
|
|
489
|
+
// The old span-based thinning dropped "2023" and "2025" here.
|
|
491
490
|
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
492
491
|
const barSpec: NormalizedChartSpec = {
|
|
493
492
|
...lineSpec,
|
|
@@ -507,16 +506,13 @@ describe('text-aware tick density', () => {
|
|
|
507
506
|
height: 12,
|
|
508
507
|
});
|
|
509
508
|
|
|
510
|
-
// Narrow area (280px) forces -45° rotation.
|
|
511
|
-
//
|
|
509
|
+
// Narrow area (280px) forces -45° rotation. Every label survives — the
|
|
510
|
+
// ~47px step separates the diagonal ribbons by far more than a line height.
|
|
512
511
|
const narrowArea = { x: 50, y: 50, width: 280, height: 300 };
|
|
513
512
|
const narrowScales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
514
513
|
const narrowAxes = computeAxes(narrowScales, narrowArea, fullStrategy, theme, measure);
|
|
515
|
-
const narrowKept = narrowAxes.x!.ticks.map((t) => t.label);
|
|
516
514
|
expect(narrowAxes.x!.tickAngle).toBe(-45);
|
|
517
|
-
expect(
|
|
518
|
-
expect(narrowKept).toContain('2024');
|
|
519
|
-
expect(narrowKept).toContain('2026 (to wk 17)');
|
|
515
|
+
expect(narrowAxes.x!.ticks.map((t) => t.label)).toEqual(categories);
|
|
520
516
|
|
|
521
517
|
// Wider area (500px) has room for every label — none are thinned.
|
|
522
518
|
const wideArea = { x: 50, y: 50, width: 500, height: 300 };
|
|
@@ -525,13 +521,11 @@ describe('text-aware tick density', () => {
|
|
|
525
521
|
expect(wideAxes.x!.ticks.length).toBe(categories.length);
|
|
526
522
|
});
|
|
527
523
|
|
|
528
|
-
it('keeps a short neighbor
|
|
529
|
-
// Regression (mobile):
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// seat the wide endpoint reads as arbitrary. The protection pass keys on
|
|
534
|
-
// true pixel overlap, not the comfort gap, so "2025" survives.
|
|
524
|
+
it('keeps a short neighbor beside a wide final label at phone width', () => {
|
|
525
|
+
// Regression (mobile): "2025" beside "2026 (to wk 17)" at ~300px. Two
|
|
526
|
+
// earlier span-model revisions dropped "2025" here (once via minGap
|
|
527
|
+
// comfort padding, once via a genuine 1-D span overlap at larger fonts)
|
|
528
|
+
// even though the rendered -45° diagonals never touch.
|
|
535
529
|
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
536
530
|
const barSpec: NormalizedChartSpec = {
|
|
537
531
|
...lineSpec,
|
|
@@ -557,11 +551,38 @@ describe('text-aware tick density', () => {
|
|
|
557
551
|
expect(kept.length).toBe(categories.length);
|
|
558
552
|
});
|
|
559
553
|
|
|
560
|
-
it('
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
554
|
+
it('keeps all labels with a larger themed tick font at phone width (deployed blog regression)', () => {
|
|
555
|
+
// Regression: the deployed blog theme sets axisTick: 14 (default 11). At
|
|
556
|
+
// ~61px steps the larger font made the 1-D span overlap genuine, so 7.9.2
|
|
557
|
+
// still dropped "2025" in production while the default theme passed every
|
|
558
|
+
// test. Ribbon separation at -45° is 61·sin45 ≈ 43px vs the ~21px one line
|
|
559
|
+
// at 14px needs — the labels never touch, so all five must survive.
|
|
560
|
+
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
561
|
+
const barSpec: NormalizedChartSpec = {
|
|
562
|
+
...lineSpec,
|
|
563
|
+
markType: 'bar',
|
|
564
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
565
|
+
data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2026, 1602][i] })),
|
|
566
|
+
encoding: {
|
|
567
|
+
x: { field: 'cat', type: 'nominal' },
|
|
568
|
+
y: { field: 'val', type: 'quantitative' },
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
const blogTheme = resolveTheme({ fonts: { sizes: { axisTick: 14 } } });
|
|
572
|
+
|
|
573
|
+
const area = { x: 60, y: 50, width: 330, height: 300 };
|
|
574
|
+
const scales = computeScales(barSpec, area, barSpec.data);
|
|
575
|
+
const axes = computeAxes(scales, area, fullStrategy, blogTheme);
|
|
576
|
+
expect(axes.x!.tickAngle).toBe(-45);
|
|
577
|
+
expect(axes.x!.ticks.map((t) => t.label)).toEqual(categories);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('keeps every label when one is very long — rotated labels are parallel ribbons', () => {
|
|
581
|
+
// A very long final category does NOT force neighbors to drop. Rotated
|
|
582
|
+
// labels all lean at the same angle from their own anchors, so adjacent
|
|
583
|
+
// labels are parallel ribbons separated by step·sin(angle) — label LENGTH
|
|
584
|
+
// is irrelevant to collision. The old 1-D horizontal-span model reported
|
|
585
|
+
// phantom overlaps here and dropped "B", "C", and "D".
|
|
565
586
|
const categories = ['A', 'B', 'C', 'D', 'This Is A Very Long Final Category Name'];
|
|
566
587
|
const barSpec: NormalizedChartSpec = {
|
|
567
588
|
...lineSpec,
|
|
@@ -579,15 +600,63 @@ describe('text-aware tick density', () => {
|
|
|
579
600
|
const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
|
|
580
601
|
expect(axes.x!.tickAngle).toBe(-45);
|
|
581
602
|
|
|
603
|
+
// Every category keeps its label — the diagonals never touch.
|
|
604
|
+
const kept = axes.x!.ticks.map((t) => t.label);
|
|
605
|
+
expect(kept).toEqual(categories);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('measures collision at the drawn tick spacing when an explicit tickCount thins upstream', () => {
|
|
609
|
+
// 40 categories with tickCount: 8 — categoricalTicks retains every 5th
|
|
610
|
+
// category, so the drawn ticks sit 5 band steps apart. The angle and
|
|
611
|
+
// stride must use that real spacing: at ~45px apart the 8 labels fit at
|
|
612
|
+
// -45° with no thinning. Keying on the raw ~9px band step would wrongly
|
|
613
|
+
// escalate to -90° and stride away half the requested labels.
|
|
614
|
+
const categories = Array.from({ length: 40 }, (_, i) => `Cat ${i + 1}`);
|
|
615
|
+
const barSpec: NormalizedChartSpec = {
|
|
616
|
+
...lineSpec,
|
|
617
|
+
markType: 'bar',
|
|
618
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
619
|
+
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
620
|
+
encoding: {
|
|
621
|
+
x: { field: 'cat', type: 'nominal', axis: { tickCount: 8 } },
|
|
622
|
+
y: { field: 'val', type: 'quantitative' },
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
const measure = (text: string) => ({ width: text.length * 6, height: 12 });
|
|
626
|
+
const area = { x: 50, y: 50, width: 360, height: 300 };
|
|
627
|
+
const scales = computeScales(barSpec, area, barSpec.data);
|
|
628
|
+
const axes = computeAxes(scales, area, fullStrategy, theme, measure);
|
|
629
|
+
|
|
630
|
+
expect(axes.x!.tickAngle).toBe(-45);
|
|
631
|
+
expect(axes.x!.ticks.length).toBe(8);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('applies the last-anchored stride when even vertical labels cannot fit', () => {
|
|
635
|
+
// 40 short categories on a 360px chart: ~9px steps fit nothing at any
|
|
636
|
+
// angle (a vertical label ribbon needs ~17px), so the safety net keeps
|
|
637
|
+
// every 2nd label counting back from the LAST category.
|
|
638
|
+
const categories = Array.from({ length: 40 }, (_, i) => `C${i + 1}`);
|
|
639
|
+
const barSpec: NormalizedChartSpec = {
|
|
640
|
+
...lineSpec,
|
|
641
|
+
markType: 'bar',
|
|
642
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
643
|
+
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
644
|
+
encoding: {
|
|
645
|
+
x: { field: 'cat', type: 'nominal' },
|
|
646
|
+
y: { field: 'val', type: 'quantitative' },
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
const measure = (text: string) => ({ width: text.length * 6, height: 12 });
|
|
650
|
+
const area = { x: 50, y: 50, width: 360, height: 300 };
|
|
651
|
+
const scales = computeScales(barSpec, area, barSpec.data);
|
|
652
|
+
const axes = computeAxes(scales, area, fullStrategy, theme, measure);
|
|
653
|
+
|
|
654
|
+
expect(axes.x!.tickAngle).toBe(-90);
|
|
582
655
|
const kept = axes.x!.ticks.map((t) => t.label);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
// overlap the final label; only "D" (the immediate neighbor) was dropped.
|
|
588
|
-
expect(kept).not.toContain('B');
|
|
589
|
-
expect(kept).not.toContain('C');
|
|
590
|
-
expect(kept).not.toContain('D');
|
|
656
|
+
expect(kept.length).toBe(20);
|
|
657
|
+
// The stride anchors at the final category — the most recent one is
|
|
658
|
+
// always labeled.
|
|
659
|
+
expect(kept[kept.length - 1]).toBe('C40');
|
|
591
660
|
});
|
|
592
661
|
|
|
593
662
|
it('y-axis gridlines match ticks so every gridline has a label', () => {
|
|
@@ -978,7 +1047,7 @@ describe('horizontal bar y-axis label thinning regression', () => {
|
|
|
978
1047
|
expect(axes.y!.ticks.length).toBe(countries.length);
|
|
979
1048
|
});
|
|
980
1049
|
|
|
981
|
-
it('
|
|
1050
|
+
it('rotates to vertical instead of thinning when diagonals would touch (column chart)', () => {
|
|
982
1051
|
const vBarSpec: NormalizedChartSpec = {
|
|
983
1052
|
...hBarSpec,
|
|
984
1053
|
markDef: { type: 'bar', orient: 'vertical' },
|
|
@@ -991,9 +1060,11 @@ describe('horizontal bar y-axis label thinning regression', () => {
|
|
|
991
1060
|
const scales = computeScales(vBarSpec, narrowArea, vBarSpec.data);
|
|
992
1061
|
const axes = computeAxes(scales, narrowArea, minimalStrategy, theme);
|
|
993
1062
|
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
1063
|
+
// 10 categories at ~19px steps: -45° ribbons would touch (19·sin45 ≈ 14px
|
|
1064
|
+
// < one line height), so the ladder escalates to vertical, where the 19px
|
|
1065
|
+
// step separation fits — every bar keeps its label.
|
|
1066
|
+
expect(axes.x!.tickAngle).toBe(-90);
|
|
1067
|
+
expect(axes.x!.ticks.length).toBe(countries.length);
|
|
997
1068
|
});
|
|
998
1069
|
});
|
|
999
1070
|
|
|
@@ -1294,3 +1365,168 @@ describe('quantitative axis minimum tick floor', () => {
|
|
|
1294
1365
|
}
|
|
1295
1366
|
});
|
|
1296
1367
|
});
|
|
1368
|
+
|
|
1369
|
+
// ---------------------------------------------------------------------------
|
|
1370
|
+
// Compact temporal tick formats: pinned pre-change behavior
|
|
1371
|
+
// ---------------------------------------------------------------------------
|
|
1372
|
+
// These two tests pin the exact tick labels produced BEFORE the compact
|
|
1373
|
+
// temporal format rung lands in fitContinuousTicks. They must stay green
|
|
1374
|
+
// through that change:
|
|
1375
|
+
// - wide desktop axes have room for full-format labels, so compact never fires
|
|
1376
|
+
// - temporal y ticks are never compact (planner/renderer gutter agreement;
|
|
1377
|
+
// see plans/compact-temporal-ticks.md)
|
|
1378
|
+
|
|
1379
|
+
describe('compact temporal ticks: pinned pre-change output', () => {
|
|
1380
|
+
const monthlyData = Array.from({ length: 12 }, (_, i) => ({
|
|
1381
|
+
date: `2025-${String(i + 1).padStart(2, '0')}-01`,
|
|
1382
|
+
value: 100 + i * 10,
|
|
1383
|
+
}));
|
|
1384
|
+
|
|
1385
|
+
const monthlySpec: NormalizedChartSpec = {
|
|
1386
|
+
...lineSpec,
|
|
1387
|
+
data: monthlyData,
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
it('desktop unchanged: monthly temporal x at ~900px keeps full-format labels', () => {
|
|
1391
|
+
const wideArea = { x: 50, y: 50, width: 900, height: 300 };
|
|
1392
|
+
const scales = computeScales(monthlySpec, wideArea, monthlySpec.data);
|
|
1393
|
+
const axes = computeAxes(scales, wideArea, fullStrategy, theme);
|
|
1394
|
+
|
|
1395
|
+
expect(axes.x!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
it('temporal y-axis unaffected: labels at ~360px stay full-format', () => {
|
|
1399
|
+
const ySpec: NormalizedChartSpec = {
|
|
1400
|
+
...monthlySpec,
|
|
1401
|
+
markType: 'point',
|
|
1402
|
+
markDef: { type: 'point' },
|
|
1403
|
+
encoding: {
|
|
1404
|
+
x: { field: 'value', type: 'quantitative' },
|
|
1405
|
+
y: { field: 'date', type: 'temporal' },
|
|
1406
|
+
},
|
|
1407
|
+
};
|
|
1408
|
+
const narrowArea = { x: 40, y: 40, width: 360, height: 300 };
|
|
1409
|
+
const scales = computeScales(ySpec, narrowArea, ySpec.data);
|
|
1410
|
+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
1411
|
+
|
|
1412
|
+
expect(axes.y!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('temporal y-axis stays full-format even at starved heights (fallback path)', () => {
|
|
1416
|
+
// Regression guard for the fallback path: compact is x-axis-only (the
|
|
1417
|
+
// planner/renderer y pair must format identically), so even a height so
|
|
1418
|
+
// short that fitContinuousTicks falls through to thinning must keep the
|
|
1419
|
+
// year on every label — never a bare compact month like 'Oct'.
|
|
1420
|
+
const ySpec: NormalizedChartSpec = {
|
|
1421
|
+
...monthlySpec,
|
|
1422
|
+
markType: 'point',
|
|
1423
|
+
markDef: { type: 'point' },
|
|
1424
|
+
encoding: {
|
|
1425
|
+
x: { field: 'value', type: 'quantitative' },
|
|
1426
|
+
y: { field: 'date', type: 'temporal' },
|
|
1427
|
+
},
|
|
1428
|
+
};
|
|
1429
|
+
const starvedArea = { x: 40, y: 40, width: 360, height: 55 };
|
|
1430
|
+
const scales = computeScales(ySpec, starvedArea, ySpec.data);
|
|
1431
|
+
const axes = computeAxes(scales, starvedArea, fullStrategy, theme);
|
|
1432
|
+
|
|
1433
|
+
const labels = axes.y!.ticks.map((t) => t.label);
|
|
1434
|
+
expect(labels.length).toBeGreaterThanOrEqual(2);
|
|
1435
|
+
for (const label of labels) {
|
|
1436
|
+
expect(label).toMatch(/\d{4}/);
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
// ---------------------------------------------------------------------------
|
|
1442
|
+
// Compact temporal tick formats: the fitting rung
|
|
1443
|
+
// ---------------------------------------------------------------------------
|
|
1444
|
+
// When full-format labels overlap on a temporal x-axis, fitContinuousTicks
|
|
1445
|
+
// tries the compact format table at the SAME count before dropping ticks.
|
|
1446
|
+
// Pre-change counts below were measured by running these exact inputs against
|
|
1447
|
+
// the unmodified engine (git stash) — see plans/compact-temporal-ticks.md.
|
|
1448
|
+
|
|
1449
|
+
describe('compact temporal ticks: fitting rung', () => {
|
|
1450
|
+
const monthlySpecOver = (
|
|
1451
|
+
months: number,
|
|
1452
|
+
axis?: Record<string, unknown>,
|
|
1453
|
+
): NormalizedChartSpec => ({
|
|
1454
|
+
...lineSpec,
|
|
1455
|
+
data: Array.from({ length: months }, (_, i) => ({
|
|
1456
|
+
date: `2025-${String(i + 1).padStart(2, '0')}-01`,
|
|
1457
|
+
value: 100 + i * 10,
|
|
1458
|
+
})),
|
|
1459
|
+
encoding: {
|
|
1460
|
+
x: { field: 'date', type: 'temporal', ...(axis ? { axis } : {}) },
|
|
1461
|
+
y: { field: 'value', type: 'quantitative' },
|
|
1462
|
+
},
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
const xLabelsAt = (spec: NormalizedChartSpec, width: number): string[] => {
|
|
1466
|
+
const area = { x: 40, y: 40, width, height: 300 };
|
|
1467
|
+
const scales = computeScales(spec, area, spec.data);
|
|
1468
|
+
const axes = computeAxes(scales, area, fullStrategy, theme);
|
|
1469
|
+
return axes.x!.ticks.map((t) => t.label);
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
const COMPACT_MONTH = /^[A-Z][a-z]{2}$/; // 'Feb'
|
|
1473
|
+
const FULL_MONTH = /^[A-Z][a-z]{2} \d{4}$/; // 'Feb 2025'
|
|
1474
|
+
const YEAR_ONLY = /^\d{4}$/; // year-boundary tick, same in both tables
|
|
1475
|
+
|
|
1476
|
+
it('keeps more ticks than pre-change when full labels overlap (Jan-Dec 2025 at 160px)', () => {
|
|
1477
|
+
const labels = xLabelsAt(monthlySpecOver(12), 160);
|
|
1478
|
+
|
|
1479
|
+
// Pre-change output at this width: ['2025', 'Oct 2025'] — the overlap
|
|
1480
|
+
// forced a count step-down to 2. Compact keeps all 4 quarterly ticks.
|
|
1481
|
+
expect(labels).toEqual(['2025', 'Apr', 'Jul', 'Oct']);
|
|
1482
|
+
expect(labels.length).toBeGreaterThan(2);
|
|
1483
|
+
|
|
1484
|
+
// No label carries a year except year-boundary ticks.
|
|
1485
|
+
for (const label of labels) {
|
|
1486
|
+
if (/\d{4}/.test(label)) expect(label).toMatch(YEAR_ONLY);
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
it('renders bare compact months at monthly tick granularity (Jan-Apr 2025 at 160px)', () => {
|
|
1491
|
+
// End the domain mid-April: a domain ending exactly on Apr 1 includes the
|
|
1492
|
+
// Apr tick only when the runner's timezone is UTC (local time scales place
|
|
1493
|
+
// month boundaries at local midnight, so negative offsets push Apr 1 past
|
|
1494
|
+
// the domain end).
|
|
1495
|
+
const base = monthlySpecOver(4);
|
|
1496
|
+
const spec = { ...base, data: [...base.data, { date: '2025-04-15', value: 145 }] };
|
|
1497
|
+
const labels = xLabelsAt(spec, 160);
|
|
1498
|
+
|
|
1499
|
+
// Pre-change output at this width: ['2025', 'Mar 2025'] (2 ticks).
|
|
1500
|
+
expect(labels).toEqual(['2025', 'Feb', 'Mar', 'Apr']);
|
|
1501
|
+
expect(labels).toContain('Feb');
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
it('refit stays internally consistent when D3 overshoots the requested count', () => {
|
|
1505
|
+
// Jan-May 2025: D3 returns 5 monthly ticks for a request of 3 (> the 1.5x
|
|
1506
|
+
// overshoot tolerance), forcing the refit loop. Whatever count/format the
|
|
1507
|
+
// loop lands on, the returned set must be ONE format family — never a mix
|
|
1508
|
+
// of 'Feb' and 'Mar 2025'.
|
|
1509
|
+
const spec = monthlySpecOver(5);
|
|
1510
|
+
for (const width of [100, 140, 180, 240]) {
|
|
1511
|
+
const labels = xLabelsAt(spec, width);
|
|
1512
|
+
const monthLabels = labels.filter((l) => !YEAR_ONLY.test(l));
|
|
1513
|
+
expect(monthLabels.length, `width ${width}`).toBeGreaterThan(0);
|
|
1514
|
+
const allCompact = monthLabels.every((l) => COMPACT_MONTH.test(l));
|
|
1515
|
+
const allFull = monthLabels.every((l) => FULL_MONTH.test(l));
|
|
1516
|
+
expect(
|
|
1517
|
+
allCompact || allFull,
|
|
1518
|
+
`mixed format families at width ${width}: ${JSON.stringify(labels)}`,
|
|
1519
|
+
).toBe(true);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it('explicit axis.format wins over compact at narrow width', () => {
|
|
1524
|
+
const labels = xLabelsAt(monthlySpecOver(12, { format: '%Y-%m' }), 160);
|
|
1525
|
+
|
|
1526
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
1527
|
+
for (const label of labels) {
|
|
1528
|
+
expect(label).toMatch(/^\d{4}-\d{2}$/);
|
|
1529
|
+
}
|
|
1530
|
+
expect(labels).toEqual(['2025-01', '2025-10']);
|
|
1531
|
+
});
|
|
1532
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { bandLabelStride, resolveBandTickAngle } from '../layout/axes/rotation';
|
|
3
|
+
|
|
4
|
+
// Geometry cheat sheet at the default 11px tick font:
|
|
5
|
+
// - ribbon thickness = 11 * 1.2 + 4 = 17.2px (perpendicular room one label needs)
|
|
6
|
+
// - at -45°, ribbon separation = spacing * 0.707, so the -45 → -90 flip sits
|
|
7
|
+
// near spacing ≈ 24.3px
|
|
8
|
+
// - at -90°, separation = spacing, so the stride safety net starts below
|
|
9
|
+
// spacing ≈ 17.2px
|
|
10
|
+
|
|
11
|
+
describe('resolveBandTickAngle', () => {
|
|
12
|
+
it('returns the explicit angle untouched', () => {
|
|
13
|
+
expect(resolveBandTickAngle(-30, 100, 20, 5, 50, 11)).toBe(-30);
|
|
14
|
+
expect(resolveBandTickAngle(0, 100, 20, 5, 50, 11)).toBe(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('never rotates a single label', () => {
|
|
18
|
+
expect(resolveBandTickAngle(undefined, 100, 20, 1, 50, 11)).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('stays flat when the widest label fits its band', () => {
|
|
22
|
+
expect(resolveBandTickAngle(undefined, 17, 20, 5, 30, 11)).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('picks -45 when diagonal ribbons clear one line height', () => {
|
|
26
|
+
// separation = 25 * sin45 ≈ 17.7 >= 17.2
|
|
27
|
+
expect(resolveBandTickAngle(undefined, 40, 20, 5, 25, 11)).toBe(-45);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('escalates to -90 when diagonal ribbons would touch', () => {
|
|
31
|
+
// separation = 24 * sin45 ≈ 17.0 < 17.2, and 24px along-text clearance
|
|
32
|
+
// cannot fit a 40px label; vertical separation 24 >= 17.2 fits.
|
|
33
|
+
expect(resolveBandTickAngle(undefined, 40, 20, 5, 24, 11)).toBe(-90);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps -45 for short labels that clear end-to-end along the text direction', () => {
|
|
37
|
+
// Ribbons touch (24 * sin45 < 17.2) but a 10px label clears via the
|
|
38
|
+
// 17px along-text offset — finite segments slide past each other.
|
|
39
|
+
expect(resolveBandTickAngle(undefined, 10, 8, 5, 24, 11)).toBe(-45);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('refuses -90 for labels longer than the rotated-extent cap', () => {
|
|
43
|
+
// A 150px label projected vertically exceeds the 120px reservation cap
|
|
44
|
+
// and would clip into the source line; stay at -45 and let the stride
|
|
45
|
+
// safety net thin instead.
|
|
46
|
+
expect(resolveBandTickAngle(undefined, 150, 20, 5, 20, 11)).toBe(-45);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('bandLabelStride', () => {
|
|
51
|
+
it('returns 1 for degenerate spacing', () => {
|
|
52
|
+
expect(bandLabelStride(50, -45, 11, 0)).toBe(1);
|
|
53
|
+
expect(bandLabelStride(50, -45, 11, -5)).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('drives the flat stride by label width', () => {
|
|
57
|
+
// (50 + 4) / 20 → every 3rd label
|
|
58
|
+
expect(bandLabelStride(50, undefined, 11, 20)).toBe(3);
|
|
59
|
+
expect(bandLabelStride(10, undefined, 11, 20)).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps every label when vertical separation fits', () => {
|
|
63
|
+
expect(bandLabelStride(50, -90, 11, 20)).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('thins vertical labels only at tiny steps', () => {
|
|
67
|
+
// separation 7 < 17.2 → ceil(17.2 / 7) = 3
|
|
68
|
+
expect(bandLabelStride(50, -90, 11, 7)).toBe(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not thin a shallow explicit angle whose labels clear end-to-end', () => {
|
|
72
|
+
// labelAngle -20 at 50px spacing: ribbon separation 17.1 barely misses
|
|
73
|
+
// one line height, but the 47px along-text offset clears a 30px label.
|
|
74
|
+
// The old span model dropped every other label here.
|
|
75
|
+
expect(bandLabelStride(30, -20, 11, 50)).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('thins a shallow explicit angle when labels overlap by both routes', () => {
|
|
79
|
+
// labelAngle -20 at 30px spacing with 60px labels: separation 10.3 needs
|
|
80
|
+
// stride 2, along-text clearance 28.2 needs stride 3 → ribbons win.
|
|
81
|
+
expect(bandLabelStride(60, -20, 11, 30)).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,36 +1,152 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-rotation
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Auto-rotation and thinning policy for band-scale x-axis tick labels.
|
|
3
|
+
*
|
|
4
|
+
* Band categories are data: every bar should keep its label. The policy
|
|
5
|
+
* steepens the label angle until everything fits — flat, then -45°, then -90°
|
|
6
|
+
* (vertical) — and only thins (uniform every-Nth) when even the steepest
|
|
7
|
+
* usable angle cannot fit every label, which takes dozens of categories at
|
|
8
|
+
* tiny steps.
|
|
9
|
+
*
|
|
10
|
+
* The collision geometry differs by orientation, and getting it right is what
|
|
11
|
+
* makes thinning almost never necessary:
|
|
12
|
+
*
|
|
13
|
+
* - FLAT labels share a baseline, so adjacent labels collide when the widest
|
|
14
|
+
* label's width exceeds the anchor spacing.
|
|
15
|
+
* - ROTATED labels are PARALLEL RIBBONS: every label leans at the same angle
|
|
16
|
+
* from its own anchor, so adjacent labels are parallel segments. They clear
|
|
17
|
+
* each other when EITHER the perpendicular separation between the ribbons
|
|
18
|
+
* (`spacing * |sin θ|`) exceeds one line height, OR the offset along the
|
|
19
|
+
* text direction (`spacing * |cos θ|`) exceeds the label length — finite
|
|
20
|
+
* segments slide past each other end-to-end at shallow angles. Earlier
|
|
21
|
+
* revisions modeled rotated labels as 1-D horizontal spans, which reported
|
|
22
|
+
* phantom collisions for long labels (dropping "2025" beside
|
|
23
|
+
* "2026 (to wk 17)") even though the rendered diagonals never touch.
|
|
24
|
+
*
|
|
25
|
+
* The layout planner and computeAxes must agree on these decisions: the
|
|
26
|
+
* planner reserves the bottom margin for the rotated label footprint, and
|
|
27
|
+
* computeAxes emits the same angle. Sharing this module keeps the reserved
|
|
28
|
+
* space matched to the rendered labels (a mismatch spilled rotated ticks into
|
|
29
|
+
* the source line).
|
|
10
30
|
*/
|
|
11
31
|
|
|
12
|
-
|
|
32
|
+
import { TICK_LINE_HEIGHT_FACTOR, X_AXIS_ROTATED_EXTENT_CAP } from '@opendata-ai/openchart-core';
|
|
33
|
+
|
|
34
|
+
/** Band fraction above which flat labels are considered too wide to fit. */
|
|
13
35
|
const BAND_FIT_FRACTION = 0.85;
|
|
14
36
|
|
|
15
|
-
/**
|
|
37
|
+
/** Diagonal angle tried first when flat labels don't fit. */
|
|
16
38
|
const AUTO_ROTATE_ANGLE = -45;
|
|
17
39
|
|
|
40
|
+
/** Vertical angle used when diagonal ribbons would touch. */
|
|
41
|
+
const STEEP_ROTATE_ANGLE = -90;
|
|
42
|
+
|
|
43
|
+
/** Minimum visible gap between adjacent label ribbons, in px. */
|
|
44
|
+
const LABEL_GAP = 4;
|
|
45
|
+
|
|
18
46
|
/**
|
|
19
|
-
*
|
|
47
|
+
* Perpendicular separation between adjacent rotated label ribbons.
|
|
48
|
+
* Labels anchor at points `spacing` apart; leaning at `angleDeg` makes them
|
|
49
|
+
* parallel lines whose perpendicular distance is `spacing * |sin(angle)|`.
|
|
50
|
+
*/
|
|
51
|
+
function ribbonSeparation(spacing: number, angleDeg: number): number {
|
|
52
|
+
return spacing * Math.abs(Math.sin((angleDeg * Math.PI) / 180));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Offset between adjacent labels measured along the text direction.
|
|
57
|
+
* Finite labels clear each other end-to-end when this exceeds the label
|
|
58
|
+
* length, even if the ribbons are closer than a line height — the escape
|
|
59
|
+
* route that matters at shallow angles.
|
|
60
|
+
*/
|
|
61
|
+
function alongTextClearance(spacing: number, angleDeg: number): number {
|
|
62
|
+
return spacing * Math.abs(Math.cos((angleDeg * Math.PI) / 180));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether every label fits at the given rotation without touching. */
|
|
66
|
+
function rotatedLabelsFit(
|
|
67
|
+
spacing: number,
|
|
68
|
+
angleDeg: number,
|
|
69
|
+
fontSize: number,
|
|
70
|
+
maxLabelWidth: number,
|
|
71
|
+
): boolean {
|
|
72
|
+
return (
|
|
73
|
+
ribbonSeparation(spacing, angleDeg) >= fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP ||
|
|
74
|
+
alongTextClearance(spacing, angleDeg) >= maxLabelWidth + LABEL_GAP
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decide the effective x tick angle for a band scale: the shallowest angle in
|
|
80
|
+
* the ladder (flat → -45° → -90°) at which adjacent labels don't touch.
|
|
81
|
+
*
|
|
82
|
+
* The flat trigger compares the widest label against the bandwidth (does the
|
|
83
|
+
* flat label fit its band?) — the long-standing rule, kept for stability.
|
|
84
|
+
* The rotated steps use the parallel-ribbon fit test above.
|
|
85
|
+
*
|
|
86
|
+
* -90° projects the full label length vertically, so labels longer than the
|
|
87
|
+
* reserved-extent cap would clip into the source line. For those the ladder
|
|
88
|
+
* stays at -45° (smaller vertical footprint) and lets the stride safety net
|
|
89
|
+
* thin the ribbons instead.
|
|
20
90
|
*
|
|
21
91
|
* @param explicitAngle - User-specified labelAngle (wins if defined).
|
|
22
92
|
* @param maxLabelWidth - Widest tick label width in pixels.
|
|
23
|
-
* @param bandwidth - Band width in pixels.
|
|
93
|
+
* @param bandwidth - Band width in pixels (excludes inner padding).
|
|
24
94
|
* @param labelCount - Number of category labels (rotation needs >= 2).
|
|
25
|
-
* @
|
|
95
|
+
* @param spacing - Distance between the closest adjacent DRAWN tick anchors.
|
|
96
|
+
* This is the band step when every category gets a tick, but a multiple of
|
|
97
|
+
* it when an explicit tickCount capped the tick set upstream.
|
|
98
|
+
* @param fontSize - Tick label font size (ribbon thickness term).
|
|
99
|
+
* @returns The explicit angle, `-45`, `-90`, or `undefined` (no rotation).
|
|
26
100
|
*/
|
|
27
101
|
export function resolveBandTickAngle(
|
|
28
102
|
explicitAngle: number | undefined,
|
|
29
103
|
maxLabelWidth: number,
|
|
30
104
|
bandwidth: number,
|
|
31
105
|
labelCount: number,
|
|
106
|
+
spacing: number,
|
|
107
|
+
fontSize: number,
|
|
32
108
|
): number | undefined {
|
|
33
109
|
if (explicitAngle !== undefined) return explicitAngle;
|
|
34
110
|
if (labelCount < 2) return undefined;
|
|
35
|
-
|
|
111
|
+
if (maxLabelWidth <= bandwidth * BAND_FIT_FRACTION) return undefined;
|
|
112
|
+
if (rotatedLabelsFit(spacing, AUTO_ROTATE_ANGLE, fontSize, maxLabelWidth)) {
|
|
113
|
+
return AUTO_ROTATE_ANGLE;
|
|
114
|
+
}
|
|
115
|
+
return maxLabelWidth <= X_AXIS_ROTATED_EXTENT_CAP ? STEEP_ROTATE_ANGLE : AUTO_ROTATE_ANGLE;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Uniform every-Nth retention stride for band tick labels — the safety net
|
|
120
|
+
* for when even the resolved angle cannot fit every label (dozens of
|
|
121
|
+
* categories at tiny steps, an explicit shallow labelAngle on a crowded axis,
|
|
122
|
+
* or labels too long for the -90° escalation). Returns 1 (keep every label)
|
|
123
|
+
* whenever labels fit.
|
|
124
|
+
*
|
|
125
|
+
* Callers keep indices counting back from the LAST label
|
|
126
|
+
* (`(lastIdx - i) % stride === 0`) so the most recent category — usually the
|
|
127
|
+
* most editorially important — is always labeled.
|
|
128
|
+
*
|
|
129
|
+
* Flat labels collide along their shared baseline, so the stride is driven by
|
|
130
|
+
* the widest label's width. Rotated labels escape by either route — ribbon
|
|
131
|
+
* separation or along-text clearance — and both scale linearly with the
|
|
132
|
+
* stride, so the cheaper one wins.
|
|
133
|
+
*/
|
|
134
|
+
export function bandLabelStride(
|
|
135
|
+
maxLabelWidth: number,
|
|
136
|
+
angleDeg: number | undefined,
|
|
137
|
+
fontSize: number,
|
|
138
|
+
spacing: number,
|
|
139
|
+
): number {
|
|
140
|
+
if (spacing <= 0) return 1;
|
|
141
|
+
if (angleDeg === undefined || angleDeg === 0) {
|
|
142
|
+
return Math.max(1, Math.ceil((maxLabelWidth + LABEL_GAP) / spacing));
|
|
143
|
+
}
|
|
144
|
+
const separation = ribbonSeparation(spacing, angleDeg);
|
|
145
|
+
const clearance = alongTextClearance(spacing, angleDeg);
|
|
146
|
+
const required = fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP;
|
|
147
|
+
const byRibbon = separation > 0 ? Math.ceil(required / separation) : Number.POSITIVE_INFINITY;
|
|
148
|
+
const byLength =
|
|
149
|
+
clearance > 0 ? Math.ceil((maxLabelWidth + LABEL_GAP) / clearance) : Number.POSITIVE_INFINITY;
|
|
150
|
+
const stride = Math.min(byRibbon, byLength);
|
|
151
|
+
return Number.isFinite(stride) ? Math.max(1, stride) : 1;
|
|
36
152
|
}
|