@rowsncolumns/charts 28.1.8 → 28.1.10

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.
Files changed (47) hide show
  1. package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
  2. package/dist/cjs/basic-chart/basic-chart.js +483 -417
  3. package/dist/cjs/basic-chart/helpers.d.ts.map +1 -1
  4. package/dist/cjs/basic-chart/helpers.js +2 -0
  5. package/dist/cjs/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  6. package/dist/cjs/plotly/basic-chart/basic-chart.js +315 -184
  7. package/dist/cjs/plotly/basic-chart/helpers.d.ts.map +1 -1
  8. package/dist/cjs/plotly/basic-chart/helpers.js +2 -0
  9. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  10. package/dist/cjs/utils/bubble.d.ts +34 -1
  11. package/dist/cjs/utils/bubble.d.ts.map +1 -1
  12. package/dist/cjs/utils/bubble.js +39 -13
  13. package/dist/cjs/utils/echarts-overlays.d.ts +46 -0
  14. package/dist/cjs/utils/echarts-overlays.d.ts.map +1 -0
  15. package/dist/cjs/utils/echarts-overlays.js +134 -0
  16. package/dist/cjs/utils/hierarchy.d.ts +23 -1
  17. package/dist/cjs/utils/hierarchy.d.ts.map +1 -1
  18. package/dist/cjs/utils/hierarchy.js +179 -55
  19. package/dist/cjs/utils/overrides.d.ts.map +1 -1
  20. package/dist/cjs/utils/overrides.js +9 -2
  21. package/dist/cjs/utils/trendline.d.ts +7 -1
  22. package/dist/cjs/utils/trendline.d.ts.map +1 -1
  23. package/dist/cjs/utils/trendline.js +82 -18
  24. package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
  25. package/dist/esm/basic-chart/basic-chart.js +320 -239
  26. package/dist/esm/basic-chart/helpers.d.ts.map +1 -1
  27. package/dist/esm/basic-chart/helpers.js +2 -0
  28. package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  29. package/dist/esm/plotly/basic-chart/basic-chart.js +235 -58
  30. package/dist/esm/plotly/basic-chart/helpers.d.ts.map +1 -1
  31. package/dist/esm/plotly/basic-chart/helpers.js +2 -0
  32. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  33. package/dist/esm/utils/bubble.d.ts +34 -1
  34. package/dist/esm/utils/bubble.d.ts.map +1 -1
  35. package/dist/esm/utils/bubble.js +35 -11
  36. package/dist/esm/utils/echarts-overlays.d.ts +46 -0
  37. package/dist/esm/utils/echarts-overlays.d.ts.map +1 -0
  38. package/dist/esm/utils/echarts-overlays.js +135 -0
  39. package/dist/esm/utils/hierarchy.d.ts +23 -1
  40. package/dist/esm/utils/hierarchy.d.ts.map +1 -1
  41. package/dist/esm/utils/hierarchy.js +168 -50
  42. package/dist/esm/utils/overrides.d.ts.map +1 -1
  43. package/dist/esm/utils/overrides.js +9 -2
  44. package/dist/esm/utils/trendline.d.ts +7 -1
  45. package/dist/esm/utils/trendline.d.ts.map +1 -1
  46. package/dist/esm/utils/trendline.js +67 -14
  47. package/package.json +3 -3
@@ -18,9 +18,9 @@ import { coerceCalculatedLabel, getChartDataRanges, } from "../utils/chart-spec-
18
18
  import { canSwitchRowColumn, cellLabelGetterFromRangeReader, switchRowColumn, } from "../utils/chart-orientation";
19
19
  import { dataUrlToBlob } from "../utils/chart-image";
20
20
  import { getChartPalette } from "../utils/chart-styles";
21
+ import { buildEchartsOverlaySeries } from "../utils/echarts-overlays";
21
22
  import { applyEmptyCellsPolicy, connectsAcrossEmptyCells, isolatedPointIndexes, isLineLikeChartType, } from "../utils/empty-cells";
22
23
  import { getEchartsGlState, loadEchartsGl, } from "../utils/echarts-gl-loader";
23
- import { computeErrorBars, resolveCustomErrorBarValues, } from "../utils/error-bars";
24
24
  import { echartsGroupedCategoryAxes, resolveCategoryLevels, } from "../utils/category-levels";
25
25
  import { gradientToEcharts } from "../utils/gradient";
26
26
  import { buildHierarchyTree, extractHierarchyRows, getHierarchyHeaderTitle, } from "../utils/hierarchy";
@@ -36,7 +36,6 @@ import { STOCK_ROLES, toCandlestick, toLineSeries, toVolumeSeries, } from "../ut
36
36
  import { explosionOffsetPx, pieRadiusPx, resolveExplosion, } from "../utils/pie-explosion";
37
37
  import { chartSeriesId, chartSeriesSpecIndex, resolveClickedDataIndex, resolveClickedSeriesIndex, } from "../utils/series-click";
38
38
  import { buildBar3DData, buildContourData, buildLine3DSeries, buildSurfaceData, getCategoryCount, getValueExtent, isContourSpec, resolve3DRenderKind, SURFACE_COLOR_RAMP, } from "../utils/three-d";
39
- import { computeTrendline } from "../utils/trendline";
40
39
  import { computeWaterfall } from "../utils/waterfall";
41
40
  import { isWebGLAvailable } from "../utils/webgl";
42
41
  import { chartTypeToEchartsType, circularLegendEntries, getEchartsSeriesStyle, isCircularChartType, mapMarkerSymbolToEcharts, showXAxis, } from "./helpers";
@@ -103,29 +102,41 @@ const HIERARCHY_CHART_TYPES = new Set([
103
102
  const isBasicChartSpec = (spec) => BASIC_CHART_TYPES.has(spec.chartType);
104
103
  const isBubbleChartSpec = (spec) => BUBBLE_CHART_TYPES.has(spec.chartType);
105
104
  const isHierarchyChartSpec = (spec) => HIERARCHY_CHART_TYPES.has(spec.chartType);
105
+ // Every level keeps the colour its node was given (one solid colour per
106
+ // top-level category, like Excel — no per-level saturation ramp); thin
107
+ // white gaps separate the cells.
106
108
  const HIERARCHY_LEVEL_STYLES = [
107
109
  {
108
110
  itemStyle: {
109
- gapWidth: 1,
110
- borderColorSaturation: 0.6,
111
+ gapWidth: 2,
112
+ borderColor: "#ffffff",
113
+ borderWidth: 0,
111
114
  },
112
115
  upperLabel: { show: false },
113
116
  },
114
117
  {
115
- colorSaturation: [0.35, 0.65],
116
118
  itemStyle: {
117
119
  gapWidth: 1,
118
- borderColorSaturation: 0.6,
119
- },
120
- },
121
- {
122
- colorSaturation: [0.45, 0.75],
123
- itemStyle: {
124
- gapWidth: 1,
125
- borderColorSaturation: 0.6,
120
+ borderColor: "#ffffff",
121
+ borderWidth: 1,
126
122
  },
127
123
  },
128
124
  ];
125
+ /**
126
+ * Colour a hierarchy like Excel: each top-level node takes the next
127
+ * palette colour and every descendant inherits it (#1072).
128
+ */
129
+ const colorHierarchyNodes = (nodes, palette) => nodes.map((node, index) => {
130
+ const color = chartPaletteColorAt(palette, index);
131
+ const paint = (n) => ({
132
+ ...n,
133
+ itemStyle: { color },
134
+ ...(n.children ? { children: n.children.map(paint) } : {}),
135
+ });
136
+ return paint(node);
137
+ });
138
+ /** Sunburst rings: Excel leaves the centre empty, so even the first level is a ring (#1073). */
139
+ const SUNBURST_RADIUS = ["15%", "80%"];
129
140
  const getHierarchyDepth = (nodes, depth = 1) => {
130
141
  let maxDepth = depth;
131
142
  nodes.forEach((node) => {
@@ -176,6 +187,16 @@ const buildHierarchyLevels = (maxDepth) => {
176
187
  };
177
188
  /** Series whose tooltip value is one number on a value axis. */
178
189
  const VALUE_TOOLTIP_SERIES_TYPES = new Set(["bar", "line", "scatter", "funnel"]);
190
+ /** The transparent spacer bars a waterfall stacks its visible bars on. */
191
+ const WATERFALL_PLACEHOLDER_ID = "rnc-waterfall-placeholder";
192
+ /** The invisible funnel that carries the stage names beside the bars. */
193
+ const FUNNEL_NAMES_ID = "rnc-funnel-names";
194
+ /**
195
+ * Layout-only series: they carry no data of their own, so they get no
196
+ * data labels and no tooltip entry (#1070 labelled every waterfall bar
197
+ * with its spacer's height too).
198
+ */
199
+ const DECORATION_SERIES_IDS = new Set([WATERFALL_PLACEHOLDER_ID, FUNNEL_NAMES_ID]);
179
200
  const defaultLegendConfig = {
180
201
  padding: 10,
181
202
  itemWidth: 10,
@@ -556,7 +577,38 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
556
577
  sizeValues: sharedSizeValues,
557
578
  series: seriesInputs,
558
579
  });
559
- const sizeScale = createBubbleSizeScale(sizeValues);
580
+ // Bubble diameters scale from zero (area by default, width when
581
+ // `sizeRepresents` says so) and the largest bubble is a fraction of
582
+ // the plot area — the grid keeps ~85% of the width and ~75% of the
583
+ // height for the plot (#1068).
584
+ const sizeScale = createBubbleSizeScale(sizeValues, {
585
+ sizeRepresents: spec.sizeRepresents,
586
+ bubbleScale: spec.bubbleScale,
587
+ plotMinSide: Math.min((width ?? 0) * 0.85, (height ?? 0) * 0.75),
588
+ });
589
+ const bubblePalette = getChartPalette(spec, [
590
+ theme.themeColors.accent1,
591
+ theme.themeColors.accent2,
592
+ theme.themeColors.accent3,
593
+ theme.themeColors.accent4,
594
+ theme.themeColors.accent5,
595
+ theme.themeColors.accent6,
596
+ ]);
597
+ // Per-point colours (`<c:dPt>`) key on the point's source row, which
598
+ // `BubbleSeriesPoint.index` keeps (points without a Y are skipped).
599
+ const bubbleItemColor = (seriesEntry, index) => {
600
+ const base = seriesEntry?.color ?? chartPaletteColorAt(bubblePalette, index);
601
+ const overrides = new Map();
602
+ for (const dp of seriesEntry?.dataPointColors ?? []) {
603
+ if (typeof dp?.idx === "number" && typeof dp?.color === "string") {
604
+ overrides.set(dp.idx, dp.color);
605
+ }
606
+ }
607
+ return overrides.size
608
+ ? (params) => overrides.get(params?.data?.index) ?? base
609
+ : base;
610
+ };
611
+ const hasBubbleSecondaryAxis = (spec.series ?? []).some((seriesEntry) => seriesEntry?.useSecondaryAxis);
560
612
  const xAxisConfig = categories.length > 0
561
613
  ? {
562
614
  type: "category",
@@ -585,6 +637,102 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
585
637
  // non-bubble layout below. A series' own `dataLabels` replace the
586
638
  // chart-level ones for it (#1059).
587
639
  const bubbleSeriesLabelFor = (index) => buildSeriesLabelOption(resolveSeriesDataLabels(spec.dataLabels, spec.series?.[index]), undefined, locale);
640
+ // Scatter vs bubble — scatter uses small fixed markers (optionally
641
+ // connected with a line per scatterStyle); bubble applies a size
642
+ // scale on the 3rd dimension. Pre-fix scatter routed through the
643
+ // bubble code, producing Y-scaled giant circles instead of Excel's
644
+ // small dots.
645
+ const bubbleSeries = preparedSeries
646
+ .map((entry, index) => {
647
+ const isScatter = spec.chartType === "scatter";
648
+ const scatterStyle = spec.scatterStyle ?? (isScatter ? "lineMarker" : undefined);
649
+ const seriesEntry = spec.series?.[index];
650
+ const seriesColor = seriesEntry?.color;
651
+ const perMarker = seriesEntry?.marker;
652
+ const itemColor = bubbleItemColor(seriesEntry, index);
653
+ const onSecondary = seriesEntry?.useSecondaryAxis ? { yAxisIndex: 1 } : {};
654
+ if (isScatter) {
655
+ const symbol = perMarker?.symbol;
656
+ const noMarkers = symbol === "none";
657
+ const wantsLine = scatterStyle === "line" ||
658
+ scatterStyle === "lineMarker" ||
659
+ scatterStyle === "smooth" ||
660
+ scatterStyle === "smoothMarker";
661
+ const wantsMarkers = !noMarkers &&
662
+ (scatterStyle === "marker" ||
663
+ scatterStyle === "lineMarker" ||
664
+ scatterStyle === "smoothMarker" ||
665
+ !scatterStyle);
666
+ const smooth = scatterStyle === "smooth" || scatterStyle === "smoothMarker";
667
+ // Labels hang off the point symbols: a marker-less line keeps
668
+ // an invisible 0-size symbol per point when they show (#1049).
669
+ const labelsWithoutMarkers = !wantsMarkers &&
670
+ Boolean(resolveSeriesDataLabels(spec.dataLabels, seriesEntry)
671
+ ?.show);
672
+ // The series colour paints the line; the markers take the
673
+ // per-point overrides when the series has any.
674
+ const markerStyle = seriesColor || typeof itemColor === "function"
675
+ ? { itemStyle: { color: itemColor } }
676
+ : {};
677
+ return wantsLine
678
+ ? {
679
+ type: "line",
680
+ name: entry.name,
681
+ data: entry.points,
682
+ showSymbol: wantsMarkers || labelsWithoutMarkers,
683
+ smooth,
684
+ symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
685
+ symbolSize: labelsWithoutMarkers ? 0 : perMarker?.size ?? 6,
686
+ ...markerStyle,
687
+ ...(seriesColor ? { lineStyle: { color: seriesColor } } : {}),
688
+ ...onSecondary,
689
+ }
690
+ : {
691
+ type: "scatter",
692
+ name: entry.name,
693
+ data: entry.points,
694
+ symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
695
+ symbolSize: perMarker?.size ?? 6,
696
+ ...markerStyle,
697
+ ...onSecondary,
698
+ };
699
+ }
700
+ return {
701
+ type: chartType,
702
+ name: entry.name,
703
+ data: entry.points,
704
+ symbolSize: (data) => sizeScale(data?.[2] ?? 0),
705
+ label: {
706
+ show: true,
707
+ formatter: (params) => params.name ?? "",
708
+ },
709
+ ...(seriesColor || typeof itemColor === "function"
710
+ ? { itemStyle: { color: itemColor } }
711
+ : {}),
712
+ ...onSecondary,
713
+ };
714
+ })
715
+ .map((s, index) => {
716
+ const bubbleSeriesLabel = bubbleSeriesLabelFor(index);
717
+ return bubbleSeriesLabel
718
+ ? { ...s, label: { ...(s.label ?? {}), ...bubbleSeriesLabel } }
719
+ : s;
720
+ });
721
+ // Trendlines and error bars on the real (x, y) points: the fit is a
722
+ // regression of Y on X, not on the point index (#1067).
723
+ const bubbleOverlays = preparedSeries.flatMap((entry, index) => {
724
+ const seriesEntry = spec.series?.[index];
725
+ if (!seriesEntry)
726
+ return [];
727
+ return buildEchartsOverlaySeries({
728
+ series: seriesEntry,
729
+ values: entry.points.map((point) => point.value[1]),
730
+ xValues: entry.points.map((point) => point.value[0]),
731
+ dataLabel: entry.name,
732
+ seriesColor: seriesEntry.color ?? chartPaletteColorAt(bubblePalette, index),
733
+ resolveRange: (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy),
734
+ });
735
+ });
588
736
  chartRef.current?.setOption({
589
737
  xAxis: [
590
738
  applyAxisOverrides({ ...xAxisConfig }, spec.categoryAxisOptions),
@@ -596,6 +744,19 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
596
744
  nameLocation: "middle",
597
745
  nameGap: 45,
598
746
  }, spec.valueAxisOptions, undefined, locale),
747
+ // Secondary value axis on the right for the series that opt in
748
+ // (`useSecondaryAxis`), as the category layout has (#1067).
749
+ ...(hasBubbleSecondaryAxis
750
+ ? [
751
+ applyAxisOverrides({
752
+ type: "value",
753
+ position: "right",
754
+ nameLocation: "middle",
755
+ nameGap: 45,
756
+ splitLine: { show: false },
757
+ }, spec.secondaryValueAxisOptions, undefined, locale),
758
+ ]
759
+ : []),
599
760
  ],
600
761
  legend: applyLegendOverrides({
601
762
  ...legend,
@@ -612,82 +773,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
612
773
  trigger: "item",
613
774
  formatter: tooltipFormatter,
614
775
  },
615
- // Scatter vs bubble — scatter uses small fixed markers
616
- // (optionally connected with a line per scatterStyle); bubble
617
- // applies a size scale on the 3rd dimension. Pre-fix scatter
618
- // routed through the bubble code, producing Y-scaled giant
619
- // circles instead of Excel's small dots.
620
- series: preparedSeries.map((entry, index) => {
621
- const isScatter = spec.chartType === "scatter";
622
- const scatterStyle = spec.scatterStyle ??
623
- (isScatter ? "lineMarker" : undefined);
624
- const seriesEntry = spec.series?.[index];
625
- const seriesColor = seriesEntry?.color;
626
- const perMarker = seriesEntry?.marker;
627
- if (isScatter) {
628
- const symbol = perMarker?.symbol;
629
- const noMarkers = symbol === "none";
630
- const wantsLine = scatterStyle === "line" ||
631
- scatterStyle === "lineMarker" ||
632
- scatterStyle === "smooth" ||
633
- scatterStyle === "smoothMarker";
634
- const wantsMarkers = !noMarkers &&
635
- (scatterStyle === "marker" ||
636
- scatterStyle === "lineMarker" ||
637
- scatterStyle === "smoothMarker" ||
638
- !scatterStyle);
639
- const smooth = scatterStyle === "smooth" || scatterStyle === "smoothMarker";
640
- // Labels hang off the point symbols: a marker-less line keeps
641
- // an invisible 0-size symbol per point when they show (#1049).
642
- const labelsWithoutMarkers = !wantsMarkers &&
643
- Boolean(resolveSeriesDataLabels(spec.dataLabels, seriesEntry)
644
- ?.show);
645
- return wantsLine
646
- ? {
647
- type: "line",
648
- name: entry.name,
649
- data: entry.points,
650
- showSymbol: wantsMarkers || labelsWithoutMarkers,
651
- smooth,
652
- symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
653
- symbolSize: labelsWithoutMarkers
654
- ? 0
655
- : perMarker?.size ?? 6,
656
- ...(seriesColor
657
- ? {
658
- itemStyle: { color: seriesColor },
659
- lineStyle: { color: seriesColor },
660
- }
661
- : {}),
662
- }
663
- : {
664
- type: "scatter",
665
- name: entry.name,
666
- data: entry.points,
667
- symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
668
- symbolSize: perMarker?.size ?? 6,
669
- ...(seriesColor
670
- ? { itemStyle: { color: seriesColor } }
671
- : {}),
672
- };
673
- }
674
- return {
675
- type: chartType,
676
- name: entry.name,
677
- data: entry.points,
678
- symbolSize: (data) => sizeScale(data?.[2] ?? 0),
679
- label: {
680
- show: true,
681
- formatter: (params) => params.name ?? "",
682
- },
683
- ...(seriesColor ? { itemStyle: { color: seriesColor } } : {}),
684
- };
685
- }).map((s, index) => {
686
- const bubbleSeriesLabel = bubbleSeriesLabelFor(index);
687
- return bubbleSeriesLabel
688
- ? { ...s, label: { ...(s.label ?? {}), ...bubbleSeriesLabel } }
689
- : s;
690
- }),
776
+ series: [...bubbleSeries, ...bubbleOverlays],
691
777
  }, {
692
778
  replaceMerge: [
693
779
  "series",
@@ -699,7 +785,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
699
785
  ],
700
786
  });
701
787
  previousChartType.current = spec.chartType;
702
- previousSeriesCount.current = preparedSeries.length;
788
+ previousSeriesCount.current = bubbleSeries.length + bubbleOverlays.length;
703
789
  return;
704
790
  }
705
791
  if (isHierarchy) {
@@ -714,8 +800,17 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
714
800
  hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
715
801
  });
716
802
  hierarchyTitleRef.current = explicitChartTitle ?? headerTitle;
717
- const hierarchyData = buildHierarchyTree(hierarchyRows);
803
+ const hierarchyPalette = getChartPalette(spec, [
804
+ theme.themeColors.accent1,
805
+ theme.themeColors.accent2,
806
+ theme.themeColors.accent3,
807
+ theme.themeColors.accent4,
808
+ theme.themeColors.accent5,
809
+ theme.themeColors.accent6,
810
+ ]);
811
+ const hierarchyData = colorHierarchyNodes(buildHierarchyTree(hierarchyRows), hierarchyPalette);
718
812
  const rootName = hierarchyData.length === 1 ? hierarchyData[0]?.name ?? "" : "";
813
+ const drillDown = Boolean(spec.drillDown);
719
814
  const seriesDisplayName = explicitChartTitle ?? headerTitle ?? rootName ?? "";
720
815
  const hierarchyDepth = getHierarchyDepth(hierarchyData);
721
816
  const levels = buildHierarchyLevels(hierarchyDepth);
@@ -768,6 +863,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
768
863
  label: {
769
864
  show: true,
770
865
  formatter: "{b}",
866
+ ...(spec.chartType === "treemap"
867
+ ? { position: "insideBottomLeft" }
868
+ : {}),
771
869
  ...(hierarchySeriesLabel ?? {}),
772
870
  },
773
871
  upperLabel: spec.chartType === "treemap"
@@ -777,7 +875,16 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
777
875
  }
778
876
  : undefined,
779
877
  levels,
780
- colorMappingBy: "index",
878
+ // Excel has no drill-down on either chart: zooming into a
879
+ // branch on click (and the treemap breadcrumb) is opt-in.
880
+ nodeClick: drillDown
881
+ ? spec.chartType === "treemap"
882
+ ? "zoomToNode"
883
+ : "rootToNode"
884
+ : false,
885
+ ...(spec.chartType === "treemap"
886
+ ? { breadcrumb: { show: drillDown } }
887
+ : { radius: [...SUNBURST_RADIUS] }),
781
888
  },
782
889
  ],
783
890
  }, {
@@ -1176,12 +1283,19 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1176
1283
  };
1177
1284
  chartRef.current?.setOption({
1178
1285
  radar: {
1179
- indicator: indicators.length
1286
+ // The common scale's tick labels along ONE axis, as Excel draws
1287
+ // them: echarts labels every indicator axis, so each indicator
1288
+ // carries its own `axisLabel.show` (an indicator's own options win
1289
+ // over the radar-level ones in echarts' merge) and only the first
1290
+ // shows (#1066 follow-up); hidden with the spec's value axis.
1291
+ indicator: (indicators.length
1180
1292
  ? indicators
1181
- : [{ name: "", min: 0, max: 1 }],
1182
- // The common scale's tick labels along one axis, as Excel draws
1183
- // them (echarts labels the first indicator axis only); hidden
1184
- // with the spec's value axis.
1293
+ : [{ name: "", min: 0, max: 1 }]).map((indicator, index) => ({
1294
+ ...indicator,
1295
+ axisLabel: {
1296
+ show: index === 0 && radarAxisOptions?.visible !== false,
1297
+ },
1298
+ })),
1185
1299
  axisLabel: {
1186
1300
  show: radarAxisOptions?.visible !== false,
1187
1301
  formatter: (value) => formatAxisValue(radarFormats[0], value, locale),
@@ -1549,135 +1663,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1549
1663
  : [];
1550
1664
  if (numericValues.length === 0)
1551
1665
  return;
1552
- const seriesColor = series.color ?? theme.themeColors.accent1;
1553
- const useSecondary = series.useSecondaryAxis ? { yAxisIndex: 1 } : {};
1554
- // Trendlines — one overlay line series per spec.
1555
- for (const tl of series.trendlines ?? []) {
1556
- const result = computeTrendline(numericValues, {
1557
- type: tl.type,
1558
- order: tl.order,
1559
- period: tl.period,
1560
- forecastForward: tl.forecastForward,
1561
- forecastBackward: tl.forecastBackward,
1562
- });
1563
- if (!result || result.points.length < 2)
1564
- continue;
1565
- const tlName = tl.name ??
1566
- `${dataLabel ? dataLabel + " " : ""}${tl.type ?? "linear"} trend`;
1567
- chartSeries.push({
1568
- type: "line",
1569
- name: tlName,
1570
- // Index x lets the trendline overlay the category axis even
1571
- // when forecasting extends beyond the data range.
1572
- data: result.points.map((p) => [p.x, p.y]),
1573
- showSymbol: false,
1574
- smooth: tl.type === "poly",
1575
- lineStyle: {
1576
- color: tl.color ?? seriesColor,
1577
- type: "dashed",
1578
- width: 2,
1579
- },
1580
- itemStyle: { color: tl.color ?? seriesColor },
1581
- tooltip: { show: false },
1582
- z: 5,
1583
- ...useSecondary,
1584
- });
1585
- }
1586
- // Error bars — drawn as a custom series of vertical/horizontal
1587
- // whiskers. valueType "custom" reads per-point magnitudes from the
1588
- // spec's plus/minus ranges, resolved through the same extractor
1589
- // the series values use.
1590
- const magnitudes = computeErrorBars(numericValues, resolveCustomErrorBarValues(series.errorBars, (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy)));
1591
- if (magnitudes) {
1592
- const direction = series.errorBars?.direction ?? "y";
1593
- const noEndCap = series.errorBars?.noEndCap ?? false;
1594
- const errorData = numericValues.map((v, i) => [
1595
- i,
1596
- typeof v === "number" ? v : 0,
1597
- magnitudes.plus[i] ?? 0,
1598
- magnitudes.minus[i] ?? 0,
1599
- ]);
1600
- chartSeries.push({
1601
- type: "custom",
1602
- name: `${dataLabel ? dataLabel + " " : ""}error`,
1603
- data: errorData,
1604
- z: 6,
1605
- tooltip: { show: false },
1606
- ...useSecondary,
1607
- renderItem: (_params, api) => {
1608
- const value = api.value(1);
1609
- const plus = api.value(2);
1610
- const minus = api.value(3);
1611
- const capHalf = 5;
1612
- if (direction === "x") {
1613
- const center = api.coord([value, api.value(0)]);
1614
- const hi = api.coord([value + plus, api.value(0)]);
1615
- const lo = api.coord([value - minus, api.value(0)]);
1616
- const children = [
1617
- {
1618
- type: "line",
1619
- shape: { x1: lo[0], y1: center[1], x2: hi[0], y2: center[1] },
1620
- style: { stroke: seriesColor, lineWidth: 1 },
1621
- },
1622
- ];
1623
- if (!noEndCap) {
1624
- children.push({
1625
- type: "line",
1626
- shape: {
1627
- x1: hi[0],
1628
- y1: center[1] - capHalf,
1629
- x2: hi[0],
1630
- y2: center[1] + capHalf,
1631
- },
1632
- style: { stroke: seriesColor, lineWidth: 1 },
1633
- }, {
1634
- type: "line",
1635
- shape: {
1636
- x1: lo[0],
1637
- y1: center[1] - capHalf,
1638
- x2: lo[0],
1639
- y2: center[1] + capHalf,
1640
- },
1641
- style: { stroke: seriesColor, lineWidth: 1 },
1642
- });
1643
- }
1644
- return { type: "group", children };
1645
- }
1646
- const center = api.coord([api.value(0), value]);
1647
- const hi = api.coord([api.value(0), value + plus]);
1648
- const lo = api.coord([api.value(0), value - minus]);
1649
- const children = [
1650
- {
1651
- type: "line",
1652
- shape: { x1: center[0], y1: lo[1], x2: center[0], y2: hi[1] },
1653
- style: { stroke: seriesColor, lineWidth: 1 },
1654
- },
1655
- ];
1656
- if (!noEndCap) {
1657
- children.push({
1658
- type: "line",
1659
- shape: {
1660
- x1: center[0] - capHalf,
1661
- y1: hi[1],
1662
- x2: center[0] + capHalf,
1663
- y2: hi[1],
1664
- },
1665
- style: { stroke: seriesColor, lineWidth: 1 },
1666
- }, {
1667
- type: "line",
1668
- shape: {
1669
- x1: center[0] - capHalf,
1670
- y1: lo[1],
1671
- x2: center[0] + capHalf,
1672
- y2: lo[1],
1673
- },
1674
- style: { stroke: seriesColor, lineWidth: 1 },
1675
- });
1676
- }
1677
- return { type: "group", children };
1678
- },
1679
- });
1680
- }
1666
+ chartSeries.push(...buildEchartsOverlaySeries({
1667
+ series,
1668
+ values: numericValues,
1669
+ // A horizontal bar keeps its categories on y (#1061).
1670
+ horizontal: isHorizontalBarChart,
1671
+ dataLabel,
1672
+ seriesColor: series.color ?? theme.themeColors.accent1,
1673
+ resolveRange: (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy),
1674
+ }));
1681
1675
  };
1682
1676
  // Excel `<c:overlap>` / `<c:gapWidth>` → echarts barGap /
1683
1677
  // barCategoryGap (see utils/bar-layout.ts for the exact mapping).
@@ -2150,17 +2144,30 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2150
2144
  nameGap: 30,
2151
2145
  });
2152
2146
  chartSeries.length = 0;
2147
+ // The spacer bars are layout only: no labels, no tooltip entry.
2153
2148
  chartSeries.push({
2154
2149
  type: "bar",
2150
+ id: WATERFALL_PLACEHOLDER_ID,
2155
2151
  stack: "waterfall",
2156
2152
  itemStyle: { color: "transparent" },
2157
2153
  emphasis: { itemStyle: { color: "transparent" } },
2158
2154
  data: steps.map((s) => s.placeholder),
2159
2155
  silent: true,
2156
+ label: { show: false },
2157
+ tooltip: { show: false },
2160
2158
  });
2161
2159
  const accent = seriesEntry?.color ?? theme.themeColors.accent1;
2162
2160
  const decreaseColor = seriesEntry?.negativeColor ?? theme.themeColors.accent2 ?? accent;
2163
2161
  const totalColor = theme.themeColors.accent3 ?? accent;
2162
+ // Per-bar colours (`<c:dPt>`) win over the increase / decrease /
2163
+ // total colours — an importer gives totals the workbook's own
2164
+ // colour and keeps bars recoloured in Excel.
2165
+ const pointColors = new Map();
2166
+ for (const dp of seriesEntry?.dataPointColors ?? []) {
2167
+ if (typeof dp?.idx === "number" && typeof dp?.color === "string") {
2168
+ pointColors.set(dp.idx, dp.color);
2169
+ }
2170
+ }
2164
2171
  // Excel-style connector lines: a horizontal segment at the running
2165
2172
  // total after bar i, spanning across to bar i+1. Within the bars
2166
2173
  // the segment overlaps their top/bottom edges, so only the gap
@@ -2171,16 +2178,22 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2171
2178
  ]);
2172
2179
  chartSeries.push({
2173
2180
  type: "bar",
2181
+ id: chartSeriesId(0, 0),
2174
2182
  stack: "waterfall",
2175
2183
  name: seriesEntry?.dataLabel ?? "",
2184
+ // A bar's height is the step's magnitude; the signed source value
2185
+ // rides along as `originalValue` so labels and tooltips read
2186
+ // "-90" for a decrease, like Excel.
2176
2187
  data: steps.map((s, i) => ({
2177
2188
  value: s.value,
2189
+ originalValue: rawValues[i],
2178
2190
  itemStyle: {
2179
- color: s.isTotal
2180
- ? totalColor
2181
- : rawValues[i] < 0
2182
- ? decreaseColor
2183
- : accent,
2191
+ color: pointColors.get(i) ??
2192
+ (s.isTotal
2193
+ ? totalColor
2194
+ : rawValues[i] < 0
2195
+ ? decreaseColor
2196
+ : accent),
2184
2197
  },
2185
2198
  })),
2186
2199
  markLine: {
@@ -2210,14 +2223,64 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2210
2223
  : [];
2211
2224
  chartDomains.length = 0;
2212
2225
  chartSeries.length = 0;
2226
+ // Excel draws every stage in the series colour (only `<c:dPt>`
2227
+ // recolours a stage), keeps the rows' order top to bottom, and
2228
+ // prints the values inside the bars (#1069).
2229
+ const funnelPalette = getChartPalette(spec, [
2230
+ theme.themeColors.accent1,
2231
+ theme.themeColors.accent2,
2232
+ theme.themeColors.accent3,
2233
+ theme.themeColors.accent4,
2234
+ theme.themeColors.accent5,
2235
+ theme.themeColors.accent6,
2236
+ ]);
2237
+ const funnelColor = seriesEntry?.color ?? chartPaletteColorAt(funnelPalette, 0);
2238
+ const stageColors = new Map();
2239
+ for (const dp of seriesEntry?.dataPointColors ?? []) {
2240
+ if (typeof dp?.idx === "number" && typeof dp?.color === "string") {
2241
+ stageColors.set(dp.idx, dp.color);
2242
+ }
2243
+ }
2244
+ const funnelData = rawValues.map((value, i) => ({
2245
+ name: labels[i] ?? String(i),
2246
+ value,
2247
+ }));
2248
+ const funnelLayout = {
2249
+ sort: "none",
2250
+ left: "22%",
2251
+ right: "10%",
2252
+ top: 60,
2253
+ bottom: 20,
2254
+ };
2213
2255
  chartSeries.push({
2214
2256
  type: "funnel",
2257
+ id: chartSeriesId(0, 0),
2215
2258
  name: seriesEntry?.dataLabel ?? "",
2216
- data: rawValues.map((value, i) => ({
2217
- name: labels[i] ?? String(i),
2218
- value,
2219
- })),
2220
- label: { show: true, position: "inside" },
2259
+ data: funnelData,
2260
+ ...funnelLayout,
2261
+ itemStyle: {
2262
+ color: (params) => stageColors.get(params.dataIndex) ?? funnelColor,
2263
+ },
2264
+ label: {
2265
+ show: true,
2266
+ position: "inside",
2267
+ formatter: (params) => formatAxisValue(undefined, params.value, locale),
2268
+ },
2269
+ });
2270
+ // The stage names sit to the left of the bars, where Excel's
2271
+ // category axis puts them: an invisible twin funnel carries them.
2272
+ chartSeries.push({
2273
+ type: "funnel",
2274
+ id: FUNNEL_NAMES_ID,
2275
+ data: funnelData,
2276
+ ...funnelLayout,
2277
+ silent: true,
2278
+ itemStyle: { color: "transparent", borderWidth: 0 },
2279
+ emphasis: { disabled: true },
2280
+ tooltip: { show: false },
2281
+ label: { show: true, position: "left", formatter: "{b}" },
2282
+ labelLine: { show: false },
2283
+ z: 1,
2221
2284
  });
2222
2285
  legend.data = [];
2223
2286
  }
@@ -2551,6 +2614,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2551
2614
  }
2552
2615
  const seriesWithLabels = chartSeries.map((s) => {
2553
2616
  const next = { ...s };
2617
+ if (DECORATION_SERIES_IDS.has(s?.id)) {
2618
+ return next;
2619
+ }
2554
2620
  const seriesLabelOption = seriesLabelOptionFor(s);
2555
2621
  if (seriesLabelOption) {
2556
2622
  next.label = { ...(s?.label ?? {}), ...seriesLabelOption };
@@ -2566,10 +2632,25 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2566
2632
  const seriesTooltipValueFormatter = VALUE_TOOLTIP_SERIES_TYPES.has(s?.type)
2567
2633
  ? valueAxisLabelFormatter(seriesNumberFormatFor(s), locale)
2568
2634
  : undefined;
2569
- if (seriesTooltipValueFormatter && s?.tooltip?.show !== false) {
2635
+ // A data item carrying `originalValue` (a waterfall step's signed
2636
+ // source value, a percent-stacked bar's raw amount) reads as that
2637
+ // value in the tooltip, like its data label.
2638
+ const originalValueAt = (dataIndex) => {
2639
+ const item = Array.isArray(s?.data) ? s.data[dataIndex] : undefined;
2640
+ return item !== null && typeof item === "object"
2641
+ ? item.originalValue
2642
+ : undefined;
2643
+ };
2644
+ const carriesOriginalValues = VALUE_TOOLTIP_SERIES_TYPES.has(s?.type) &&
2645
+ Array.isArray(s?.data) &&
2646
+ s.data.some((item) => item !== null && typeof item === "object" && "originalValue" in item);
2647
+ if ((seriesTooltipValueFormatter || carriesOriginalValues) &&
2648
+ s?.tooltip?.show !== false) {
2649
+ const formatValue = seriesTooltipValueFormatter ??
2650
+ ((value) => formatAxisValue(undefined, value, locale));
2570
2651
  next.tooltip = {
2571
2652
  ...(s?.tooltip ?? {}),
2572
- valueFormatter: seriesTooltipValueFormatter,
2653
+ valueFormatter: (value, dataIndex) => formatValue(originalValueAt(dataIndex) ?? value),
2573
2654
  };
2574
2655
  }
2575
2656
  if (next.type === "pie") {