@opendata-ai/openchart-engine 7.1.3 → 7.2.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.
@@ -23,6 +23,7 @@ import type {
23
23
  ResolvedTheme,
24
24
  } from '@opendata-ai/openchart-core';
25
25
  import {
26
+ AXIS_TITLE_GAP,
26
27
  AXIS_TITLE_TRAILING_PAD,
27
28
  BREAKPOINT_COMPACT_MAX,
28
29
  computeChrome,
@@ -337,7 +338,8 @@ export function computeDimensions(
337
338
  const labelHeight = Math.min(rotatedHeight, 120);
338
339
  xAxisHeight = hasXAxisLabel ? labelHeight + 20 : labelHeight;
339
340
  } else {
340
- xAxisHeight = hasXAxisLabel ? 48 : 26;
341
+ const base = theme.spacing.xAxisHeight;
342
+ xAxisHeight = hasXAxisLabel ? base + 22 : base;
341
343
  }
342
344
 
343
345
  // Resolve effective y-axis tickPosition early so margin math can account
@@ -633,9 +635,8 @@ export function computeDimensions(
633
635
  );
634
636
  }
635
637
  // Mirror the renderer's dynamic offset formula:
636
- // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + 8px gap
637
- // titleOffset = max(dynamicOffset, AXIS_TITLE_OFFSET_COMPACT)
638
- const AXIS_TITLE_GAP = 8;
638
+ // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + AXIS_TITLE_GAP(14)
639
+ // titleOffset = max(dynamicOffset, getAxisTitleOffset(width))
639
640
  const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
640
641
  const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
641
642
  const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
@@ -658,6 +659,8 @@ export function computeDimensions(
658
659
  // here. The legend lands below the x-axis tick row (which is reserved via
659
660
  // `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
660
661
  // stacks underneath the legend band rather than colliding with it.
662
+ const hasTopLegend =
663
+ 'entries' in legendLayout && legendLayout.entries.length > 0 && legendLayout.position === 'top';
661
664
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
662
665
  const gap = legendGap(width);
663
666
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
@@ -669,9 +672,12 @@ export function computeDimensions(
669
672
  // above.
670
673
  }
671
674
 
672
- // Add topAxisGap after legend so it sits between the legend (or chrome
673
- // when there's no legend) and the chart area.
674
- margins.top += topAxisGap;
675
+ // topAxisGap sits between the legend (or chrome, if no legend) and the
676
+ // chart area. When a top legend is present, the legendGap already provides
677
+ // breathing room, so only the inlineTickOverhang is needed (the axisMargin
678
+ // component would double up with legendGap). Without a top legend, the
679
+ // full topAxisGap (axisMargin + inlineTickOverhang) applies.
680
+ margins.top += hasTopLegend ? inlineTickOverhang : topAxisGap;
675
681
 
676
682
  // Chart area is what's left after margins
677
683
  let chartArea: Rect = {
@@ -707,6 +713,7 @@ export function computeDimensions(
707
713
  // until resolveMetrics decides otherwise).
708
714
  const fallbackTopAxisGap =
709
715
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
716
+ const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
710
717
  const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
711
718
  const topDelta = margins.top - newTop;
712
719
  const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
@@ -721,7 +728,7 @@ export function computeDimensions(
721
728
  legendLayout.position === 'top'
722
729
  ? legendLayout.bounds.height + gap
723
730
  : 0) +
724
- fallbackTopAxisGap;
731
+ fallbackEffectiveAxisGap;
725
732
  margins.bottom = newBottom;
726
733
 
727
734
  chartArea = {
@@ -679,8 +679,17 @@ export function computeScales(
679
679
  xStackEnabled
680
680
  ) {
681
681
  if (encoding.x.stack === 'normalize') {
682
- // Normalize: domain is [0, 1]
683
- xChannel = { ...encoding.x, scale: { ...encoding.x.scale, domain: [0, 1], nice: false } };
682
+ // Normalize: domain is [0, 1], default to percentage axis
683
+ const existingAxis = encoding.x.axis;
684
+ const axis =
685
+ existingAxis === false || existingAxis?.format
686
+ ? existingAxis
687
+ : { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
688
+ xChannel = {
689
+ ...encoding.x,
690
+ scale: { ...encoding.x.scale, domain: [0, 1], nice: false },
691
+ axis,
692
+ };
684
693
  } else if (encoding.x.stack === 'center') {
685
694
  // Center: compute max half-sum for symmetric domain
686
695
  const yField = encoding.y?.field;
@@ -785,8 +794,17 @@ export function computeScales(
785
794
  }
786
795
  if ((isBarStacked || isAreaStacked) && encoding.color && encoding.y.type === 'quantitative') {
787
796
  if (encoding.y.stack === 'normalize') {
788
- // Normalize: domain is [0, 1] (VL convention)
789
- yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
797
+ // Normalize: domain is [0, 1] (VL convention), default to percentage axis
798
+ const existingAxis = encoding.y.axis;
799
+ const axis =
800
+ existingAxis === false || existingAxis?.format
801
+ ? existingAxis
802
+ : { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
803
+ yChannel = {
804
+ ...encoding.y,
805
+ scale: { ...encoding.y.scale, domain: [0, 1], nice: false },
806
+ axis,
807
+ };
790
808
  } else if (encoding.y.stack === 'center') {
791
809
  // Center: compute max half-sum for symmetric domain
792
810
  const xField = encoding.x?.field;