@rowsncolumns/charts 28.1.8 → 28.1.11

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 +538 -430
  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 +333 -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 +15 -3
  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 +381 -251
  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 +253 -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 +15 -3
  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,54 @@ 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. Index 0 is echarts' virtual root, 1 the
108
+ // top-level categories, 2+ the levels beneath them.
106
109
  const HIERARCHY_LEVEL_STYLES = [
107
110
  {
108
111
  itemStyle: {
109
- gapWidth: 1,
110
- borderColorSaturation: 0.6,
111
- },
112
- upperLabel: { show: false },
113
- },
114
- {
115
- colorSaturation: [0.35, 0.65],
116
- itemStyle: {
117
- gapWidth: 1,
118
- borderColorSaturation: 0.6,
112
+ gapWidth: 2,
113
+ borderColor: "#ffffff",
114
+ borderWidth: 0,
119
115
  },
120
116
  },
121
117
  {
122
- colorSaturation: [0.45, 0.75],
123
118
  itemStyle: {
124
119
  gapWidth: 1,
125
- borderColorSaturation: 0.6,
120
+ borderColor: "#ffffff",
121
+ borderWidth: 1,
126
122
  },
127
123
  },
128
124
  ];
125
+ /** Height of the strip a treemap category name is drawn in (Excel's "Banner"). */
126
+ const TREEMAP_BANNER_HEIGHT = 20;
127
+ /**
128
+ * Excel's treemap `parentLabelLayout`: the top-level category names are
129
+ * drawn in a banner strip (or not at all); the levels between the categories
130
+ * and the leaves are never labelled, so small groups keep their height for
131
+ * the leaf cells (rowsncolumns/spreadsheet#1072). Excel's default
132
+ * `overlapping` writes the name over the group's top-left corner, which an
133
+ * echarts treemap cannot draw (an upper label always reserves its strip and
134
+ * is dropped when the strip is too small for the text), so it draws as a
135
+ * banner.
136
+ */
137
+ const treemapBannerShown = (layout) => layout !== "none";
138
+ /**
139
+ * Colour a hierarchy like Excel: each top-level node takes the next
140
+ * palette colour and every descendant inherits it (#1072).
141
+ */
142
+ const colorHierarchyNodes = (nodes, palette) => nodes.map((node, index) => {
143
+ const color = chartPaletteColorAt(palette, index);
144
+ const paint = (n) => ({
145
+ ...n,
146
+ itemStyle: { color },
147
+ ...(n.children ? { children: n.children.map(paint) } : {}),
148
+ });
149
+ return paint(node);
150
+ });
151
+ /** Sunburst rings: Excel leaves the centre empty, so even the first level is a ring (#1073). */
152
+ const SUNBURST_RADIUS = ["15%", "80%"];
129
153
  const getHierarchyDepth = (nodes, depth = 1) => {
130
154
  let maxDepth = depth;
131
155
  nodes.forEach((node) => {
@@ -159,23 +183,46 @@ const flattenChartDataValues = (chartData, extractor, hiddenStrategy, skipFirstR
159
183
  });
160
184
  return values;
161
185
  };
162
- const buildHierarchyLevels = (maxDepth) => {
186
+ const buildHierarchyLevels = (maxDepth, options) => {
163
187
  if (maxDepth <= 0)
164
188
  return undefined;
165
189
  const levels = [];
166
- for (let i = 0; i < maxDepth; i++) {
190
+ // One entry per depth, the virtual root included, so every level's upper
191
+ // label is explicit: the categories' banner on level 1 only.
192
+ for (let i = 0; i <= maxDepth; i++) {
167
193
  const baseStyle = HIERARCHY_LEVEL_STYLES[Math.min(i, HIERARCHY_LEVEL_STYLES.length - 1)];
168
194
  levels.push({
169
195
  ...baseStyle,
170
196
  itemStyle: {
171
197
  ...baseStyle.itemStyle,
172
198
  },
199
+ ...(options.treemap
200
+ ? {
201
+ upperLabel: i === 1 && options.banner
202
+ ? {
203
+ show: true,
204
+ height: TREEMAP_BANNER_HEIGHT,
205
+ padding: [0, 0, 0, 4],
206
+ }
207
+ : { show: false },
208
+ }
209
+ : {}),
173
210
  });
174
211
  }
175
212
  return levels;
176
213
  };
177
214
  /** Series whose tooltip value is one number on a value axis. */
178
215
  const VALUE_TOOLTIP_SERIES_TYPES = new Set(["bar", "line", "scatter", "funnel"]);
216
+ /** The transparent spacer bars a waterfall stacks its visible bars on. */
217
+ const WATERFALL_PLACEHOLDER_ID = "rnc-waterfall-placeholder";
218
+ /** The invisible funnel that carries the stage names beside the bars. */
219
+ const FUNNEL_NAMES_ID = "rnc-funnel-names";
220
+ /**
221
+ * Layout-only series: they carry no data of their own, so they get no
222
+ * data labels and no tooltip entry (#1070 labelled every waterfall bar
223
+ * with its spacer's height too).
224
+ */
225
+ const DECORATION_SERIES_IDS = new Set([WATERFALL_PLACEHOLDER_ID, FUNNEL_NAMES_ID]);
179
226
  const defaultLegendConfig = {
180
227
  padding: 10,
181
228
  itemWidth: 10,
@@ -556,7 +603,38 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
556
603
  sizeValues: sharedSizeValues,
557
604
  series: seriesInputs,
558
605
  });
559
- const sizeScale = createBubbleSizeScale(sizeValues);
606
+ // Bubble diameters scale from zero (area by default, width when
607
+ // `sizeRepresents` says so) and the largest bubble is a fraction of
608
+ // the plot area — the grid keeps ~85% of the width and ~75% of the
609
+ // height for the plot (#1068).
610
+ const sizeScale = createBubbleSizeScale(sizeValues, {
611
+ sizeRepresents: spec.sizeRepresents,
612
+ bubbleScale: spec.bubbleScale,
613
+ plotMinSide: Math.min((width ?? 0) * 0.85, (height ?? 0) * 0.75),
614
+ });
615
+ const bubblePalette = getChartPalette(spec, [
616
+ theme.themeColors.accent1,
617
+ theme.themeColors.accent2,
618
+ theme.themeColors.accent3,
619
+ theme.themeColors.accent4,
620
+ theme.themeColors.accent5,
621
+ theme.themeColors.accent6,
622
+ ]);
623
+ // Per-point colours (`<c:dPt>`) key on the point's source row, which
624
+ // `BubbleSeriesPoint.index` keeps (points without a Y are skipped).
625
+ const bubbleItemColor = (seriesEntry, index) => {
626
+ const base = seriesEntry?.color ?? chartPaletteColorAt(bubblePalette, index);
627
+ const overrides = new Map();
628
+ for (const dp of seriesEntry?.dataPointColors ?? []) {
629
+ if (typeof dp?.idx === "number" && typeof dp?.color === "string") {
630
+ overrides.set(dp.idx, dp.color);
631
+ }
632
+ }
633
+ return overrides.size
634
+ ? (params) => overrides.get(params?.data?.index) ?? base
635
+ : base;
636
+ };
637
+ const hasBubbleSecondaryAxis = (spec.series ?? []).some((seriesEntry) => seriesEntry?.useSecondaryAxis);
560
638
  const xAxisConfig = categories.length > 0
561
639
  ? {
562
640
  type: "category",
@@ -585,6 +663,102 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
585
663
  // non-bubble layout below. A series' own `dataLabels` replace the
586
664
  // chart-level ones for it (#1059).
587
665
  const bubbleSeriesLabelFor = (index) => buildSeriesLabelOption(resolveSeriesDataLabels(spec.dataLabels, spec.series?.[index]), undefined, locale);
666
+ // Scatter vs bubble — scatter uses small fixed markers (optionally
667
+ // connected with a line per scatterStyle); bubble applies a size
668
+ // scale on the 3rd dimension. Pre-fix scatter routed through the
669
+ // bubble code, producing Y-scaled giant circles instead of Excel's
670
+ // small dots.
671
+ const bubbleSeries = preparedSeries
672
+ .map((entry, index) => {
673
+ const isScatter = spec.chartType === "scatter";
674
+ const scatterStyle = spec.scatterStyle ?? (isScatter ? "lineMarker" : undefined);
675
+ const seriesEntry = spec.series?.[index];
676
+ const seriesColor = seriesEntry?.color;
677
+ const perMarker = seriesEntry?.marker;
678
+ const itemColor = bubbleItemColor(seriesEntry, index);
679
+ const onSecondary = seriesEntry?.useSecondaryAxis ? { yAxisIndex: 1 } : {};
680
+ if (isScatter) {
681
+ const symbol = perMarker?.symbol;
682
+ const noMarkers = symbol === "none";
683
+ const wantsLine = scatterStyle === "line" ||
684
+ scatterStyle === "lineMarker" ||
685
+ scatterStyle === "smooth" ||
686
+ scatterStyle === "smoothMarker";
687
+ const wantsMarkers = !noMarkers &&
688
+ (scatterStyle === "marker" ||
689
+ scatterStyle === "lineMarker" ||
690
+ scatterStyle === "smoothMarker" ||
691
+ !scatterStyle);
692
+ const smooth = scatterStyle === "smooth" || scatterStyle === "smoothMarker";
693
+ // Labels hang off the point symbols: a marker-less line keeps
694
+ // an invisible 0-size symbol per point when they show (#1049).
695
+ const labelsWithoutMarkers = !wantsMarkers &&
696
+ Boolean(resolveSeriesDataLabels(spec.dataLabels, seriesEntry)
697
+ ?.show);
698
+ // The series colour paints the line; the markers take the
699
+ // per-point overrides when the series has any.
700
+ const markerStyle = seriesColor || typeof itemColor === "function"
701
+ ? { itemStyle: { color: itemColor } }
702
+ : {};
703
+ return wantsLine
704
+ ? {
705
+ type: "line",
706
+ name: entry.name,
707
+ data: entry.points,
708
+ showSymbol: wantsMarkers || labelsWithoutMarkers,
709
+ smooth,
710
+ symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
711
+ symbolSize: labelsWithoutMarkers ? 0 : perMarker?.size ?? 6,
712
+ ...markerStyle,
713
+ ...(seriesColor ? { lineStyle: { color: seriesColor } } : {}),
714
+ ...onSecondary,
715
+ }
716
+ : {
717
+ type: "scatter",
718
+ name: entry.name,
719
+ data: entry.points,
720
+ symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
721
+ symbolSize: perMarker?.size ?? 6,
722
+ ...markerStyle,
723
+ ...onSecondary,
724
+ };
725
+ }
726
+ return {
727
+ type: chartType,
728
+ name: entry.name,
729
+ data: entry.points,
730
+ symbolSize: (data) => sizeScale(data?.[2] ?? 0),
731
+ label: {
732
+ show: true,
733
+ formatter: (params) => params.name ?? "",
734
+ },
735
+ ...(seriesColor || typeof itemColor === "function"
736
+ ? { itemStyle: { color: itemColor } }
737
+ : {}),
738
+ ...onSecondary,
739
+ };
740
+ })
741
+ .map((s, index) => {
742
+ const bubbleSeriesLabel = bubbleSeriesLabelFor(index);
743
+ return bubbleSeriesLabel
744
+ ? { ...s, label: { ...(s.label ?? {}), ...bubbleSeriesLabel } }
745
+ : s;
746
+ });
747
+ // Trendlines and error bars on the real (x, y) points: the fit is a
748
+ // regression of Y on X, not on the point index (#1067).
749
+ const bubbleOverlays = preparedSeries.flatMap((entry, index) => {
750
+ const seriesEntry = spec.series?.[index];
751
+ if (!seriesEntry)
752
+ return [];
753
+ return buildEchartsOverlaySeries({
754
+ series: seriesEntry,
755
+ values: entry.points.map((point) => point.value[1]),
756
+ xValues: entry.points.map((point) => point.value[0]),
757
+ dataLabel: entry.name,
758
+ seriesColor: seriesEntry.color ?? chartPaletteColorAt(bubblePalette, index),
759
+ resolveRange: (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy),
760
+ });
761
+ });
588
762
  chartRef.current?.setOption({
589
763
  xAxis: [
590
764
  applyAxisOverrides({ ...xAxisConfig }, spec.categoryAxisOptions),
@@ -596,6 +770,19 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
596
770
  nameLocation: "middle",
597
771
  nameGap: 45,
598
772
  }, spec.valueAxisOptions, undefined, locale),
773
+ // Secondary value axis on the right for the series that opt in
774
+ // (`useSecondaryAxis`), as the category layout has (#1067).
775
+ ...(hasBubbleSecondaryAxis
776
+ ? [
777
+ applyAxisOverrides({
778
+ type: "value",
779
+ position: "right",
780
+ nameLocation: "middle",
781
+ nameGap: 45,
782
+ splitLine: { show: false },
783
+ }, spec.secondaryValueAxisOptions, undefined, locale),
784
+ ]
785
+ : []),
599
786
  ],
600
787
  legend: applyLegendOverrides({
601
788
  ...legend,
@@ -612,82 +799,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
612
799
  trigger: "item",
613
800
  formatter: tooltipFormatter,
614
801
  },
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
- }),
802
+ series: [...bubbleSeries, ...bubbleOverlays],
691
803
  }, {
692
804
  replaceMerge: [
693
805
  "series",
@@ -699,7 +811,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
699
811
  ],
700
812
  });
701
813
  previousChartType.current = spec.chartType;
702
- previousSeriesCount.current = preparedSeries.length;
814
+ previousSeriesCount.current = bubbleSeries.length + bubbleOverlays.length;
703
815
  return;
704
816
  }
705
817
  if (isHierarchy) {
@@ -714,11 +826,43 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
714
826
  hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
715
827
  });
716
828
  hierarchyTitleRef.current = explicitChartTitle ?? headerTitle;
717
- const hierarchyData = buildHierarchyTree(hierarchyRows);
829
+ const hierarchyPalette = getChartPalette(spec, [
830
+ theme.themeColors.accent1,
831
+ theme.themeColors.accent2,
832
+ theme.themeColors.accent3,
833
+ theme.themeColors.accent4,
834
+ theme.themeColors.accent5,
835
+ theme.themeColors.accent6,
836
+ ]);
837
+ const hierarchyData = colorHierarchyNodes(buildHierarchyTree(hierarchyRows), hierarchyPalette);
718
838
  const rootName = hierarchyData.length === 1 ? hierarchyData[0]?.name ?? "" : "";
839
+ const drillDown = Boolean(spec.drillDown);
719
840
  const seriesDisplayName = explicitChartTitle ?? headerTitle ?? rootName ?? "";
720
841
  const hierarchyDepth = getHierarchyDepth(hierarchyData);
721
- const levels = buildHierarchyLevels(hierarchyDepth);
842
+ const isTreemap = spec.chartType === "treemap";
843
+ const banner = isTreemap && treemapBannerShown(spec.parentLabelLayout);
844
+ const levels = buildHierarchyLevels(hierarchyDepth, {
845
+ treemap: isTreemap,
846
+ banner,
847
+ });
848
+ // Excel lists the top-level categories with their colours in the
849
+ // legend. An echarts legend cannot address treemap nodes, so each
850
+ // category gets a data-less placeholder series carrying its name and
851
+ // colour; `selectedMode: false` keeps the swatches from toggling
852
+ // anything.
853
+ const hierarchyLegend = applyLegendOverrides({ ...defaultLegendConfig, show: false }, spec.legend);
854
+ const legendShown = hierarchyLegend.show !== false;
855
+ const categoryLegendSeries = legendShown
856
+ ? hierarchyData.map((node) => ({
857
+ type: "bar",
858
+ id: `rnc-hierarchy-legend-${node.id ?? node.name}`,
859
+ name: node.name,
860
+ data: [],
861
+ silent: true,
862
+ itemStyle: { color: node.itemStyle?.color },
863
+ tooltip: { show: false },
864
+ }))
865
+ : [];
722
866
  // Sizes format like the cells they were read from (Excel's "Linked
723
867
  // to source"), unless the data labels carry their own pattern.
724
868
  const sizeNumberFormat = resolveSourceNumberFormat(spec.sizeData?.[0]
@@ -748,7 +892,11 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
748
892
  show: false,
749
893
  },
750
894
  ],
751
- legend: applyLegendOverrides({ ...defaultLegendConfig, show: false }, spec.legend),
895
+ legend: {
896
+ ...hierarchyLegend,
897
+ data: hierarchyData.map((node) => node.name),
898
+ selectedMode: false,
899
+ },
752
900
  grid: {
753
901
  left: 0,
754
902
  right: 0,
@@ -768,17 +916,25 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
768
916
  label: {
769
917
  show: true,
770
918
  formatter: "{b}",
919
+ ...(isTreemap ? { position: "insideBottomLeft" } : {}),
771
920
  ...(hierarchySeriesLabel ?? {}),
772
921
  },
773
- upperLabel: spec.chartType === "treemap"
774
- ? {
775
- show: true,
776
- padding: [0, 0, 0, 4],
777
- }
778
- : undefined,
922
+ // Only `levels[1]` (the categories) may carry a banner; see
923
+ // buildHierarchyLevels.
924
+ upperLabel: isTreemap ? { show: false } : undefined,
779
925
  levels,
780
- colorMappingBy: "index",
926
+ // Excel has no drill-down on either chart: zooming into a
927
+ // branch on click (and the treemap breadcrumb) is opt-in.
928
+ nodeClick: drillDown
929
+ ? spec.chartType === "treemap"
930
+ ? "zoomToNode"
931
+ : "rootToNode"
932
+ : false,
933
+ ...(isTreemap
934
+ ? { breadcrumb: { show: drillDown } }
935
+ : { radius: [...SUNBURST_RADIUS] }),
781
936
  },
937
+ ...categoryLegendSeries,
782
938
  ],
783
939
  }, {
784
940
  replaceMerge: [
@@ -791,7 +947,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
791
947
  ],
792
948
  });
793
949
  previousChartType.current = spec.chartType;
794
- previousSeriesCount.current = 1;
950
+ previousSeriesCount.current = 1 + categoryLegendSeries.length;
795
951
  return;
796
952
  }
797
953
  else {
@@ -1176,12 +1332,19 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1176
1332
  };
1177
1333
  chartRef.current?.setOption({
1178
1334
  radar: {
1179
- indicator: indicators.length
1335
+ // The common scale's tick labels along ONE axis, as Excel draws
1336
+ // them: echarts labels every indicator axis, so each indicator
1337
+ // carries its own `axisLabel.show` (an indicator's own options win
1338
+ // over the radar-level ones in echarts' merge) and only the first
1339
+ // shows (#1066 follow-up); hidden with the spec's value axis.
1340
+ indicator: (indicators.length
1180
1341
  ? 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.
1342
+ : [{ name: "", min: 0, max: 1 }]).map((indicator, index) => ({
1343
+ ...indicator,
1344
+ axisLabel: {
1345
+ show: index === 0 && radarAxisOptions?.visible !== false,
1346
+ },
1347
+ })),
1185
1348
  axisLabel: {
1186
1349
  show: radarAxisOptions?.visible !== false,
1187
1350
  formatter: (value) => formatAxisValue(radarFormats[0], value, locale),
@@ -1549,135 +1712,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1549
1712
  : [];
1550
1713
  if (numericValues.length === 0)
1551
1714
  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
- }
1715
+ chartSeries.push(...buildEchartsOverlaySeries({
1716
+ series,
1717
+ values: numericValues,
1718
+ // A horizontal bar keeps its categories on y (#1061).
1719
+ horizontal: isHorizontalBarChart,
1720
+ dataLabel,
1721
+ seriesColor: series.color ?? theme.themeColors.accent1,
1722
+ resolveRange: (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy),
1723
+ }));
1681
1724
  };
1682
1725
  // Excel `<c:overlap>` / `<c:gapWidth>` → echarts barGap /
1683
1726
  // barCategoryGap (see utils/bar-layout.ts for the exact mapping).
@@ -2150,17 +2193,30 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2150
2193
  nameGap: 30,
2151
2194
  });
2152
2195
  chartSeries.length = 0;
2196
+ // The spacer bars are layout only: no labels, no tooltip entry.
2153
2197
  chartSeries.push({
2154
2198
  type: "bar",
2199
+ id: WATERFALL_PLACEHOLDER_ID,
2155
2200
  stack: "waterfall",
2156
2201
  itemStyle: { color: "transparent" },
2157
2202
  emphasis: { itemStyle: { color: "transparent" } },
2158
2203
  data: steps.map((s) => s.placeholder),
2159
2204
  silent: true,
2205
+ label: { show: false },
2206
+ tooltip: { show: false },
2160
2207
  });
2161
2208
  const accent = seriesEntry?.color ?? theme.themeColors.accent1;
2162
2209
  const decreaseColor = seriesEntry?.negativeColor ?? theme.themeColors.accent2 ?? accent;
2163
2210
  const totalColor = theme.themeColors.accent3 ?? accent;
2211
+ // Per-bar colours (`<c:dPt>`) win over the increase / decrease /
2212
+ // total colours — an importer gives totals the workbook's own
2213
+ // colour and keeps bars recoloured in Excel.
2214
+ const pointColors = new Map();
2215
+ for (const dp of seriesEntry?.dataPointColors ?? []) {
2216
+ if (typeof dp?.idx === "number" && typeof dp?.color === "string") {
2217
+ pointColors.set(dp.idx, dp.color);
2218
+ }
2219
+ }
2164
2220
  // Excel-style connector lines: a horizontal segment at the running
2165
2221
  // total after bar i, spanning across to bar i+1. Within the bars
2166
2222
  // the segment overlaps their top/bottom edges, so only the gap
@@ -2171,16 +2227,22 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2171
2227
  ]);
2172
2228
  chartSeries.push({
2173
2229
  type: "bar",
2230
+ id: chartSeriesId(0, 0),
2174
2231
  stack: "waterfall",
2175
2232
  name: seriesEntry?.dataLabel ?? "",
2233
+ // A bar's height is the step's magnitude; the signed source value
2234
+ // rides along as `originalValue` so labels and tooltips read
2235
+ // "-90" for a decrease, like Excel.
2176
2236
  data: steps.map((s, i) => ({
2177
2237
  value: s.value,
2238
+ originalValue: rawValues[i],
2178
2239
  itemStyle: {
2179
- color: s.isTotal
2180
- ? totalColor
2181
- : rawValues[i] < 0
2182
- ? decreaseColor
2183
- : accent,
2240
+ color: pointColors.get(i) ??
2241
+ (s.isTotal
2242
+ ? totalColor
2243
+ : rawValues[i] < 0
2244
+ ? decreaseColor
2245
+ : accent),
2184
2246
  },
2185
2247
  })),
2186
2248
  markLine: {
@@ -2210,14 +2272,64 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2210
2272
  : [];
2211
2273
  chartDomains.length = 0;
2212
2274
  chartSeries.length = 0;
2275
+ // Excel draws every stage in the series colour (only `<c:dPt>`
2276
+ // recolours a stage), keeps the rows' order top to bottom, and
2277
+ // prints the values inside the bars (#1069).
2278
+ const funnelPalette = getChartPalette(spec, [
2279
+ theme.themeColors.accent1,
2280
+ theme.themeColors.accent2,
2281
+ theme.themeColors.accent3,
2282
+ theme.themeColors.accent4,
2283
+ theme.themeColors.accent5,
2284
+ theme.themeColors.accent6,
2285
+ ]);
2286
+ const funnelColor = seriesEntry?.color ?? chartPaletteColorAt(funnelPalette, 0);
2287
+ const stageColors = new Map();
2288
+ for (const dp of seriesEntry?.dataPointColors ?? []) {
2289
+ if (typeof dp?.idx === "number" && typeof dp?.color === "string") {
2290
+ stageColors.set(dp.idx, dp.color);
2291
+ }
2292
+ }
2293
+ const funnelData = rawValues.map((value, i) => ({
2294
+ name: labels[i] ?? String(i),
2295
+ value,
2296
+ }));
2297
+ const funnelLayout = {
2298
+ sort: "none",
2299
+ left: "22%",
2300
+ right: "10%",
2301
+ top: 60,
2302
+ bottom: 20,
2303
+ };
2213
2304
  chartSeries.push({
2214
2305
  type: "funnel",
2306
+ id: chartSeriesId(0, 0),
2215
2307
  name: seriesEntry?.dataLabel ?? "",
2216
- data: rawValues.map((value, i) => ({
2217
- name: labels[i] ?? String(i),
2218
- value,
2219
- })),
2220
- label: { show: true, position: "inside" },
2308
+ data: funnelData,
2309
+ ...funnelLayout,
2310
+ itemStyle: {
2311
+ color: (params) => stageColors.get(params.dataIndex) ?? funnelColor,
2312
+ },
2313
+ label: {
2314
+ show: true,
2315
+ position: "inside",
2316
+ formatter: (params) => formatAxisValue(undefined, params.value, locale),
2317
+ },
2318
+ });
2319
+ // The stage names sit to the left of the bars, where Excel's
2320
+ // category axis puts them: an invisible twin funnel carries them.
2321
+ chartSeries.push({
2322
+ type: "funnel",
2323
+ id: FUNNEL_NAMES_ID,
2324
+ data: funnelData,
2325
+ ...funnelLayout,
2326
+ silent: true,
2327
+ itemStyle: { color: "transparent", borderWidth: 0 },
2328
+ emphasis: { disabled: true },
2329
+ tooltip: { show: false },
2330
+ label: { show: true, position: "left", formatter: "{b}" },
2331
+ labelLine: { show: false },
2332
+ z: 1,
2221
2333
  });
2222
2334
  legend.data = [];
2223
2335
  }
@@ -2551,6 +2663,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2551
2663
  }
2552
2664
  const seriesWithLabels = chartSeries.map((s) => {
2553
2665
  const next = { ...s };
2666
+ if (DECORATION_SERIES_IDS.has(s?.id)) {
2667
+ return next;
2668
+ }
2554
2669
  const seriesLabelOption = seriesLabelOptionFor(s);
2555
2670
  if (seriesLabelOption) {
2556
2671
  next.label = { ...(s?.label ?? {}), ...seriesLabelOption };
@@ -2566,10 +2681,25 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2566
2681
  const seriesTooltipValueFormatter = VALUE_TOOLTIP_SERIES_TYPES.has(s?.type)
2567
2682
  ? valueAxisLabelFormatter(seriesNumberFormatFor(s), locale)
2568
2683
  : undefined;
2569
- if (seriesTooltipValueFormatter && s?.tooltip?.show !== false) {
2684
+ // A data item carrying `originalValue` (a waterfall step's signed
2685
+ // source value, a percent-stacked bar's raw amount) reads as that
2686
+ // value in the tooltip, like its data label.
2687
+ const originalValueAt = (dataIndex) => {
2688
+ const item = Array.isArray(s?.data) ? s.data[dataIndex] : undefined;
2689
+ return item !== null && typeof item === "object"
2690
+ ? item.originalValue
2691
+ : undefined;
2692
+ };
2693
+ const carriesOriginalValues = VALUE_TOOLTIP_SERIES_TYPES.has(s?.type) &&
2694
+ Array.isArray(s?.data) &&
2695
+ s.data.some((item) => item !== null && typeof item === "object" && "originalValue" in item);
2696
+ if ((seriesTooltipValueFormatter || carriesOriginalValues) &&
2697
+ s?.tooltip?.show !== false) {
2698
+ const formatValue = seriesTooltipValueFormatter ??
2699
+ ((value) => formatAxisValue(undefined, value, locale));
2570
2700
  next.tooltip = {
2571
2701
  ...(s?.tooltip ?? {}),
2572
- valueFormatter: seriesTooltipValueFormatter,
2702
+ valueFormatter: (value, dataIndex) => formatValue(originalValueAt(dataIndex) ?? value),
2573
2703
  };
2574
2704
  }
2575
2705
  if (next.type === "pie") {