@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.9.1",
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.1",
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
  });
@@ -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 + minGap > spans[lastIdx].left) {
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
- const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
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,