@opendata-ai/openchart-engine 7.9.1 → 7.9.2
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 +7 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +32 -0
- package/src/__tests__/dimensions.test.ts +41 -0
- package/src/layout/axes.ts +18 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.2",
|
|
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.9.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.9.2",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -525,6 +525,38 @@ describe('text-aware tick density', () => {
|
|
|
525
525
|
expect(wideAxes.x!.ticks.length).toBe(categories.length);
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
+
it('keeps a short neighbor that a wide final label only barely reaches', () => {
|
|
529
|
+
// Regression (mobile): with a short label whose footprint clears the wide
|
|
530
|
+
// final label by a couple pixels — less than the comfort minGap but with no
|
|
531
|
+
// real overlap — the last-label protection must NOT drop it. At the phone
|
|
532
|
+
// width where "2026 (to wk 17)" sits ~2px from "2025", dropping "2025" to
|
|
533
|
+
// seat the wide endpoint reads as arbitrary. The protection pass keys on
|
|
534
|
+
// true pixel overlap, not the comfort gap, so "2025" survives.
|
|
535
|
+
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
536
|
+
const barSpec: NormalizedChartSpec = {
|
|
537
|
+
...lineSpec,
|
|
538
|
+
markType: 'bar',
|
|
539
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
540
|
+
data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2000, 1600][i] })),
|
|
541
|
+
encoding: {
|
|
542
|
+
x: { field: 'cat', type: 'nominal' },
|
|
543
|
+
y: { field: 'val', type: 'quantitative' },
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Uses default (real) text measurement. At 300px the band centers sit ~56px
|
|
548
|
+
// apart; the year labels project to ~17px at -45° and the wide label to
|
|
549
|
+
// ~54px, leaving "2025" and "2026 (to wk 17)" ~2px apart — a near miss.
|
|
550
|
+
const area = { x: 60, y: 50, width: 300, height: 300 };
|
|
551
|
+
const scales = computeScales(barSpec, area, barSpec.data);
|
|
552
|
+
const axes = computeAxes(scales, area, fullStrategy, theme);
|
|
553
|
+
expect(axes.x!.tickAngle).toBe(-45);
|
|
554
|
+
const kept = axes.x!.ticks.map((t) => t.label);
|
|
555
|
+
expect(kept).toContain('2025');
|
|
556
|
+
expect(kept).toContain('2026 (to wk 17)');
|
|
557
|
+
expect(kept.length).toBe(categories.length);
|
|
558
|
+
});
|
|
559
|
+
|
|
528
560
|
it('drops every neighbor a very wide final label overlaps, not just the nearest', () => {
|
|
529
561
|
// A final category long enough to project across several bands at -45° must
|
|
530
562
|
// clear ALL kept labels its footprint overlaps. The last-label protection
|
|
@@ -829,4 +829,45 @@ describe('bottom legend placement (defect-3 regression)', () => {
|
|
|
829
829
|
);
|
|
830
830
|
});
|
|
831
831
|
});
|
|
832
|
+
|
|
833
|
+
it('keeps the inline y-axis title inside the container on narrow, large-font charts', () => {
|
|
834
|
+
// Regression (mobile): the inline y-tick fix reserved a narrow left margin,
|
|
835
|
+
// but computeAxes still positioned the rotated y-title with the GUTTER
|
|
836
|
+
// offset (tick-label width + viewport floor). On a phone at deck-scale
|
|
837
|
+
// fonts that pushed the title's rotation center to x ≈ -1, clipping the
|
|
838
|
+
// whole title off the left edge. The title offset now honors the inline
|
|
839
|
+
// flag, so titlePosition.x stays a comfortable distance inside x = 0.
|
|
840
|
+
const spec = {
|
|
841
|
+
mark: { type: 'line' as const, point: true },
|
|
842
|
+
data: [
|
|
843
|
+
{ year: '2009-10', pct: 94.2 },
|
|
844
|
+
{ year: '2015-16', pct: 93.8 },
|
|
845
|
+
{ year: '2020-21', pct: 95.0 },
|
|
846
|
+
],
|
|
847
|
+
encoding: {
|
|
848
|
+
x: { field: 'year', type: 'ordinal' as const },
|
|
849
|
+
y: {
|
|
850
|
+
field: 'pct',
|
|
851
|
+
type: 'quantitative' as const,
|
|
852
|
+
axis: { title: 'Kindergartners with 2-dose MMR', grid: true, format: '.0f' },
|
|
853
|
+
scale: { domain: [90, 96], nice: false },
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
theme: { fonts: { sizes: { body: 21, axisTick: 18 } } },
|
|
857
|
+
chrome: { title: 'T', subtitle: 'S', source: 'Src' },
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
for (const width of [320, 360, 390]) {
|
|
861
|
+
const layout = compileChart(spec, { width, height: 600 });
|
|
862
|
+
const yAxis = layout.axes.y;
|
|
863
|
+
expect(yAxis?.tickPosition).toBe('inline');
|
|
864
|
+
const title = yAxis?.titlePosition;
|
|
865
|
+
expect(title).toBeDefined();
|
|
866
|
+
// The title is rotated -90° anchored middle; its glyph box straddles the
|
|
867
|
+
// rotation center by ~half the title font. The left edge must stay on the
|
|
868
|
+
// canvas (x >= 0). Assert the rotation center clears half a glyph.
|
|
869
|
+
const halfGlyph = Math.ceil(layout.theme.fonts.sizes.body / 2);
|
|
870
|
+
expect(title!.x - halfGlyph).toBeGreaterThanOrEqual(0);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
832
873
|
});
|
package/src/layout/axes.ts
CHANGED
|
@@ -365,12 +365,18 @@ function thinBandTicksIfNeeded(
|
|
|
365
365
|
// wide final label (long category name) projects across several bands at an
|
|
366
366
|
// angle, so clear every kept label its footprint overlaps, not just the
|
|
367
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).
|
|
368
374
|
const lastIdx = ticks.length - 1;
|
|
369
375
|
if (!keep[lastIdx]) {
|
|
370
376
|
keep[lastIdx] = true;
|
|
371
377
|
for (let i = lastIdx - 1; i > 0; i--) {
|
|
372
378
|
if (!keep[i]) continue;
|
|
373
|
-
if (spans[i].right
|
|
379
|
+
if (spans[i].right > spans[lastIdx].left) {
|
|
374
380
|
keep[i] = false;
|
|
375
381
|
continue;
|
|
376
382
|
}
|
|
@@ -735,7 +741,17 @@ export function computeAxes(
|
|
|
735
741
|
);
|
|
736
742
|
return Math.max(max, w);
|
|
737
743
|
}, 0);
|
|
738
|
-
|
|
744
|
+
// Inline y-ticks live inside the plot (no left gutter), so the title
|
|
745
|
+
// clears only the chart edge — pass the inline flag so the offset drops
|
|
746
|
+
// the tick-label width and the viewport floor. Omitting it reserved a
|
|
747
|
+
// gutter-sized offset that pushed the rotated title off the left edge of
|
|
748
|
+
// the container on narrow/large-font charts.
|
|
749
|
+
const titleOff = axisTitleOffset(
|
|
750
|
+
maxTickLabelWidth,
|
|
751
|
+
result.y.labelStyle.fontSize,
|
|
752
|
+
totalWidth,
|
|
753
|
+
result.y.tickPosition === 'inline',
|
|
754
|
+
);
|
|
739
755
|
result.y.titlePosition = {
|
|
740
756
|
x: chartArea.x - titleOff,
|
|
741
757
|
y: chartArea.y + chartArea.height / 2,
|