@opendata-ai/openchart-engine 7.0.3 → 7.1.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.
@@ -37,6 +37,7 @@ import {
37
37
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
38
38
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
39
39
  NARROW_VIEWPORT_MAX,
40
+ TICK_LABEL_OFFSET,
40
41
  TOP_PAD_EXTRA_NARROW,
41
42
  } from '@opendata-ai/openchart-core';
42
43
  import { format as d3Format } from 'd3-format';
@@ -93,6 +94,16 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
93
94
  };
94
95
  }
95
96
 
97
+ /**
98
+ * Compute the bottom margin contribution from chrome.
99
+ * chrome.bottomHeight already includes its own padding when it has content
100
+ * (watermark, source, byline, or footer). When zero, fall back to the base
101
+ * padding so the chart area doesn't butt against the container edge.
102
+ */
103
+ function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
104
+ return (bottomHeight > 0 ? bottomHeight : padding) + xAxisHeight;
105
+ }
106
+
96
107
  /**
97
108
  * Scale padding based on the smaller container dimension.
98
109
  * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
@@ -370,7 +381,7 @@ export function computeDimensions(
370
381
  const margins: Margins = {
371
382
  top: topPad + chrome.topHeight + tentativeMetricsHeight,
372
383
  right: hPad + (isRadial ? hPad : axisMargin),
373
- bottom: padding + chrome.bottomHeight + xAxisHeight,
384
+ bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
374
385
  left: hPad + (isRadial ? hPad : axisMargin),
375
386
  };
376
387
 
@@ -580,10 +591,53 @@ export function computeDimensions(
580
591
  }
581
592
 
582
593
  // Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
583
- // Tighter on compact viewports where horizontal space is scarce.
594
+ // The renderer computes a dynamic offset that accounts for wide tick labels (e.g.
595
+ // "$100,000" is ~62px wide and would overlap a fixed 45px offset). We replicate
596
+ // the same formula here so the reserved space matches what the renderer places.
584
597
  const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
585
598
  if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
586
- const axisTitleOffset = getAxisTitleOffset(width);
599
+ // Estimate the widest y-axis tick label width to mirror the renderer's dynamic offset.
600
+ const yFieldForTitle = encoding.y?.field;
601
+ const yAxisFormatForTitle = yAxis?.format as string | undefined;
602
+ let estTickLabelWidth = 0;
603
+ if (
604
+ yFieldForTitle &&
605
+ (encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
606
+ ) {
607
+ let maxAbsValForTitle = 0;
608
+ for (const row of spec.data) {
609
+ const v = Number(row[yFieldForTitle]);
610
+ if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
611
+ }
612
+ let sampleLabelForTitle: string;
613
+ if (yAxisFormatForTitle) {
614
+ try {
615
+ const fmt = d3Format(yAxisFormatForTitle);
616
+ sampleLabelForTitle = fmt(maxAbsValForTitle);
617
+ } catch {
618
+ sampleLabelForTitle = String(maxAbsValForTitle);
619
+ }
620
+ } else {
621
+ if (maxAbsValForTitle >= 1_000_000_000) sampleLabelForTitle = '1.5B';
622
+ else if (maxAbsValForTitle >= 1_000_000) sampleLabelForTitle = '1.5M';
623
+ else if (maxAbsValForTitle >= 1_000) sampleLabelForTitle = '1.5K';
624
+ else if (maxAbsValForTitle >= 100) sampleLabelForTitle = '100';
625
+ else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
626
+ else sampleLabelForTitle = '0.0';
627
+ }
628
+ const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
629
+ estTickLabelWidth = estimateTextWidth(
630
+ negPrefixForTitle + sampleLabelForTitle,
631
+ theme.fonts.sizes.axisTick,
632
+ theme.fonts.weights.normal,
633
+ );
634
+ }
635
+ // 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;
639
+ const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
640
+ const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
587
641
  const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
588
642
  const rotatedLabelMargin =
589
643
  axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
@@ -655,7 +709,7 @@ export function computeDimensions(
655
709
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
656
710
  const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
657
711
  const topDelta = margins.top - newTop;
658
- const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
712
+ const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
659
713
  const bottomDelta = margins.bottom - newBottom;
660
714
 
661
715
  if (topDelta > 0 || bottomDelta > 0) {
@@ -667,12 +667,16 @@ export function computeScales(
667
667
  // Without this, stacked bars would clip past the chart area.
668
668
  let xData = data;
669
669
  let xChannel = encoding.x;
670
- const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
670
+ const xStackEnabled =
671
+ encoding.x.stack === true ||
672
+ encoding.x.stack === 'zero' ||
673
+ encoding.x.stack === 'normalize' ||
674
+ encoding.x.stack === 'center';
671
675
  if (
672
676
  spec.markType === 'bar' &&
673
677
  encoding.color &&
674
678
  encoding.x.type === 'quantitative' &&
675
- !xStackDisabled
679
+ xStackEnabled
676
680
  ) {
677
681
  if (encoding.x.stack === 'normalize') {
678
682
  // Normalize: domain is [0, 1]
@@ -738,9 +742,7 @@ export function computeScales(
738
742
  spec.markType === 'bar' &&
739
743
  (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
740
744
  encoding.y.type === 'quantitative';
741
- // Bar default is stacked, so undefined counts as stacked. Area default is
742
- // overlap (v6), so the stacked-domain expansion only applies when the user
743
- // explicitly opts into stacking.
745
+ // Both bar and area require explicit opt-in for stacked domain expansion.
744
746
  const stackProp = encoding.y.stack;
745
747
  const isExplicitlyStacked =
746
748
  stackProp === true ||
@@ -748,7 +750,7 @@ export function computeScales(
748
750
  stackProp === 'normalize' ||
749
751
  stackProp === 'center';
750
752
  const isAreaStacked = spec.markType === 'area' && isExplicitlyStacked;
751
- const isBarStacked = isVerticalBar && stackProp !== null && stackProp !== false;
753
+ const isBarStacked = isVerticalBar && isExplicitlyStacked;
752
754
 
753
755
  // Sparkline tightening: drop the default `zero: true` baseline so the
754
756
  // y-domain hugs the actual data range. Without this, a series with