@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.
- package/dist/index.js +69 -26
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +112 -108
- package/src/__tests__/compile-chart.test.ts +4 -1
- package/src/__tests__/dimensions.test.ts +6 -1
- package/src/charts/bar/__tests__/compute.test.ts +58 -56
- package/src/charts/bar/__tests__/labels.test.ts +3 -2
- package/src/charts/bar/compute.ts +8 -4
- package/src/charts/bar/labels.ts +5 -5
- package/src/charts/column/__tests__/compute.test.ts +22 -21
- package/src/charts/column/compute.ts +8 -3
- package/src/charts/column/labels.ts +7 -4
- package/src/charts/line/__tests__/compute.test.ts +20 -1
- package/src/charts/line/area.ts +12 -2
- package/src/charts/line/index.ts +2 -2
- package/src/endpoint-labels/__tests__/compute.test.ts +5 -1
- package/src/endpoint-labels/compute.ts +4 -2
- package/src/layout/axes/thinning.ts +4 -2
- package/src/layout/dimensions.ts +58 -4
- package/src/layout/scales.ts +8 -6
package/src/layout/dimensions.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
712
|
+
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
659
713
|
const bottomDelta = margins.bottom - newBottom;
|
|
660
714
|
|
|
661
715
|
if (topDelta > 0 || bottomDelta > 0) {
|
package/src/layout/scales.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 &&
|
|
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
|