@opendata-ai/openchart-engine 7.10.0 → 7.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.10.0",
3
+ "version": "7.11.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.10.0",
51
+ "@opendata-ai/openchart-core": "7.11.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
4
4
  import { compileChart } from '../compile';
5
5
  import type { NormalizedChartSpec } from '../compiler/types';
6
6
  import { computeDimensions } from '../layout/dimensions';
7
+ import { INLINE_TICK_OVERHANG_PAD } from '../layout/shared';
7
8
  import { legendGap } from '../legend/wrap';
8
9
 
9
10
  const baseSpec: NormalizedChartSpec = {
@@ -596,16 +597,60 @@ describe('computeDimensions', () => {
596
597
  // topAxisGap). This means the chart area gains back ~axisMargin (6px)
597
598
  // that would otherwise be redundant spacing.
598
599
  //
599
- // The top margin with legend includes: legendHeight(28) + legendGap(8)
600
- // + inlineTickOverhang(17) instead of the no-legend topAxisGap(23).
601
- // Net: margin delta = 28 + 8 + 17 - 23 = 30px.
602
- // If axisMargin were doubling up: 28 + 8 + 23 - 23 = 36px.
600
+ // The top margin with legend includes: gapAbove(6) + legendHeight(28)
601
+ // + legendGap(8) + inlineTickOverhang(17) instead of the no-legend
602
+ // topAxisGap(23). Net: margin delta = 6 + 28 + 8 + 17 - 23 = 36px.
603
+ // If axisMargin were doubling up: 6 + 28 + 8 + 23 - 23 = 42px.
603
604
  const topMarginDelta = dimsWithTopLegend.margins.top - dimsNoLegend.margins.top;
604
605
  expect(topMarginDelta).toBeLessThan(topLegend.bounds.height + legendGap(600) + 1);
605
606
  // With a legend present, the chart area should still be shorter
606
607
  expect(dimsWithTopLegend.chartArea.height).toBeLessThan(dimsNoLegend.chartArea.height);
607
608
  });
608
609
 
610
+ it('returns effectiveAxisGap on the primary return path', () => {
611
+ const overhang = lightTheme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD;
612
+
613
+ // With a top legend only the inline-tick overhang separates legend and
614
+ // chart area (placeLegend consumes this to lift the legend above it).
615
+ const withTopLegend = computeDimensions(
616
+ baseSpec,
617
+ { width: 600, height: 400 },
618
+ topLegend,
619
+ lightTheme,
620
+ );
621
+ expect(withTopLegend.effectiveAxisGap).toBe(overhang);
622
+
623
+ // Without a top legend the full topAxisGap applies.
624
+ const noLegend = computeDimensions(
625
+ baseSpec,
626
+ { width: 600, height: 400 },
627
+ emptyLegend,
628
+ lightTheme,
629
+ );
630
+ expect(noLegend.effectiveAxisGap).toBe(lightTheme.spacing.axisMargin + overhang);
631
+ });
632
+
633
+ it('returns effectiveAxisGap on the chrome-strip guardrail path', () => {
634
+ // A cramped height forces the guardrail early-return with stripped chrome.
635
+ const talkativeSpec: NormalizedChartSpec = {
636
+ ...baseSpec,
637
+ chrome: {
638
+ title: { text: 'A chart title that occupies chrome space' },
639
+ subtitle: { text: 'A subtitle that gets stripped in compact chrome' },
640
+ source: { text: 'Source: somewhere' },
641
+ },
642
+ };
643
+ const dims = computeDimensions(
644
+ talkativeSpec,
645
+ { width: 600, height: 120 },
646
+ topLegend,
647
+ lightTheme,
648
+ );
649
+ // Prove the guardrail fired: compact chrome drops the subtitle.
650
+ expect(dims.chrome.subtitle).toBeUndefined();
651
+ expect(dims.effectiveAxisGap).toBe(lightTheme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD);
652
+ });
653
+
609
654
  it('tightens legend gap on narrow viewports', () => {
610
655
  const wideDims = computeDimensions(
611
656
  baseSpec,
@@ -3,6 +3,7 @@ import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import { compileChart } from '../compile';
5
5
  import type { NormalizedChartSpec } from '../compiler/types';
6
+ import { INLINE_TICK_OVERHANG_PAD } from '../layout/shared';
6
7
  import { computeLegend } from '../legend/compute';
7
8
 
8
9
  const specWithColor: NormalizedChartSpec = {
@@ -510,6 +511,56 @@ describe('computeLegend', () => {
510
511
  const gap = layout.area.y - legendBottom;
511
512
  expect(gap).toBe(0);
512
513
  });
514
+
515
+ // Inline y ticks (line/area) draw their topmost label ABOVE chartArea.y,
516
+ // inside the inlineTickOverhang zone the margin stack reserved. The legend
517
+ // must sit above that zone, not flush against chartArea.y.
518
+ const inlineTopLegendSpec = {
519
+ mark: 'line' as const,
520
+ data: [
521
+ { date: '2020-01-01', value: 10, country: 'US' },
522
+ { date: '2021-01-01', value: 40, country: 'US' },
523
+ { date: '2020-01-01', value: 15, country: 'UK' },
524
+ { date: '2021-01-01', value: 35, country: 'UK' },
525
+ ],
526
+ encoding: {
527
+ x: { field: 'date', type: 'temporal' as const },
528
+ y: { field: 'value', type: 'quantitative' as const },
529
+ color: { field: 'country', type: 'nominal' as const },
530
+ },
531
+ legend: { show: true, position: 'top' as const },
532
+ };
533
+
534
+ it('top legend clears the inline y-tick overhang at compact width', () => {
535
+ const layout = compileChart(inlineTopLegendSpec, { width: 360, height: 400 });
536
+
537
+ expect(layout.legend.position).toBe('top');
538
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
539
+
540
+ const inlineTickOverhang = layout.theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD;
541
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
542
+ expect(legendBottom).toBeLessThanOrEqual(layout.area.y - inlineTickOverhang + 1e-6);
543
+
544
+ // Pinned placement. Before the fix the legend bottom sat flush at
545
+ // chartArea.y (legend bottom was 62 = chartArea.y), overlapping the
546
+ // topmost inline tick label; it now sits the 17px overhang higher,
547
+ // while the 6px minimum gap above the legend shifts chartArea down.
548
+ expect(layout.legend.bounds.y).toBe(31);
549
+ });
550
+
551
+ it('gutter y-ticks keep the legend flush at the legendGap (no overhang shift)', () => {
552
+ // Bar charts use gutter y-tick labels, so inlineTickOverhang is 0 and
553
+ // the legend keeps its legendGap-only offset from the chart area.
554
+ const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
555
+
556
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
557
+ expect(layout.area.y - legendBottom).toBe(8);
558
+
559
+ // Pinned placement: 17 before the placement fix; the +6 comes from the
560
+ // TOP_LEGEND_GAP_ABOVE margin (not from any overhang shift — gutter
561
+ // ticks have no overhang, so the legend-to-chart gap above stays 8).
562
+ expect(layout.legend.bounds.y).toBe(23);
563
+ });
513
564
  });
514
565
 
515
566
  describe('entryPositions contract', () => {
package/src/compile.ts CHANGED
@@ -640,12 +640,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
640
640
  const chartArea = options.frozenChartArea ?? dims.chartArea;
641
641
 
642
642
  // Place legend in its final position relative to the computed chart area.
643
+ // effectiveAxisGap lifts a top legend above the inline-tick overhang zone
644
+ // the margin stack reserved below it.
643
645
  const finalLegend = placeLegend(
644
646
  plan.legendContent,
645
647
  chartArea,
646
648
  options.width,
647
649
  theme,
648
650
  plan.xAxisExtent,
651
+ dims.effectiveAxisGap,
649
652
  );
650
653
 
651
654
  // Inline y-axis labels: inset the x-scale range start by the widest tick
@@ -898,12 +901,14 @@ function compileFaceted(
898
901
  );
899
902
  const chartArea = dims.chartArea;
900
903
 
904
+ // Same effectiveAxisGap threading as the regular (non-faceted) path.
901
905
  const finalLegend = placeLegend(
902
906
  plan.legendContent,
903
907
  chartArea,
904
908
  options.width,
905
909
  theme,
906
910
  plan.xAxisExtent,
911
+ dims.effectiveAxisGap,
907
912
  );
908
913
 
909
914
  // Estimate y-axis gutter width (used as left reservation for outer axes)
package/src/index.ts CHANGED
@@ -112,6 +112,14 @@ export {
112
112
  registerChartRenderer,
113
113
  } from './charts/registry';
114
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Legend geometry
117
+ // ---------------------------------------------------------------------------
118
+
119
+ // Used by the vanilla mount to compute the top-legend block when growing
120
+ // auto-height figures; exporting it keeps the gap math single-sourced.
121
+ export { legendGap } from './legend/wrap';
122
+
115
123
  // ---------------------------------------------------------------------------
116
124
  // Data transforms
117
125
  // ---------------------------------------------------------------------------
@@ -44,7 +44,7 @@ import { format as d3Format } from 'd3-format';
44
44
  import type { NormalizedChartSpec } from '../compiler/types';
45
45
  import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
46
46
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
47
- import { legendGap } from '../legend/wrap';
47
+ import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
48
48
  import { yTickPositionIsInline } from './axes';
49
49
  import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
50
50
  import type { LayoutPlan } from './plan';
@@ -82,6 +82,14 @@ export interface LayoutDimensions {
82
82
  * second legend pass) can position elements below the axis row.
83
83
  */
84
84
  xAxisHeight: number;
85
+ /**
86
+ * The axis gap the margin stack placed between the top legend (or chrome,
87
+ * when there is no top legend) and the chart area. With a top legend this
88
+ * is the inline-tick overhang, and `placeLegend` must receive it as
89
+ * `axisGapBelowLegend` so the legend sits above the reserved tick zone
90
+ * instead of flush against `chartArea.y` (where inline y-tick labels draw).
91
+ */
92
+ effectiveAxisGap: number;
85
93
  }
86
94
 
87
95
  /** Minimum chart area dimensions before guardrails kick in. */
@@ -254,7 +262,15 @@ export function computeDimensions(
254
262
  height: Math.max(0, height - margins.top - margins.bottom),
255
263
  };
256
264
 
257
- return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
265
+ return {
266
+ total,
267
+ chrome,
268
+ chartArea,
269
+ margins,
270
+ theme,
271
+ xAxisHeight: xAxisSpace,
272
+ effectiveAxisGap: 0,
273
+ };
258
274
  }
259
275
 
260
276
  // Start with the total rect
@@ -664,18 +680,23 @@ export function computeDimensions(
664
680
  if (legendPos === 'right' || legendPos === 'bottom-right') {
665
681
  margins.right += legendBoundsWidth + 8;
666
682
  } else if (legendPos === 'top') {
667
- margins.top += legendHeight + gap;
683
+ // TOP_LEGEND_GAP_ABOVE keeps the legend from sitting flush against the
684
+ // subtitle/metric bar now that the axis gap sits BELOW the legend.
685
+ margins.top += TOP_LEGEND_GAP_ABOVE + legendHeight + gap;
668
686
  }
669
687
  // 'bottom' is intentionally not handled here -- see bottomLegendReservation
670
688
  // above.
671
689
  }
672
690
 
673
- // topAxisGap sits between the legend (or chrome, if no legend) and the
674
- // chart area. When a top legend is present, the legendGap already provides
675
- // breathing room, so only the inlineTickOverhang is needed (the axisMargin
676
- // component would double up with legendGap). Without a top legend, the
677
- // full topAxisGap (axisMargin + inlineTickOverhang) applies.
678
- margins.top += hasTopLegend ? inlineTickOverhang : topAxisGap;
691
+ // effectiveAxisGap sits between the legend (or chrome, if no legend) and
692
+ // the chart area. When a top legend is present, the legendGap already
693
+ // provides breathing room, so only the inlineTickOverhang is needed (the
694
+ // axisMargin component would double up with legendGap). Without a top
695
+ // legend, the full topAxisGap (axisMargin + inlineTickOverhang) applies.
696
+ // The value is returned so placeLegend can position the top legend above
697
+ // this reserved zone (inline y-tick labels draw inside it).
698
+ const effectiveAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
699
+ margins.top += effectiveAxisGap;
679
700
 
680
701
  // Chart area is what's left after margins
681
702
  let chartArea: Rect = {
@@ -719,7 +740,10 @@ export function computeDimensions(
719
740
 
720
741
  if (topDelta > 0 || bottomDelta > 0) {
721
742
  const gap = legendGap(width);
722
- margins.top = newTop + (hasTopLegend ? legendHeight + gap : 0) + fallbackEffectiveAxisGap;
743
+ margins.top =
744
+ newTop +
745
+ (hasTopLegend ? TOP_LEGEND_GAP_ABOVE + legendHeight + gap : 0) +
746
+ fallbackEffectiveAxisGap;
723
747
  margins.bottom = newBottom;
724
748
 
725
749
  chartArea = {
@@ -765,6 +789,7 @@ export function computeDimensions(
765
789
  theme,
766
790
  metrics: fallbackMetrics,
767
791
  xAxisHeight,
792
+ effectiveAxisGap: fallbackEffectiveAxisGap,
768
793
  };
769
794
  }
770
795
  }
@@ -773,8 +798,9 @@ export function computeDimensions(
773
798
  // topPad
774
799
  // chrome.topHeight (title / subtitle / eyebrow)
775
800
  // tentativeMetricsHeight (KPI bar — placed here)
776
- // [optional top legend band]
777
- // topAxisGap (axisMargin + inlineTickOverhang)
801
+ // [optional top legend band (TOP_LEGEND_GAP_ABOVE + legend + legendGap)]
802
+ // effectiveAxisGap (inlineTickOverhang with a top legend,
803
+ // else axisMargin + inlineTickOverhang)
778
804
  // chartArea
779
805
  // The metric bar belongs with chrome, above the legend, so its y is
780
806
  // computed off chrome.topHeight only — not the full legend-inclusive
@@ -801,6 +827,7 @@ export function computeDimensions(
801
827
  theme,
802
828
  metrics: resolvedMetrics,
803
829
  xAxisHeight,
830
+ effectiveAxisGap,
804
831
  };
805
832
  }
806
833
 
@@ -32,7 +32,7 @@ import { format as d3Format } from 'd3-format';
32
32
  import type { NormalizedChartSpec } from '../compiler/types';
33
33
  import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
34
34
  import { computeLegendContent, type LegendContent } from '../legend/compute';
35
- import { legendGap } from '../legend/wrap';
35
+ import { legendGap, TOP_LEGEND_GAP_ABOVE } 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';
@@ -395,7 +395,8 @@ export function resolveLayoutPlan(
395
395
  const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
396
396
 
397
397
  let topMargin = topPad + chrome.topHeight;
398
- if (hasTopLegend) topMargin += legendContent.height + gap;
398
+ // Mirrors the dimensions.ts margin stack: min gap above the top legend.
399
+ if (hasTopLegend) topMargin += TOP_LEGEND_GAP_ABOVE + legendContent.height + gap;
399
400
  topMargin += effectiveTopAxisGap;
400
401
 
401
402
  const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);
@@ -494,6 +494,11 @@ export function computeLegendContent(
494
494
  * @param containerWidth - Full container width, used for responsive legend gap.
495
495
  * @param _theme - Resolved theme (reserved for future use).
496
496
  * @param xAxisHeight - Height of the x-axis, used for bottom legend positioning.
497
+ * @param axisGapBelowLegend - The `effectiveAxisGap` the margin stack reserved
498
+ * between a top legend and the chart area (inline y-tick labels draw in that
499
+ * zone, above `chartArea.y`). The top legend is lifted above it so it
500
+ * occupies its reserved slot instead of colliding with the topmost tick
501
+ * label. Defaults to 0 so other callers/positions are unaffected.
497
502
  * @returns Full LegendLayout with bounds.
498
503
  */
499
504
  export function placeLegend(
@@ -502,6 +507,7 @@ export function placeLegend(
502
507
  containerWidth: number,
503
508
  _theme: ResolvedTheme,
504
509
  xAxisHeight: number,
510
+ axisGapBelowLegend: number = 0,
505
511
  ): LegendLayout {
506
512
  const { position, entries, labelStyle, legendWidth, height, offset } = content;
507
513
 
@@ -538,7 +544,7 @@ export function placeLegend(
538
544
  } else {
539
545
  // top
540
546
  boundsX = chartArea.x + offsetDx;
541
- boundsY = chartArea.y - gap - height + offsetDy;
547
+ boundsY = chartArea.y - axisGapBelowLegend - gap - height + offsetDy;
542
548
  }
543
549
 
544
550
  const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
@@ -34,6 +34,14 @@ export const ENTRY_GAP_COMPACT = 10;
34
34
  /** Default gap between legend bounds and chart area. Zero on narrow viewports. */
35
35
  export const LEGEND_GAP = 8;
36
36
 
37
+ /**
38
+ * Minimum gap between the chrome/metrics block above and a top legend.
39
+ * Without it the legend sits flush against the subtitle (compact widths)
40
+ * or the metric bar (all widths) once the inline-tick overhang is placed
41
+ * below the legend where it belongs.
42
+ */
43
+ export const TOP_LEGEND_GAP_ABOVE = 6;
44
+
37
45
  /** Gap between legend and chart area, responsive to container width. */
38
46
  export function legendGap(width: number): number {
39
47
  return width < COMPACT_WIDTH ? 0 : LEGEND_GAP;