@classic-homes/charts-core 0.3.2 → 0.4.1

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.cjs CHANGED
@@ -624,6 +624,237 @@ function mergeChartOption(generated, override) {
624
624
  return merged;
625
625
  }
626
626
 
627
+ // src/utils/stats.ts
628
+ var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
629
+ var clean = (value) => Number(value.toPrecision(12));
630
+ function niceStep(rough) {
631
+ if (!(rough > 0) || !Number.isFinite(rough)) return 1;
632
+ const power = 10 ** Math.floor(Math.log10(rough));
633
+ const fraction = rough / power;
634
+ const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10;
635
+ return clean(nice * power);
636
+ }
637
+ function bucketLabel(start, end, format) {
638
+ const text = (value) => formatValue(value, format);
639
+ if (start === -Infinity && end === Infinity) return "All";
640
+ if (start === -Infinity) return `< ${text(end)}`;
641
+ if (end === Infinity) return `${text(start)}+`;
642
+ return `${text(start)}\u2013${text(end)}`;
643
+ }
644
+ function binValues(values, options = {}) {
645
+ const { bins, binWidth, format } = options;
646
+ const finite = values.filter(isFiniteNumber);
647
+ let edges;
648
+ if (Array.isArray(bins)) {
649
+ edges = bins.map((bin) => ({
650
+ start: bin.start ?? -Infinity,
651
+ end: bin.end ?? Infinity,
652
+ label: bin.label
653
+ }));
654
+ } else {
655
+ if (!finite.length) return [];
656
+ let min = Infinity;
657
+ let max = -Infinity;
658
+ for (const value of finite) {
659
+ if (value < min) min = value;
660
+ if (value > max) max = value;
661
+ }
662
+ if (min === max) {
663
+ edges = [{ start: min, end: max }];
664
+ } else {
665
+ let step;
666
+ let start;
667
+ if (binWidth && binWidth > 0) {
668
+ step = binWidth;
669
+ start = Math.floor(min / step) * step;
670
+ } else if (typeof bins === "number" && bins > 0) {
671
+ step = (max - min) / Math.floor(bins);
672
+ start = min;
673
+ } else {
674
+ const sturges = Math.ceil(Math.log2(finite.length) + 1);
675
+ step = niceStep((max - min) / sturges);
676
+ start = Math.floor(min / step) * step;
677
+ }
678
+ const count = Math.max(1, Math.ceil(clean((max - start) / step)));
679
+ edges = Array.from({ length: count }, (_, index) => ({
680
+ start: clean(start + index * step),
681
+ end: clean(start + (index + 1) * step)
682
+ }));
683
+ }
684
+ }
685
+ const buckets = edges.map(({ start, end, label }) => ({
686
+ label: label ?? bucketLabel(start, end, format),
687
+ start,
688
+ end,
689
+ count: 0
690
+ }));
691
+ const last = buckets.length - 1;
692
+ for (const value of finite) {
693
+ const index = buckets.findIndex(
694
+ (bucket, i) => value >= bucket.start && (value < bucket.end || i === last && value <= bucket.end)
695
+ );
696
+ if (index !== -1) buckets[index].count++;
697
+ }
698
+ return buckets;
699
+ }
700
+ function quantile(sorted, q) {
701
+ if (!sorted.length) return NaN;
702
+ const position = (sorted.length - 1) * q;
703
+ const lower = Math.floor(position);
704
+ const upper = Math.ceil(position);
705
+ if (lower === upper) return sorted[lower];
706
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
707
+ }
708
+ function boxplotStats(values) {
709
+ const sorted = values.filter(isFiniteNumber).sort((a, b) => a - b);
710
+ if (!sorted.length) return null;
711
+ const q1 = quantile(sorted, 0.25);
712
+ const median = quantile(sorted, 0.5);
713
+ const q3 = quantile(sorted, 0.75);
714
+ const iqr = q3 - q1;
715
+ const lowFence = q1 - 1.5 * iqr;
716
+ const highFence = q3 + 1.5 * iqr;
717
+ const inside = sorted.filter((value) => value >= lowFence && value <= highFence);
718
+ const outliers = sorted.filter((value) => value < lowFence || value > highFence);
719
+ return {
720
+ low: inside[0] ?? q1,
721
+ q1,
722
+ median,
723
+ q3,
724
+ high: inside[inside.length - 1] ?? q3,
725
+ outliers,
726
+ count: sorted.length,
727
+ mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length
728
+ };
729
+ }
730
+ function waterfallLayout(steps) {
731
+ let running = 0;
732
+ return steps.map((step) => {
733
+ let start;
734
+ let end;
735
+ let kind;
736
+ if (step.isTotal === "value") {
737
+ start = 0;
738
+ end = isFiniteNumber(step.value) ? step.value : running;
739
+ kind = "total";
740
+ } else if (step.isTotal) {
741
+ start = 0;
742
+ end = running;
743
+ kind = "total";
744
+ } else {
745
+ const change = isFiniteNumber(step.value) ? step.value : 0;
746
+ start = running;
747
+ end = running + change;
748
+ kind = change < 0 ? "decrease" : "increase";
749
+ }
750
+ running = end;
751
+ const low = Math.min(start, end);
752
+ const high = Math.max(start, end);
753
+ let base = 0;
754
+ let positive = 0;
755
+ let negative = 0;
756
+ if (low >= 0) {
757
+ base = low;
758
+ positive = high - low;
759
+ } else if (high <= 0) {
760
+ base = high;
761
+ negative = low - high;
762
+ } else {
763
+ positive = high;
764
+ negative = low;
765
+ }
766
+ return {
767
+ start,
768
+ end,
769
+ value: kind === "total" ? end : end - start,
770
+ kind,
771
+ base: clean(base),
772
+ positive: clean(positive),
773
+ negative: clean(negative)
774
+ };
775
+ });
776
+ }
777
+ function toPercentStack(series) {
778
+ const length = Math.max(0, ...series.map((s) => s.data.length));
779
+ const totals = Array.from(
780
+ { length },
781
+ (_, index) => series.reduce((sum, s) => {
782
+ const value = s.data[index];
783
+ return sum + (isFiniteNumber(value) ? Math.abs(value) : 0);
784
+ }, 0)
785
+ );
786
+ return series.map((s) => ({
787
+ ...s,
788
+ data: s.data.map((value, index) => {
789
+ if (!isFiniteNumber(value)) return null;
790
+ return totals[index] === 0 ? 0 : clean(value / totals[index] * 100);
791
+ })
792
+ }));
793
+ }
794
+ function stackTotals(series, stacked) {
795
+ const length = Math.max(0, ...series.map((s) => s.data.length));
796
+ const totals = /* @__PURE__ */ new Map();
797
+ for (const s of series) {
798
+ const stack = s.stack ?? (stacked ? "Total" : void 0);
799
+ if (stack === void 0) continue;
800
+ const sums = totals.get(stack) ?? Array.from({ length }, () => null);
801
+ s.data.forEach((value, index) => {
802
+ if (isFiniteNumber(value)) sums[index] = clean((sums[index] ?? 0) + value);
803
+ });
804
+ totals.set(stack, sums);
805
+ }
806
+ return totals;
807
+ }
808
+ function pairWithCategories(categories, values, axisType) {
809
+ if (axisType !== "time" && axisType !== "value") return values;
810
+ return values.map((value, index) => [categories[index], value]);
811
+ }
812
+ function resolveChartHeight(height, rows, { rowHeight = 28, chrome = 56, minHeight = 160, maxHeight } = {}) {
813
+ if (height !== "auto") return height;
814
+ let value = Math.max(minHeight, chrome + Math.max(rows, 1) * rowHeight);
815
+ if (maxHeight !== void 0) value = Math.min(value, maxHeight);
816
+ return value;
817
+ }
818
+ function allSeriesEmpty(series) {
819
+ return !series?.length || series.every((s) => !s.data?.some((value) => value != null));
820
+ }
821
+ function trendLine(points) {
822
+ const finite = points.filter(([x, y]) => isFiniteNumber(x) && isFiniteNumber(y));
823
+ const n = finite.length;
824
+ if (n < 2) return null;
825
+ let sumX = 0;
826
+ let sumY = 0;
827
+ let sumXY = 0;
828
+ let sumX2 = 0;
829
+ let minX = Infinity;
830
+ let maxX = -Infinity;
831
+ for (const [x, y] of finite) {
832
+ sumX += x;
833
+ sumY += y;
834
+ sumXY += x * y;
835
+ sumX2 += x * x;
836
+ if (x < minX) minX = x;
837
+ if (x > maxX) maxX = x;
838
+ }
839
+ const denominator = n * sumX2 - sumX * sumX;
840
+ if (minX === maxX || denominator === 0) return null;
841
+ const slope = (n * sumXY - sumX * sumY) / denominator;
842
+ const intercept = (sumY - slope * sumX) / n;
843
+ return [
844
+ [minX, slope * minX + intercept],
845
+ [maxX, slope * maxX + intercept]
846
+ ];
847
+ }
848
+ function computeDelta(current, previous) {
849
+ if (!isFiniteNumber(current) || !isFiniteNumber(previous)) return null;
850
+ const change = clean(current - previous);
851
+ return {
852
+ change,
853
+ ratio: previous === 0 ? null : change / Math.abs(previous),
854
+ direction: change > 0 ? "up" : change < 0 ? "down" : "flat"
855
+ };
856
+ }
857
+
627
858
  // src/utils/options.ts
628
859
  function toParams(raw, getLabel) {
629
860
  const seriesIndex = raw.seriesIndex ?? 0;
@@ -908,222 +1139,40 @@ function withZoomInset(inset, mode, axis) {
908
1139
  const current = typeof inset?.[edge] === "number" ? inset[edge] : 12;
909
1140
  return { ...inset, [edge]: current + 36 };
910
1141
  }
911
-
912
- // src/utils/stats.ts
913
- var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
914
- var clean = (value) => Number(value.toPrecision(12));
915
- function niceStep(rough) {
916
- if (!(rough > 0) || !Number.isFinite(rough)) return 1;
917
- const power = 10 ** Math.floor(Math.log10(rough));
918
- const fraction = rough / power;
919
- const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10;
920
- return clean(nice * power);
921
- }
922
- function bucketLabel(start, end, format) {
923
- const text = (value) => formatValue(value, format);
924
- if (start === -Infinity && end === Infinity) return "All";
925
- if (start === -Infinity) return `< ${text(end)}`;
926
- if (end === Infinity) return `${text(start)}+`;
927
- return `${text(start)}\u2013${text(end)}`;
928
- }
929
- function binValues(values, options = {}) {
930
- const { bins, binWidth, format } = options;
931
- const finite = values.filter(isFiniteNumber);
932
- let edges;
933
- if (Array.isArray(bins)) {
934
- edges = bins.map((bin) => ({
935
- start: bin.start ?? -Infinity,
936
- end: bin.end ?? Infinity,
937
- label: bin.label
938
- }));
939
- } else {
940
- if (!finite.length) return [];
941
- let min = Infinity;
942
- let max = -Infinity;
943
- for (const value of finite) {
944
- if (value < min) min = value;
945
- if (value > max) max = value;
946
- }
947
- if (min === max) {
948
- edges = [{ start: min, end: max }];
949
- } else {
950
- let step;
951
- let start;
952
- if (binWidth && binWidth > 0) {
953
- step = binWidth;
954
- start = Math.floor(min / step) * step;
955
- } else if (typeof bins === "number" && bins > 0) {
956
- step = (max - min) / Math.floor(bins);
957
- start = min;
958
- } else {
959
- const sturges = Math.ceil(Math.log2(finite.length) + 1);
960
- step = niceStep((max - min) / sturges);
961
- start = Math.floor(min / step) * step;
962
- }
963
- const count = Math.max(1, Math.ceil(clean((max - start) / step)));
964
- edges = Array.from({ length: count }, (_, index) => ({
965
- start: clean(start + index * step),
966
- end: clean(start + (index + 1) * step)
967
- }));
968
- }
969
- }
970
- const buckets = edges.map(({ start, end, label }) => ({
971
- label: label ?? bucketLabel(start, end, format),
972
- start,
973
- end,
974
- count: 0
975
- }));
976
- const last = buckets.length - 1;
977
- for (const value of finite) {
978
- const index = buckets.findIndex(
979
- (bucket, i) => value >= bucket.start && (value < bucket.end || i === last && value <= bucket.end)
980
- );
981
- if (index !== -1) buckets[index].count++;
982
- }
983
- return buckets;
984
- }
985
- function quantile(sorted, q) {
986
- if (!sorted.length) return NaN;
987
- const position = (sorted.length - 1) * q;
988
- const lower = Math.floor(position);
989
- const upper = Math.ceil(position);
990
- if (lower === upper) return sorted[lower];
991
- return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
992
- }
993
- function boxplotStats(values) {
994
- const sorted = values.filter(isFiniteNumber).sort((a, b) => a - b);
995
- if (!sorted.length) return null;
996
- const q1 = quantile(sorted, 0.25);
997
- const median = quantile(sorted, 0.5);
998
- const q3 = quantile(sorted, 0.75);
999
- const iqr = q3 - q1;
1000
- const lowFence = q1 - 1.5 * iqr;
1001
- const highFence = q3 + 1.5 * iqr;
1002
- const inside = sorted.filter((value) => value >= lowFence && value <= highFence);
1003
- const outliers = sorted.filter((value) => value < lowFence || value > highFence);
1004
- return {
1005
- low: inside[0] ?? q1,
1006
- q1,
1007
- median,
1008
- q3,
1009
- high: inside[inside.length - 1] ?? q3,
1010
- outliers,
1011
- count: sorted.length,
1012
- mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length
1013
- };
1014
- }
1015
- function waterfallLayout(steps) {
1016
- let running = 0;
1017
- return steps.map((step) => {
1018
- let start;
1019
- let end;
1020
- let kind;
1021
- if (step.isTotal === "value") {
1022
- start = 0;
1023
- end = isFiniteNumber(step.value) ? step.value : running;
1024
- kind = "total";
1025
- } else if (step.isTotal) {
1026
- start = 0;
1027
- end = running;
1028
- kind = "total";
1029
- } else {
1030
- const change = isFiniteNumber(step.value) ? step.value : 0;
1031
- start = running;
1032
- end = running + change;
1033
- kind = change < 0 ? "decrease" : "increase";
1034
- }
1035
- running = end;
1036
- const low = Math.min(start, end);
1037
- const high = Math.max(start, end);
1038
- let base = 0;
1039
- let positive = 0;
1040
- let negative = 0;
1041
- if (low >= 0) {
1042
- base = low;
1043
- positive = high - low;
1044
- } else if (high <= 0) {
1045
- base = high;
1046
- negative = low - high;
1047
- } else {
1048
- positive = high;
1049
- negative = low;
1142
+ function buildStackTotalSeries(categories, series, { stacked, position, format, axisType }) {
1143
+ return [...stackTotals(series, stacked)].map(([stack, totals]) => ({
1144
+ id: `__total:${stack}`,
1145
+ type: "bar",
1146
+ stack,
1147
+ data: pairWithCategories(
1148
+ categories,
1149
+ totals.map((total) => total === null ? null : 0),
1150
+ axisType
1151
+ ),
1152
+ silent: true,
1153
+ legendHoverLink: false,
1154
+ tooltip: { show: false },
1155
+ itemStyle: { color: "transparent" },
1156
+ emphasis: { disabled: true },
1157
+ label: {
1158
+ show: true,
1159
+ position,
1160
+ fontWeight: "bold",
1161
+ formatter: (params) => formatValue(totals[params.dataIndex], format)
1050
1162
  }
1051
- return {
1052
- start,
1053
- end,
1054
- value: kind === "total" ? end : end - start,
1055
- kind,
1056
- base: clean(base),
1057
- positive: clean(positive),
1058
- negative: clean(negative)
1059
- };
1060
- });
1061
- }
1062
- function toPercentStack(series) {
1063
- const length = Math.max(0, ...series.map((s) => s.data.length));
1064
- const totals = Array.from(
1065
- { length },
1066
- (_, index) => series.reduce((sum, s) => {
1067
- const value = s.data[index];
1068
- return sum + (isFiniteNumber(value) ? Math.abs(value) : 0);
1069
- }, 0)
1070
- );
1071
- return series.map((s) => ({
1072
- ...s,
1073
- data: s.data.map((value, index) => {
1074
- if (!isFiniteNumber(value)) return null;
1075
- return totals[index] === 0 ? 0 : clean(value / totals[index] * 100);
1076
- })
1077
1163
  }));
1078
1164
  }
1079
- function pairWithCategories(categories, values, axisType) {
1080
- if (axisType !== "time" && axisType !== "value") return values;
1081
- return values.map((value, index) => [categories[index], value]);
1082
- }
1083
- function resolveChartHeight(height, rows, { rowHeight = 28, chrome = 56, minHeight = 160, maxHeight } = {}) {
1084
- if (height !== "auto") return height;
1085
- let value = Math.max(minHeight, chrome + Math.max(rows, 1) * rowHeight);
1086
- if (maxHeight !== void 0) value = Math.min(value, maxHeight);
1087
- return value;
1088
- }
1089
- function allSeriesEmpty(series) {
1090
- return !series?.length || series.every((s) => !s.data?.some((value) => value != null));
1091
- }
1092
- function trendLine(points) {
1093
- const finite = points.filter(([x, y]) => isFiniteNumber(x) && isFiniteNumber(y));
1094
- const n = finite.length;
1095
- if (n < 2) return null;
1096
- let sumX = 0;
1097
- let sumY = 0;
1098
- let sumXY = 0;
1099
- let sumX2 = 0;
1100
- let minX = Infinity;
1101
- let maxX = -Infinity;
1102
- for (const [x, y] of finite) {
1103
- sumX += x;
1104
- sumY += y;
1105
- sumXY += x * y;
1106
- sumX2 += x * x;
1107
- if (x < minX) minX = x;
1108
- if (x > maxX) maxX = x;
1109
- }
1110
- const denominator = n * sumX2 - sumX * sumX;
1111
- if (minX === maxX || denominator === 0) return null;
1112
- const slope = (n * sumXY - sumX * sumY) / denominator;
1113
- const intercept = (sumY - slope * sumX) / n;
1114
- return [
1115
- [minX, slope * minX + intercept],
1116
- [maxX, slope * maxX + intercept]
1117
- ];
1165
+ function barCornerRadius(horizontal, stackIds, index, radius = 4) {
1166
+ const stack = stackIds[index];
1167
+ const outer = stack === void 0 || stackIds.lastIndexOf(stack) === index;
1168
+ if (!outer) return [0, 0, 0, 0];
1169
+ return horizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0];
1118
1170
  }
1119
- function computeDelta(current, previous) {
1120
- if (!isFiniteNumber(current) || !isFiniteNumber(previous)) return null;
1121
- const change = clean(current - previous);
1122
- return {
1123
- change,
1124
- ratio: previous === 0 ? null : change / Math.abs(previous),
1125
- direction: change > 0 ? "up" : change < 0 ? "down" : "flat"
1126
- };
1171
+ function withEndLabelInset(inset, labels) {
1172
+ const longest = Math.max(0, ...labels.map((label) => label.length));
1173
+ if (longest === 0) return inset;
1174
+ const current = typeof inset?.right === "number" ? inset.right : 0;
1175
+ return { ...inset, right: current + longest * 7 + 16 };
1127
1176
  }
1128
1177
 
1129
1178
  // src/utils/records.ts
@@ -1518,6 +1567,7 @@ exports.THEME_LIGHT = THEME_LIGHT;
1518
1567
  exports.abbreviateNumber = abbreviateNumber;
1519
1568
  exports.allSeriesEmpty = allSeriesEmpty;
1520
1569
  exports.anchorCenterGraphic = anchorCenterGraphic;
1570
+ exports.barCornerRadius = barCornerRadius;
1521
1571
  exports.binValues = binValues;
1522
1572
  exports.boxPlotTable = boxPlotTable;
1523
1573
  exports.boxplotStats = boxplotStats;
@@ -1527,6 +1577,7 @@ exports.buildDataZoom = buildDataZoom;
1527
1577
  exports.buildLegend = buildLegend;
1528
1578
  exports.buildMarkArea = buildMarkArea;
1529
1579
  exports.buildMarkLine = buildMarkLine;
1580
+ exports.buildStackTotalSeries = buildStackTotalSeries;
1530
1581
  exports.buildTooltip = buildTooltip;
1531
1582
  exports.bulletTable = bulletTable;
1532
1583
  exports.candlestickTable = candlestickTable;
@@ -1587,6 +1638,7 @@ exports.sankeyTable = sankeyTable;
1587
1638
  exports.scatterTable = scatterTable;
1588
1639
  exports.seriesLabelLookup = seriesLabelLookup;
1589
1640
  exports.sliceTable = sliceTable;
1641
+ exports.stackTotals = stackTotals;
1590
1642
  exports.tableToCsv = tableToCsv;
1591
1643
  exports.toPercentStack = toPercentStack;
1592
1644
  exports.toSliceData = toSliceData;
@@ -1596,6 +1648,7 @@ exports.valueAxisConfig = valueAxisConfig;
1596
1648
  exports.valueLabelFormatter = valueLabelFormatter;
1597
1649
  exports.waterfallLayout = waterfallLayout;
1598
1650
  exports.waterfallTable = waterfallTable;
1651
+ exports.withEndLabelInset = withEndLabelInset;
1599
1652
  exports.withPointLabels = withPointLabels;
1600
1653
  exports.withReferenceMarks = withReferenceMarks;
1601
1654
  exports.withZoomInset = withZoomInset;
package/dist/index.d.cts CHANGED
@@ -350,6 +350,38 @@ bottomOffset?: number): AnyObject[] | undefined;
350
350
  * Grid room for a data zoom slider, added to a `gridInset` result
351
351
  */
352
352
  declare function withZoomInset(inset: AnyObject | undefined, mode: boolean | 'inside' | 'slider' | undefined, axis: 'x' | 'y'): AnyObject | undefined;
353
+ interface StackTotalOptions {
354
+ /** The chart's `stacked` prop */
355
+ stacked: boolean | 'percent' | undefined;
356
+ /** Where the label sits: 'right' for horizontal bars, 'top' for vertical ones */
357
+ position: 'right' | 'top';
358
+ /** Formats each total (the resolved value format) */
359
+ format?: (value: number) => string;
360
+ /** The category axis type, for time or value axes that read [x, y] points */
361
+ axisType?: string;
362
+ }
363
+ /**
364
+ * Invisible series that label each stack's total at the end of the bar: a
365
+ * zero-height bar on top of every stack, carrying the total as its label.
366
+ * They are silent and left out of the tooltip; the legend lists only the
367
+ * real series.
368
+ */
369
+ declare function buildStackTotalSeries(categories: (string | number)[], series: {
370
+ data: (number | null)[];
371
+ stack?: string;
372
+ }[], { stacked, position, format, axisType }: StackTotalOptions): AnyObject[];
373
+ /**
374
+ * Corner radius for one bar series. The theme rounds the top of vertical
375
+ * bars; horizontal bars round their right end instead. In a stack only the
376
+ * last series (the segment at the bar's end) is rounded.
377
+ */
378
+ declare function barCornerRadius(horizontal: boolean, stackIds: (string | undefined)[], index: number, radius?: number): number[];
379
+ /**
380
+ * Widen the grid's right edge for labels drawn past the end of horizontal
381
+ * bars, which `containLabel` does not reserve room for. Width is estimated
382
+ * from the longest label.
383
+ */
384
+ declare function withEndLabelInset(inset: AnyObject | undefined, labels: string[]): AnyObject | undefined;
353
385
 
354
386
  /**
355
387
  * Data preparation for distribution, waterfall and stacked charts, and
@@ -417,6 +449,16 @@ declare function waterfallLayout(steps: WaterfallStep[]): WaterfallBar[];
417
449
  declare function toPercentStack<S extends {
418
450
  data: (number | null)[];
419
451
  }>(series: S[]): S[];
452
+ /**
453
+ * Each stack's total by category, keyed by stack id. A series stacks under
454
+ * its own `stack` id, or 'Total' when `stacked` is set; unstacked series are
455
+ * left out. Totals are the raw values, also for percent stacks. A category
456
+ * with no values in a stack gets `null`.
457
+ */
458
+ declare function stackTotals(series: {
459
+ data: (number | null)[];
460
+ stack?: string;
461
+ }[], stacked: boolean | 'percent' | undefined): Map<string, (number | null)[]>;
420
462
  /**
421
463
  * Pair category labels with values for a time or value x-axis, which reads
422
464
  * [x, y] points rather than values by category index
@@ -631,4 +673,4 @@ declare function downloadFile(content: string, filename: string, mimeType?: stri
631
673
  */
632
674
  declare function downloadCsv(table: ChartDataTable, filename: string): void;
633
675
 
634
- export { type Accessor, type Aggregate, type AutoHeightOptions, AxisConfig, BarChartData, type BinValuesOptions, BoxPlotStats, type BuildDataLabelOptions, type BuildLegendOptions, type BuildTooltipOptions, BulletItem, CandlestickData, CategoryChartData, CategorySeriesData, ChartDataTable, ChartFormatter, type ChartFormatterOptions, type ChartFormatters, ChartLegendConfig, type Delta, type FromRecordsOptions, HeatmapData, HistogramBin, HistogramBucket, LabelFormatterParams, LegendItem, LineChartData, PieSliceData, type PointLabelLookup, RadarChartData, RadarIndicator, ReferenceBand, ReferenceLine, type ReferenceMarkOptions, SankeyData, ScatterChartData, type ToSliceDataOptions, TooltipFormatterParams, TreemapNode, ValueFormat, type WaterfallBar, WaterfallStep, abbreviateNumber, allSeriesEmpty, anchorCenterGraphic, binValues, boxPlotTable, boxplotStats, buildAxis, buildDataLabel, buildDataZoom, buildLegend, buildMarkArea, buildMarkLine, buildTooltip, bulletTable, candlestickTable, categoryTable, chartFilename, chartFormatters, computeDelta, createTooltipFormatter, downloadCsv, downloadFile, escapeHtml, formatCurrency, formatDate, formatNumber, formatPercent, formatTableCell, formatTooltipValue, formatValue, fromRecords, generateAreaChartDescription, generateBarChartDescription, generateBoxPlotDescription, generateBulletDescription, generateComboChartDescription, generateDataTable, generateDotPlotDescription, generateHistogramDescription, generateLineChartDescription, generatePieChartDescription, generateScatterChartDescription, generateWaterfallDescription, getLegendItems, getReplaceMerge, gridInset, hasPointLabels, heatmapTable, histogramTable, itemLabelLookup, legendCenter, mergeChartOption, nameLabelLookup, niceStep, pairWithCategories, pointLabel, quantile, radarTable, resolveChartHeight, resolveValueFormatter, sankeyTable, scatterTable, seriesLabelLookup, sliceTable, tableToCsv, toPercentStack, toSliceData, treemapTable, trendLine, valueAxisConfig, valueLabelFormatter, waterfallLayout, waterfallTable, withPointLabels, withReferenceMarks, withZoomInset };
676
+ export { type Accessor, type Aggregate, type AutoHeightOptions, AxisConfig, BarChartData, type BinValuesOptions, BoxPlotStats, type BuildDataLabelOptions, type BuildLegendOptions, type BuildTooltipOptions, BulletItem, CandlestickData, CategoryChartData, CategorySeriesData, ChartDataTable, ChartFormatter, type ChartFormatterOptions, type ChartFormatters, ChartLegendConfig, type Delta, type FromRecordsOptions, HeatmapData, HistogramBin, HistogramBucket, LabelFormatterParams, LegendItem, LineChartData, PieSliceData, type PointLabelLookup, RadarChartData, RadarIndicator, ReferenceBand, ReferenceLine, type ReferenceMarkOptions, SankeyData, ScatterChartData, type StackTotalOptions, type ToSliceDataOptions, TooltipFormatterParams, TreemapNode, ValueFormat, type WaterfallBar, WaterfallStep, abbreviateNumber, allSeriesEmpty, anchorCenterGraphic, barCornerRadius, binValues, boxPlotTable, boxplotStats, buildAxis, buildDataLabel, buildDataZoom, buildLegend, buildMarkArea, buildMarkLine, buildStackTotalSeries, buildTooltip, bulletTable, candlestickTable, categoryTable, chartFilename, chartFormatters, computeDelta, createTooltipFormatter, downloadCsv, downloadFile, escapeHtml, formatCurrency, formatDate, formatNumber, formatPercent, formatTableCell, formatTooltipValue, formatValue, fromRecords, generateAreaChartDescription, generateBarChartDescription, generateBoxPlotDescription, generateBulletDescription, generateComboChartDescription, generateDataTable, generateDotPlotDescription, generateHistogramDescription, generateLineChartDescription, generatePieChartDescription, generateScatterChartDescription, generateWaterfallDescription, getLegendItems, getReplaceMerge, gridInset, hasPointLabels, heatmapTable, histogramTable, itemLabelLookup, legendCenter, mergeChartOption, nameLabelLookup, niceStep, pairWithCategories, pointLabel, quantile, radarTable, resolveChartHeight, resolveValueFormatter, sankeyTable, scatterTable, seriesLabelLookup, sliceTable, stackTotals, tableToCsv, toPercentStack, toSliceData, treemapTable, trendLine, valueAxisConfig, valueLabelFormatter, waterfallLayout, waterfallTable, withEndLabelInset, withPointLabels, withReferenceMarks, withZoomInset };
package/dist/index.d.ts CHANGED
@@ -350,6 +350,38 @@ bottomOffset?: number): AnyObject[] | undefined;
350
350
  * Grid room for a data zoom slider, added to a `gridInset` result
351
351
  */
352
352
  declare function withZoomInset(inset: AnyObject | undefined, mode: boolean | 'inside' | 'slider' | undefined, axis: 'x' | 'y'): AnyObject | undefined;
353
+ interface StackTotalOptions {
354
+ /** The chart's `stacked` prop */
355
+ stacked: boolean | 'percent' | undefined;
356
+ /** Where the label sits: 'right' for horizontal bars, 'top' for vertical ones */
357
+ position: 'right' | 'top';
358
+ /** Formats each total (the resolved value format) */
359
+ format?: (value: number) => string;
360
+ /** The category axis type, for time or value axes that read [x, y] points */
361
+ axisType?: string;
362
+ }
363
+ /**
364
+ * Invisible series that label each stack's total at the end of the bar: a
365
+ * zero-height bar on top of every stack, carrying the total as its label.
366
+ * They are silent and left out of the tooltip; the legend lists only the
367
+ * real series.
368
+ */
369
+ declare function buildStackTotalSeries(categories: (string | number)[], series: {
370
+ data: (number | null)[];
371
+ stack?: string;
372
+ }[], { stacked, position, format, axisType }: StackTotalOptions): AnyObject[];
373
+ /**
374
+ * Corner radius for one bar series. The theme rounds the top of vertical
375
+ * bars; horizontal bars round their right end instead. In a stack only the
376
+ * last series (the segment at the bar's end) is rounded.
377
+ */
378
+ declare function barCornerRadius(horizontal: boolean, stackIds: (string | undefined)[], index: number, radius?: number): number[];
379
+ /**
380
+ * Widen the grid's right edge for labels drawn past the end of horizontal
381
+ * bars, which `containLabel` does not reserve room for. Width is estimated
382
+ * from the longest label.
383
+ */
384
+ declare function withEndLabelInset(inset: AnyObject | undefined, labels: string[]): AnyObject | undefined;
353
385
 
354
386
  /**
355
387
  * Data preparation for distribution, waterfall and stacked charts, and
@@ -417,6 +449,16 @@ declare function waterfallLayout(steps: WaterfallStep[]): WaterfallBar[];
417
449
  declare function toPercentStack<S extends {
418
450
  data: (number | null)[];
419
451
  }>(series: S[]): S[];
452
+ /**
453
+ * Each stack's total by category, keyed by stack id. A series stacks under
454
+ * its own `stack` id, or 'Total' when `stacked` is set; unstacked series are
455
+ * left out. Totals are the raw values, also for percent stacks. A category
456
+ * with no values in a stack gets `null`.
457
+ */
458
+ declare function stackTotals(series: {
459
+ data: (number | null)[];
460
+ stack?: string;
461
+ }[], stacked: boolean | 'percent' | undefined): Map<string, (number | null)[]>;
420
462
  /**
421
463
  * Pair category labels with values for a time or value x-axis, which reads
422
464
  * [x, y] points rather than values by category index
@@ -631,4 +673,4 @@ declare function downloadFile(content: string, filename: string, mimeType?: stri
631
673
  */
632
674
  declare function downloadCsv(table: ChartDataTable, filename: string): void;
633
675
 
634
- export { type Accessor, type Aggregate, type AutoHeightOptions, AxisConfig, BarChartData, type BinValuesOptions, BoxPlotStats, type BuildDataLabelOptions, type BuildLegendOptions, type BuildTooltipOptions, BulletItem, CandlestickData, CategoryChartData, CategorySeriesData, ChartDataTable, ChartFormatter, type ChartFormatterOptions, type ChartFormatters, ChartLegendConfig, type Delta, type FromRecordsOptions, HeatmapData, HistogramBin, HistogramBucket, LabelFormatterParams, LegendItem, LineChartData, PieSliceData, type PointLabelLookup, RadarChartData, RadarIndicator, ReferenceBand, ReferenceLine, type ReferenceMarkOptions, SankeyData, ScatterChartData, type ToSliceDataOptions, TooltipFormatterParams, TreemapNode, ValueFormat, type WaterfallBar, WaterfallStep, abbreviateNumber, allSeriesEmpty, anchorCenterGraphic, binValues, boxPlotTable, boxplotStats, buildAxis, buildDataLabel, buildDataZoom, buildLegend, buildMarkArea, buildMarkLine, buildTooltip, bulletTable, candlestickTable, categoryTable, chartFilename, chartFormatters, computeDelta, createTooltipFormatter, downloadCsv, downloadFile, escapeHtml, formatCurrency, formatDate, formatNumber, formatPercent, formatTableCell, formatTooltipValue, formatValue, fromRecords, generateAreaChartDescription, generateBarChartDescription, generateBoxPlotDescription, generateBulletDescription, generateComboChartDescription, generateDataTable, generateDotPlotDescription, generateHistogramDescription, generateLineChartDescription, generatePieChartDescription, generateScatterChartDescription, generateWaterfallDescription, getLegendItems, getReplaceMerge, gridInset, hasPointLabels, heatmapTable, histogramTable, itemLabelLookup, legendCenter, mergeChartOption, nameLabelLookup, niceStep, pairWithCategories, pointLabel, quantile, radarTable, resolveChartHeight, resolveValueFormatter, sankeyTable, scatterTable, seriesLabelLookup, sliceTable, tableToCsv, toPercentStack, toSliceData, treemapTable, trendLine, valueAxisConfig, valueLabelFormatter, waterfallLayout, waterfallTable, withPointLabels, withReferenceMarks, withZoomInset };
676
+ export { type Accessor, type Aggregate, type AutoHeightOptions, AxisConfig, BarChartData, type BinValuesOptions, BoxPlotStats, type BuildDataLabelOptions, type BuildLegendOptions, type BuildTooltipOptions, BulletItem, CandlestickData, CategoryChartData, CategorySeriesData, ChartDataTable, ChartFormatter, type ChartFormatterOptions, type ChartFormatters, ChartLegendConfig, type Delta, type FromRecordsOptions, HeatmapData, HistogramBin, HistogramBucket, LabelFormatterParams, LegendItem, LineChartData, PieSliceData, type PointLabelLookup, RadarChartData, RadarIndicator, ReferenceBand, ReferenceLine, type ReferenceMarkOptions, SankeyData, ScatterChartData, type StackTotalOptions, type ToSliceDataOptions, TooltipFormatterParams, TreemapNode, ValueFormat, type WaterfallBar, WaterfallStep, abbreviateNumber, allSeriesEmpty, anchorCenterGraphic, barCornerRadius, binValues, boxPlotTable, boxplotStats, buildAxis, buildDataLabel, buildDataZoom, buildLegend, buildMarkArea, buildMarkLine, buildStackTotalSeries, buildTooltip, bulletTable, candlestickTable, categoryTable, chartFilename, chartFormatters, computeDelta, createTooltipFormatter, downloadCsv, downloadFile, escapeHtml, formatCurrency, formatDate, formatNumber, formatPercent, formatTableCell, formatTooltipValue, formatValue, fromRecords, generateAreaChartDescription, generateBarChartDescription, generateBoxPlotDescription, generateBulletDescription, generateComboChartDescription, generateDataTable, generateDotPlotDescription, generateHistogramDescription, generateLineChartDescription, generatePieChartDescription, generateScatterChartDescription, generateWaterfallDescription, getLegendItems, getReplaceMerge, gridInset, hasPointLabels, heatmapTable, histogramTable, itemLabelLookup, legendCenter, mergeChartOption, nameLabelLookup, niceStep, pairWithCategories, pointLabel, quantile, radarTable, resolveChartHeight, resolveValueFormatter, sankeyTable, scatterTable, seriesLabelLookup, sliceTable, stackTotals, tableToCsv, toPercentStack, toSliceData, treemapTable, trendLine, valueAxisConfig, valueLabelFormatter, waterfallLayout, waterfallTable, withEndLabelInset, withPointLabels, withReferenceMarks, withZoomInset };
package/dist/index.js CHANGED
@@ -303,6 +303,237 @@ function mergeChartOption(generated, override) {
303
303
  return merged;
304
304
  }
305
305
 
306
+ // src/utils/stats.ts
307
+ var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
308
+ var clean = (value) => Number(value.toPrecision(12));
309
+ function niceStep(rough) {
310
+ if (!(rough > 0) || !Number.isFinite(rough)) return 1;
311
+ const power = 10 ** Math.floor(Math.log10(rough));
312
+ const fraction = rough / power;
313
+ const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10;
314
+ return clean(nice * power);
315
+ }
316
+ function bucketLabel(start, end, format) {
317
+ const text = (value) => formatValue(value, format);
318
+ if (start === -Infinity && end === Infinity) return "All";
319
+ if (start === -Infinity) return `< ${text(end)}`;
320
+ if (end === Infinity) return `${text(start)}+`;
321
+ return `${text(start)}\u2013${text(end)}`;
322
+ }
323
+ function binValues(values, options = {}) {
324
+ const { bins, binWidth, format } = options;
325
+ const finite = values.filter(isFiniteNumber);
326
+ let edges;
327
+ if (Array.isArray(bins)) {
328
+ edges = bins.map((bin) => ({
329
+ start: bin.start ?? -Infinity,
330
+ end: bin.end ?? Infinity,
331
+ label: bin.label
332
+ }));
333
+ } else {
334
+ if (!finite.length) return [];
335
+ let min = Infinity;
336
+ let max = -Infinity;
337
+ for (const value of finite) {
338
+ if (value < min) min = value;
339
+ if (value > max) max = value;
340
+ }
341
+ if (min === max) {
342
+ edges = [{ start: min, end: max }];
343
+ } else {
344
+ let step;
345
+ let start;
346
+ if (binWidth && binWidth > 0) {
347
+ step = binWidth;
348
+ start = Math.floor(min / step) * step;
349
+ } else if (typeof bins === "number" && bins > 0) {
350
+ step = (max - min) / Math.floor(bins);
351
+ start = min;
352
+ } else {
353
+ const sturges = Math.ceil(Math.log2(finite.length) + 1);
354
+ step = niceStep((max - min) / sturges);
355
+ start = Math.floor(min / step) * step;
356
+ }
357
+ const count = Math.max(1, Math.ceil(clean((max - start) / step)));
358
+ edges = Array.from({ length: count }, (_, index) => ({
359
+ start: clean(start + index * step),
360
+ end: clean(start + (index + 1) * step)
361
+ }));
362
+ }
363
+ }
364
+ const buckets = edges.map(({ start, end, label }) => ({
365
+ label: label ?? bucketLabel(start, end, format),
366
+ start,
367
+ end,
368
+ count: 0
369
+ }));
370
+ const last = buckets.length - 1;
371
+ for (const value of finite) {
372
+ const index = buckets.findIndex(
373
+ (bucket, i) => value >= bucket.start && (value < bucket.end || i === last && value <= bucket.end)
374
+ );
375
+ if (index !== -1) buckets[index].count++;
376
+ }
377
+ return buckets;
378
+ }
379
+ function quantile(sorted, q) {
380
+ if (!sorted.length) return NaN;
381
+ const position = (sorted.length - 1) * q;
382
+ const lower = Math.floor(position);
383
+ const upper = Math.ceil(position);
384
+ if (lower === upper) return sorted[lower];
385
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
386
+ }
387
+ function boxplotStats(values) {
388
+ const sorted = values.filter(isFiniteNumber).sort((a, b) => a - b);
389
+ if (!sorted.length) return null;
390
+ const q1 = quantile(sorted, 0.25);
391
+ const median = quantile(sorted, 0.5);
392
+ const q3 = quantile(sorted, 0.75);
393
+ const iqr = q3 - q1;
394
+ const lowFence = q1 - 1.5 * iqr;
395
+ const highFence = q3 + 1.5 * iqr;
396
+ const inside = sorted.filter((value) => value >= lowFence && value <= highFence);
397
+ const outliers = sorted.filter((value) => value < lowFence || value > highFence);
398
+ return {
399
+ low: inside[0] ?? q1,
400
+ q1,
401
+ median,
402
+ q3,
403
+ high: inside[inside.length - 1] ?? q3,
404
+ outliers,
405
+ count: sorted.length,
406
+ mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length
407
+ };
408
+ }
409
+ function waterfallLayout(steps) {
410
+ let running = 0;
411
+ return steps.map((step) => {
412
+ let start;
413
+ let end;
414
+ let kind;
415
+ if (step.isTotal === "value") {
416
+ start = 0;
417
+ end = isFiniteNumber(step.value) ? step.value : running;
418
+ kind = "total";
419
+ } else if (step.isTotal) {
420
+ start = 0;
421
+ end = running;
422
+ kind = "total";
423
+ } else {
424
+ const change = isFiniteNumber(step.value) ? step.value : 0;
425
+ start = running;
426
+ end = running + change;
427
+ kind = change < 0 ? "decrease" : "increase";
428
+ }
429
+ running = end;
430
+ const low = Math.min(start, end);
431
+ const high = Math.max(start, end);
432
+ let base = 0;
433
+ let positive = 0;
434
+ let negative = 0;
435
+ if (low >= 0) {
436
+ base = low;
437
+ positive = high - low;
438
+ } else if (high <= 0) {
439
+ base = high;
440
+ negative = low - high;
441
+ } else {
442
+ positive = high;
443
+ negative = low;
444
+ }
445
+ return {
446
+ start,
447
+ end,
448
+ value: kind === "total" ? end : end - start,
449
+ kind,
450
+ base: clean(base),
451
+ positive: clean(positive),
452
+ negative: clean(negative)
453
+ };
454
+ });
455
+ }
456
+ function toPercentStack(series) {
457
+ const length = Math.max(0, ...series.map((s) => s.data.length));
458
+ const totals = Array.from(
459
+ { length },
460
+ (_, index) => series.reduce((sum, s) => {
461
+ const value = s.data[index];
462
+ return sum + (isFiniteNumber(value) ? Math.abs(value) : 0);
463
+ }, 0)
464
+ );
465
+ return series.map((s) => ({
466
+ ...s,
467
+ data: s.data.map((value, index) => {
468
+ if (!isFiniteNumber(value)) return null;
469
+ return totals[index] === 0 ? 0 : clean(value / totals[index] * 100);
470
+ })
471
+ }));
472
+ }
473
+ function stackTotals(series, stacked) {
474
+ const length = Math.max(0, ...series.map((s) => s.data.length));
475
+ const totals = /* @__PURE__ */ new Map();
476
+ for (const s of series) {
477
+ const stack = s.stack ?? (stacked ? "Total" : void 0);
478
+ if (stack === void 0) continue;
479
+ const sums = totals.get(stack) ?? Array.from({ length }, () => null);
480
+ s.data.forEach((value, index) => {
481
+ if (isFiniteNumber(value)) sums[index] = clean((sums[index] ?? 0) + value);
482
+ });
483
+ totals.set(stack, sums);
484
+ }
485
+ return totals;
486
+ }
487
+ function pairWithCategories(categories, values, axisType) {
488
+ if (axisType !== "time" && axisType !== "value") return values;
489
+ return values.map((value, index) => [categories[index], value]);
490
+ }
491
+ function resolveChartHeight(height, rows, { rowHeight = 28, chrome = 56, minHeight = 160, maxHeight } = {}) {
492
+ if (height !== "auto") return height;
493
+ let value = Math.max(minHeight, chrome + Math.max(rows, 1) * rowHeight);
494
+ if (maxHeight !== void 0) value = Math.min(value, maxHeight);
495
+ return value;
496
+ }
497
+ function allSeriesEmpty(series) {
498
+ return !series?.length || series.every((s) => !s.data?.some((value) => value != null));
499
+ }
500
+ function trendLine(points) {
501
+ const finite = points.filter(([x, y]) => isFiniteNumber(x) && isFiniteNumber(y));
502
+ const n = finite.length;
503
+ if (n < 2) return null;
504
+ let sumX = 0;
505
+ let sumY = 0;
506
+ let sumXY = 0;
507
+ let sumX2 = 0;
508
+ let minX = Infinity;
509
+ let maxX = -Infinity;
510
+ for (const [x, y] of finite) {
511
+ sumX += x;
512
+ sumY += y;
513
+ sumXY += x * y;
514
+ sumX2 += x * x;
515
+ if (x < minX) minX = x;
516
+ if (x > maxX) maxX = x;
517
+ }
518
+ const denominator = n * sumX2 - sumX * sumX;
519
+ if (minX === maxX || denominator === 0) return null;
520
+ const slope = (n * sumXY - sumX * sumY) / denominator;
521
+ const intercept = (sumY - slope * sumX) / n;
522
+ return [
523
+ [minX, slope * minX + intercept],
524
+ [maxX, slope * maxX + intercept]
525
+ ];
526
+ }
527
+ function computeDelta(current, previous) {
528
+ if (!isFiniteNumber(current) || !isFiniteNumber(previous)) return null;
529
+ const change = clean(current - previous);
530
+ return {
531
+ change,
532
+ ratio: previous === 0 ? null : change / Math.abs(previous),
533
+ direction: change > 0 ? "up" : change < 0 ? "down" : "flat"
534
+ };
535
+ }
536
+
306
537
  // src/utils/options.ts
307
538
  function toParams(raw, getLabel) {
308
539
  const seriesIndex = raw.seriesIndex ?? 0;
@@ -587,222 +818,40 @@ function withZoomInset(inset, mode, axis) {
587
818
  const current = typeof inset?.[edge] === "number" ? inset[edge] : 12;
588
819
  return { ...inset, [edge]: current + 36 };
589
820
  }
590
-
591
- // src/utils/stats.ts
592
- var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
593
- var clean = (value) => Number(value.toPrecision(12));
594
- function niceStep(rough) {
595
- if (!(rough > 0) || !Number.isFinite(rough)) return 1;
596
- const power = 10 ** Math.floor(Math.log10(rough));
597
- const fraction = rough / power;
598
- const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10;
599
- return clean(nice * power);
600
- }
601
- function bucketLabel(start, end, format) {
602
- const text = (value) => formatValue(value, format);
603
- if (start === -Infinity && end === Infinity) return "All";
604
- if (start === -Infinity) return `< ${text(end)}`;
605
- if (end === Infinity) return `${text(start)}+`;
606
- return `${text(start)}\u2013${text(end)}`;
607
- }
608
- function binValues(values, options = {}) {
609
- const { bins, binWidth, format } = options;
610
- const finite = values.filter(isFiniteNumber);
611
- let edges;
612
- if (Array.isArray(bins)) {
613
- edges = bins.map((bin) => ({
614
- start: bin.start ?? -Infinity,
615
- end: bin.end ?? Infinity,
616
- label: bin.label
617
- }));
618
- } else {
619
- if (!finite.length) return [];
620
- let min = Infinity;
621
- let max = -Infinity;
622
- for (const value of finite) {
623
- if (value < min) min = value;
624
- if (value > max) max = value;
625
- }
626
- if (min === max) {
627
- edges = [{ start: min, end: max }];
628
- } else {
629
- let step;
630
- let start;
631
- if (binWidth && binWidth > 0) {
632
- step = binWidth;
633
- start = Math.floor(min / step) * step;
634
- } else if (typeof bins === "number" && bins > 0) {
635
- step = (max - min) / Math.floor(bins);
636
- start = min;
637
- } else {
638
- const sturges = Math.ceil(Math.log2(finite.length) + 1);
639
- step = niceStep((max - min) / sturges);
640
- start = Math.floor(min / step) * step;
641
- }
642
- const count = Math.max(1, Math.ceil(clean((max - start) / step)));
643
- edges = Array.from({ length: count }, (_, index) => ({
644
- start: clean(start + index * step),
645
- end: clean(start + (index + 1) * step)
646
- }));
647
- }
648
- }
649
- const buckets = edges.map(({ start, end, label }) => ({
650
- label: label ?? bucketLabel(start, end, format),
651
- start,
652
- end,
653
- count: 0
654
- }));
655
- const last = buckets.length - 1;
656
- for (const value of finite) {
657
- const index = buckets.findIndex(
658
- (bucket, i) => value >= bucket.start && (value < bucket.end || i === last && value <= bucket.end)
659
- );
660
- if (index !== -1) buckets[index].count++;
661
- }
662
- return buckets;
663
- }
664
- function quantile(sorted, q) {
665
- if (!sorted.length) return NaN;
666
- const position = (sorted.length - 1) * q;
667
- const lower = Math.floor(position);
668
- const upper = Math.ceil(position);
669
- if (lower === upper) return sorted[lower];
670
- return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
671
- }
672
- function boxplotStats(values) {
673
- const sorted = values.filter(isFiniteNumber).sort((a, b) => a - b);
674
- if (!sorted.length) return null;
675
- const q1 = quantile(sorted, 0.25);
676
- const median = quantile(sorted, 0.5);
677
- const q3 = quantile(sorted, 0.75);
678
- const iqr = q3 - q1;
679
- const lowFence = q1 - 1.5 * iqr;
680
- const highFence = q3 + 1.5 * iqr;
681
- const inside = sorted.filter((value) => value >= lowFence && value <= highFence);
682
- const outliers = sorted.filter((value) => value < lowFence || value > highFence);
683
- return {
684
- low: inside[0] ?? q1,
685
- q1,
686
- median,
687
- q3,
688
- high: inside[inside.length - 1] ?? q3,
689
- outliers,
690
- count: sorted.length,
691
- mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length
692
- };
693
- }
694
- function waterfallLayout(steps) {
695
- let running = 0;
696
- return steps.map((step) => {
697
- let start;
698
- let end;
699
- let kind;
700
- if (step.isTotal === "value") {
701
- start = 0;
702
- end = isFiniteNumber(step.value) ? step.value : running;
703
- kind = "total";
704
- } else if (step.isTotal) {
705
- start = 0;
706
- end = running;
707
- kind = "total";
708
- } else {
709
- const change = isFiniteNumber(step.value) ? step.value : 0;
710
- start = running;
711
- end = running + change;
712
- kind = change < 0 ? "decrease" : "increase";
713
- }
714
- running = end;
715
- const low = Math.min(start, end);
716
- const high = Math.max(start, end);
717
- let base = 0;
718
- let positive = 0;
719
- let negative = 0;
720
- if (low >= 0) {
721
- base = low;
722
- positive = high - low;
723
- } else if (high <= 0) {
724
- base = high;
725
- negative = low - high;
726
- } else {
727
- positive = high;
728
- negative = low;
821
+ function buildStackTotalSeries(categories, series, { stacked, position, format, axisType }) {
822
+ return [...stackTotals(series, stacked)].map(([stack, totals]) => ({
823
+ id: `__total:${stack}`,
824
+ type: "bar",
825
+ stack,
826
+ data: pairWithCategories(
827
+ categories,
828
+ totals.map((total) => total === null ? null : 0),
829
+ axisType
830
+ ),
831
+ silent: true,
832
+ legendHoverLink: false,
833
+ tooltip: { show: false },
834
+ itemStyle: { color: "transparent" },
835
+ emphasis: { disabled: true },
836
+ label: {
837
+ show: true,
838
+ position,
839
+ fontWeight: "bold",
840
+ formatter: (params) => formatValue(totals[params.dataIndex], format)
729
841
  }
730
- return {
731
- start,
732
- end,
733
- value: kind === "total" ? end : end - start,
734
- kind,
735
- base: clean(base),
736
- positive: clean(positive),
737
- negative: clean(negative)
738
- };
739
- });
740
- }
741
- function toPercentStack(series) {
742
- const length = Math.max(0, ...series.map((s) => s.data.length));
743
- const totals = Array.from(
744
- { length },
745
- (_, index) => series.reduce((sum, s) => {
746
- const value = s.data[index];
747
- return sum + (isFiniteNumber(value) ? Math.abs(value) : 0);
748
- }, 0)
749
- );
750
- return series.map((s) => ({
751
- ...s,
752
- data: s.data.map((value, index) => {
753
- if (!isFiniteNumber(value)) return null;
754
- return totals[index] === 0 ? 0 : clean(value / totals[index] * 100);
755
- })
756
842
  }));
757
843
  }
758
- function pairWithCategories(categories, values, axisType) {
759
- if (axisType !== "time" && axisType !== "value") return values;
760
- return values.map((value, index) => [categories[index], value]);
761
- }
762
- function resolveChartHeight(height, rows, { rowHeight = 28, chrome = 56, minHeight = 160, maxHeight } = {}) {
763
- if (height !== "auto") return height;
764
- let value = Math.max(minHeight, chrome + Math.max(rows, 1) * rowHeight);
765
- if (maxHeight !== void 0) value = Math.min(value, maxHeight);
766
- return value;
767
- }
768
- function allSeriesEmpty(series) {
769
- return !series?.length || series.every((s) => !s.data?.some((value) => value != null));
770
- }
771
- function trendLine(points) {
772
- const finite = points.filter(([x, y]) => isFiniteNumber(x) && isFiniteNumber(y));
773
- const n = finite.length;
774
- if (n < 2) return null;
775
- let sumX = 0;
776
- let sumY = 0;
777
- let sumXY = 0;
778
- let sumX2 = 0;
779
- let minX = Infinity;
780
- let maxX = -Infinity;
781
- for (const [x, y] of finite) {
782
- sumX += x;
783
- sumY += y;
784
- sumXY += x * y;
785
- sumX2 += x * x;
786
- if (x < minX) minX = x;
787
- if (x > maxX) maxX = x;
788
- }
789
- const denominator = n * sumX2 - sumX * sumX;
790
- if (minX === maxX || denominator === 0) return null;
791
- const slope = (n * sumXY - sumX * sumY) / denominator;
792
- const intercept = (sumY - slope * sumX) / n;
793
- return [
794
- [minX, slope * minX + intercept],
795
- [maxX, slope * maxX + intercept]
796
- ];
844
+ function barCornerRadius(horizontal, stackIds, index, radius = 4) {
845
+ const stack = stackIds[index];
846
+ const outer = stack === void 0 || stackIds.lastIndexOf(stack) === index;
847
+ if (!outer) return [0, 0, 0, 0];
848
+ return horizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0];
797
849
  }
798
- function computeDelta(current, previous) {
799
- if (!isFiniteNumber(current) || !isFiniteNumber(previous)) return null;
800
- const change = clean(current - previous);
801
- return {
802
- change,
803
- ratio: previous === 0 ? null : change / Math.abs(previous),
804
- direction: change > 0 ? "up" : change < 0 ? "down" : "flat"
805
- };
850
+ function withEndLabelInset(inset, labels) {
851
+ const longest = Math.max(0, ...labels.map((label) => label.length));
852
+ if (longest === 0) return inset;
853
+ const current = typeof inset?.right === "number" ? inset.right : 0;
854
+ return { ...inset, right: current + longest * 7 + 16 };
806
855
  }
807
856
 
808
857
  // src/utils/records.ts
@@ -1192,4 +1241,4 @@ function downloadCsv(table, filename) {
1192
1241
  );
1193
1242
  }
1194
1243
 
1195
- export { abbreviateNumber, allSeriesEmpty, anchorCenterGraphic, binValues, boxPlotTable, boxplotStats, buildAxis, buildDataLabel, buildDataZoom, buildLegend, buildMarkArea, buildMarkLine, buildTooltip, bulletTable, candlestickTable, categoryTable, chartFilename, chartFormatters, computeDelta, createTooltipFormatter, downloadCsv, downloadFile, escapeHtml, formatCurrency, formatDate, formatNumber, formatPercent, formatTableCell, formatTooltipValue, formatValue, fromRecords, generateAreaChartDescription, generateBarChartDescription, generateBoxPlotDescription, generateBulletDescription, generateComboChartDescription, generateDataTable, generateDotPlotDescription, generateHistogramDescription, generateLineChartDescription, generatePieChartDescription, generateScatterChartDescription, generateWaterfallDescription, getLegendItems, getReplaceMerge, gridInset, hasPointLabels, heatmapTable, histogramTable, itemLabelLookup, legendCenter, mergeChartOption, nameLabelLookup, niceStep, pairWithCategories, pointLabel, quantile, radarTable, resolveChartHeight, resolveValueFormatter, sankeyTable, scatterTable, seriesLabelLookup, sliceTable, tableToCsv, toPercentStack, toSliceData, treemapTable, trendLine, valueAxisConfig, valueLabelFormatter, waterfallLayout, waterfallTable, withPointLabels, withReferenceMarks, withZoomInset };
1244
+ export { abbreviateNumber, allSeriesEmpty, anchorCenterGraphic, barCornerRadius, binValues, boxPlotTable, boxplotStats, buildAxis, buildDataLabel, buildDataZoom, buildLegend, buildMarkArea, buildMarkLine, buildStackTotalSeries, buildTooltip, bulletTable, candlestickTable, categoryTable, chartFilename, chartFormatters, computeDelta, createTooltipFormatter, downloadCsv, downloadFile, escapeHtml, formatCurrency, formatDate, formatNumber, formatPercent, formatTableCell, formatTooltipValue, formatValue, fromRecords, generateAreaChartDescription, generateBarChartDescription, generateBoxPlotDescription, generateBulletDescription, generateComboChartDescription, generateDataTable, generateDotPlotDescription, generateHistogramDescription, generateLineChartDescription, generatePieChartDescription, generateScatterChartDescription, generateWaterfallDescription, getLegendItems, getReplaceMerge, gridInset, hasPointLabels, heatmapTable, histogramTable, itemLabelLookup, legendCenter, mergeChartOption, nameLabelLookup, niceStep, pairWithCategories, pointLabel, quantile, radarTable, resolveChartHeight, resolveValueFormatter, sankeyTable, scatterTable, seriesLabelLookup, sliceTable, stackTotals, tableToCsv, toPercentStack, toSliceData, treemapTable, trendLine, valueAxisConfig, valueLabelFormatter, waterfallLayout, waterfallTable, withEndLabelInset, withPointLabels, withReferenceMarks, withZoomInset };
@@ -816,6 +816,12 @@ interface BarChartProps extends BaseChartProps, LegendChartProps, LabelChartProp
816
816
  stacked?: boolean | 'percent';
817
817
  /** Show value labels on bars */
818
818
  showValues?: boolean;
819
+ /**
820
+ * Label each stack's total at the end of the bar, formatted with
821
+ * `valueFormat`. Applies to stacked series (`stacked` or a series `stack`
822
+ * id); percent stacks show the raw total.
823
+ */
824
+ showTotal?: boolean;
819
825
  /** Click event handler */
820
826
  onClick?: (params: DataPointEventParams) => void;
821
827
  /** Hover event handler */
@@ -816,6 +816,12 @@ interface BarChartProps extends BaseChartProps, LegendChartProps, LabelChartProp
816
816
  stacked?: boolean | 'percent';
817
817
  /** Show value labels on bars */
818
818
  showValues?: boolean;
819
+ /**
820
+ * Label each stack's total at the end of the bar, formatted with
821
+ * `valueFormat`. Applies to stacked series (`stacked` or a series `stack`
822
+ * id); percent stacks show the raw total.
823
+ */
824
+ showTotal?: boolean;
819
825
  /** Click event handler */
820
826
  onClick?: (params: DataPointEventParams) => void;
821
827
  /** Hover event handler */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/charts-core",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Core utilities and theme for Classic Theme charts",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",