@rowsncolumns/charts 28.1.7 → 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 (61) hide show
  1. package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
  2. package/dist/cjs/basic-chart/basic-chart.js +525 -419
  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 +351 -192
  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/doughnut.d.ts +19 -0
  14. package/dist/cjs/utils/doughnut.d.ts.map +1 -0
  15. package/dist/cjs/utils/doughnut.js +28 -0
  16. package/dist/cjs/utils/echarts-overlays.d.ts +46 -0
  17. package/dist/cjs/utils/echarts-overlays.d.ts.map +1 -0
  18. package/dist/cjs/utils/echarts-overlays.js +134 -0
  19. package/dist/cjs/utils/hierarchy.d.ts +23 -1
  20. package/dist/cjs/utils/hierarchy.d.ts.map +1 -1
  21. package/dist/cjs/utils/hierarchy.js +179 -55
  22. package/dist/cjs/utils/overrides.d.ts +9 -0
  23. package/dist/cjs/utils/overrides.d.ts.map +1 -1
  24. package/dist/cjs/utils/overrides.js +18 -3
  25. package/dist/cjs/utils/radar.d.ts +31 -5
  26. package/dist/cjs/utils/radar.d.ts.map +1 -1
  27. package/dist/cjs/utils/radar.js +67 -27
  28. package/dist/cjs/utils/trendline.d.ts +7 -1
  29. package/dist/cjs/utils/trendline.d.ts.map +1 -1
  30. package/dist/cjs/utils/trendline.js +82 -18
  31. package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
  32. package/dist/esm/basic-chart/basic-chart.js +376 -248
  33. package/dist/esm/basic-chart/helpers.d.ts.map +1 -1
  34. package/dist/esm/basic-chart/helpers.js +2 -0
  35. package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  36. package/dist/esm/plotly/basic-chart/basic-chart.js +279 -69
  37. package/dist/esm/plotly/basic-chart/helpers.d.ts.map +1 -1
  38. package/dist/esm/plotly/basic-chart/helpers.js +2 -0
  39. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  40. package/dist/esm/utils/bubble.d.ts +34 -1
  41. package/dist/esm/utils/bubble.d.ts.map +1 -1
  42. package/dist/esm/utils/bubble.js +35 -11
  43. package/dist/esm/utils/doughnut.d.ts +19 -0
  44. package/dist/esm/utils/doughnut.d.ts.map +1 -0
  45. package/dist/esm/utils/doughnut.js +21 -0
  46. package/dist/esm/utils/echarts-overlays.d.ts +46 -0
  47. package/dist/esm/utils/echarts-overlays.d.ts.map +1 -0
  48. package/dist/esm/utils/echarts-overlays.js +135 -0
  49. package/dist/esm/utils/hierarchy.d.ts +23 -1
  50. package/dist/esm/utils/hierarchy.d.ts.map +1 -1
  51. package/dist/esm/utils/hierarchy.js +168 -50
  52. package/dist/esm/utils/overrides.d.ts +9 -0
  53. package/dist/esm/utils/overrides.d.ts.map +1 -1
  54. package/dist/esm/utils/overrides.js +16 -2
  55. package/dist/esm/utils/radar.d.ts +31 -5
  56. package/dist/esm/utils/radar.d.ts.map +1 -1
  57. package/dist/esm/utils/radar.js +44 -16
  58. package/dist/esm/utils/trendline.d.ts +7 -1
  59. package/dist/esm/utils/trendline.d.ts.map +1 -1
  60. package/dist/esm/utils/trendline.js +67 -14
  61. package/package.json +4 -4
@@ -18,16 +18,17 @@ 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";
27
27
  import { binHistogram, computePareto, isParetoSpec, } from "../utils/histogram";
28
28
  import { categoryAxisNameGap, legendLabelsFrom, reserveEchartsLegendBand, } from "../utils/legend-layout";
29
29
  import { clampSecondPieSize, isOfPieChartSpec, otherSliceHalfAngle, resolveOfPieType, splitOfPie, } from "../utils/of-pie";
30
- import { applyAxisOverrides, applyLegendOverrides, buildSeriesLabelOption, } from "../utils/overrides";
30
+ import { clampDoughnutHole, doughnutRings } from "../utils/doughnut";
31
+ import { applyAxisOverrides, applyLegendOverrides, buildSeriesLabelOption, resolveSeriesDataLabels, } from "../utils/overrides";
31
32
  import { buildRadarIndicators, buildRadarSeriesData } from "../utils/radar";
32
33
  import { buildRegionMapData, buildSequentialColorRamp, isRegionMapSpec, resolveGeoScope, warnOnceForUnmatchedRegions, } from "../utils/region-map";
33
34
  import { loadRegionMapGeo } from "../utils/region-map-loader";
@@ -35,7 +36,6 @@ import { STOCK_ROLES, toCandlestick, toLineSeries, toVolumeSeries, } from "../ut
35
36
  import { explosionOffsetPx, pieRadiusPx, resolveExplosion, } from "../utils/pie-explosion";
36
37
  import { chartSeriesId, chartSeriesSpecIndex, resolveClickedDataIndex, resolveClickedSeriesIndex, } from "../utils/series-click";
37
38
  import { buildBar3DData, buildContourData, buildLine3DSeries, buildSurfaceData, getCategoryCount, getValueExtent, isContourSpec, resolve3DRenderKind, SURFACE_COLOR_RAMP, } from "../utils/three-d";
38
- import { computeTrendline } from "../utils/trendline";
39
39
  import { computeWaterfall } from "../utils/waterfall";
40
40
  import { isWebGLAvailable } from "../utils/webgl";
41
41
  import { chartTypeToEchartsType, circularLegendEntries, getEchartsSeriesStyle, isCircularChartType, mapMarkerSymbolToEcharts, showXAxis, } from "./helpers";
@@ -102,29 +102,41 @@ const HIERARCHY_CHART_TYPES = new Set([
102
102
  const isBasicChartSpec = (spec) => BASIC_CHART_TYPES.has(spec.chartType);
103
103
  const isBubbleChartSpec = (spec) => BUBBLE_CHART_TYPES.has(spec.chartType);
104
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.
105
108
  const HIERARCHY_LEVEL_STYLES = [
106
109
  {
107
110
  itemStyle: {
108
- gapWidth: 1,
109
- borderColorSaturation: 0.6,
111
+ gapWidth: 2,
112
+ borderColor: "#ffffff",
113
+ borderWidth: 0,
110
114
  },
111
115
  upperLabel: { show: false },
112
116
  },
113
117
  {
114
- colorSaturation: [0.35, 0.65],
115
- itemStyle: {
116
- gapWidth: 1,
117
- borderColorSaturation: 0.6,
118
- },
119
- },
120
- {
121
- colorSaturation: [0.45, 0.75],
122
118
  itemStyle: {
123
119
  gapWidth: 1,
124
- borderColorSaturation: 0.6,
120
+ borderColor: "#ffffff",
121
+ borderWidth: 1,
125
122
  },
126
123
  },
127
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%"];
128
140
  const getHierarchyDepth = (nodes, depth = 1) => {
129
141
  let maxDepth = depth;
130
142
  nodes.forEach((node) => {
@@ -175,6 +187,16 @@ const buildHierarchyLevels = (maxDepth) => {
175
187
  };
176
188
  /** Series whose tooltip value is one number on a value axis. */
177
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]);
178
200
  const defaultLegendConfig = {
179
201
  padding: 10,
180
202
  itemWidth: 10,
@@ -555,7 +577,38 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
555
577
  sizeValues: sharedSizeValues,
556
578
  series: seriesInputs,
557
579
  });
558
- 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);
559
612
  const xAxisConfig = categories.length > 0
560
613
  ? {
561
614
  type: "category",
@@ -581,8 +634,105 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
581
634
  };
582
635
  // Spec-level overrides — axis min/max/visibility/textFormat, legend
583
636
  // position/visibility, dataLabel toggles. Same helpers as the
584
- // non-bubble layout below.
585
- const bubbleSeriesLabel = buildSeriesLabelOption(spec.dataLabels, undefined, locale);
637
+ // non-bubble layout below. A series' own `dataLabels` replace the
638
+ // chart-level ones for it (#1059).
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
+ });
586
736
  chartRef.current?.setOption({
587
737
  xAxis: [
588
738
  applyAxisOverrides({ ...xAxisConfig }, spec.categoryAxisOptions),
@@ -594,6 +744,19 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
594
744
  nameLocation: "middle",
595
745
  nameGap: 45,
596
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
+ : []),
597
760
  ],
598
761
  legend: applyLegendOverrides({
599
762
  ...legend,
@@ -610,77 +773,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
610
773
  trigger: "item",
611
774
  formatter: tooltipFormatter,
612
775
  },
613
- // Scatter vs bubble — scatter uses small fixed markers
614
- // (optionally connected with a line per scatterStyle); bubble
615
- // applies a size scale on the 3rd dimension. Pre-fix scatter
616
- // routed through the bubble code, producing Y-scaled giant
617
- // circles instead of Excel's small dots.
618
- series: preparedSeries.map((entry, index) => {
619
- const isScatter = spec.chartType === "scatter";
620
- const scatterStyle = spec.scatterStyle ??
621
- (isScatter ? "lineMarker" : undefined);
622
- const seriesEntry = spec.series?.[index];
623
- const seriesColor = seriesEntry?.color;
624
- const perMarker = seriesEntry?.marker;
625
- if (isScatter) {
626
- const symbol = perMarker?.symbol;
627
- const noMarkers = symbol === "none";
628
- const wantsLine = scatterStyle === "line" ||
629
- scatterStyle === "lineMarker" ||
630
- scatterStyle === "smooth" ||
631
- scatterStyle === "smoothMarker";
632
- const wantsMarkers = !noMarkers &&
633
- (scatterStyle === "marker" ||
634
- scatterStyle === "lineMarker" ||
635
- scatterStyle === "smoothMarker" ||
636
- !scatterStyle);
637
- const smooth = scatterStyle === "smooth" || scatterStyle === "smoothMarker";
638
- // Labels hang off the point symbols: a marker-less line keeps
639
- // an invisible 0-size symbol per point when they show (#1049).
640
- const labelsWithoutMarkers = !wantsMarkers && Boolean(spec.dataLabels?.show);
641
- return wantsLine
642
- ? {
643
- type: "line",
644
- name: entry.name,
645
- data: entry.points,
646
- showSymbol: wantsMarkers || labelsWithoutMarkers,
647
- smooth,
648
- symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
649
- symbolSize: labelsWithoutMarkers
650
- ? 0
651
- : perMarker?.size ?? 6,
652
- ...(seriesColor
653
- ? {
654
- itemStyle: { color: seriesColor },
655
- lineStyle: { color: seriesColor },
656
- }
657
- : {}),
658
- }
659
- : {
660
- type: "scatter",
661
- name: entry.name,
662
- data: entry.points,
663
- symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
664
- symbolSize: perMarker?.size ?? 6,
665
- ...(seriesColor
666
- ? { itemStyle: { color: seriesColor } }
667
- : {}),
668
- };
669
- }
670
- return {
671
- type: chartType,
672
- name: entry.name,
673
- data: entry.points,
674
- symbolSize: (data) => sizeScale(data?.[2] ?? 0),
675
- label: {
676
- show: true,
677
- formatter: (params) => params.name ?? "",
678
- },
679
- ...(seriesColor ? { itemStyle: { color: seriesColor } } : {}),
680
- };
681
- }).map((s) => bubbleSeriesLabel
682
- ? { ...s, label: { ...(s.label ?? {}), ...bubbleSeriesLabel } }
683
- : s),
776
+ series: [...bubbleSeries, ...bubbleOverlays],
684
777
  }, {
685
778
  replaceMerge: [
686
779
  "series",
@@ -692,7 +785,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
692
785
  ],
693
786
  });
694
787
  previousChartType.current = spec.chartType;
695
- previousSeriesCount.current = preparedSeries.length;
788
+ previousSeriesCount.current = bubbleSeries.length + bubbleOverlays.length;
696
789
  return;
697
790
  }
698
791
  if (isHierarchy) {
@@ -707,8 +800,17 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
707
800
  hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
708
801
  });
709
802
  hierarchyTitleRef.current = explicitChartTitle ?? headerTitle;
710
- 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);
711
812
  const rootName = hierarchyData.length === 1 ? hierarchyData[0]?.name ?? "" : "";
813
+ const drillDown = Boolean(spec.drillDown);
712
814
  const seriesDisplayName = explicitChartTitle ?? headerTitle ?? rootName ?? "";
713
815
  const hierarchyDepth = getHierarchyDepth(hierarchyData);
714
816
  const levels = buildHierarchyLevels(hierarchyDepth);
@@ -761,6 +863,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
761
863
  label: {
762
864
  show: true,
763
865
  formatter: "{b}",
866
+ ...(spec.chartType === "treemap"
867
+ ? { position: "insideBottomLeft" }
868
+ : {}),
764
869
  ...(hierarchySeriesLabel ?? {}),
765
870
  },
766
871
  upperLabel: spec.chartType === "treemap"
@@ -770,7 +875,16 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
770
875
  }
771
876
  : undefined,
772
877
  levels,
773
- 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] }),
774
888
  },
775
889
  ],
776
890
  }, {
@@ -1142,7 +1256,10 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1142
1256
  const values = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)));
1143
1257
  radarSeriesInputs.push({ name: label, values });
1144
1258
  }
1145
- const indicators = buildRadarIndicators(categories, radarSeriesInputs);
1259
+ // One value scale for every axis (Excel), from the spec's value-axis
1260
+ // min / max when set (#1066).
1261
+ const radarAxisOptions = spec.valueAxisOptions;
1262
+ const indicators = buildRadarIndicators(categories, radarSeriesInputs, radarAxisOptions);
1146
1263
  const radarData = buildRadarSeriesData(radarSeriesInputs, indicators.length);
1147
1264
  const radarStyle = spec.radarStyle ?? "standard";
1148
1265
  const radarColors = radarSeriesInputs.map((_, i) => spec.series?.[i]?.color);
@@ -1166,9 +1283,23 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1166
1283
  };
1167
1284
  chartRef.current?.setOption({
1168
1285
  radar: {
1169
- 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
1170
1292
  ? indicators
1171
- : [{ name: "", max: 1 }],
1293
+ : [{ name: "", min: 0, max: 1 }]).map((indicator, index) => ({
1294
+ ...indicator,
1295
+ axisLabel: {
1296
+ show: index === 0 && radarAxisOptions?.visible !== false,
1297
+ },
1298
+ })),
1299
+ axisLabel: {
1300
+ show: radarAxisOptions?.visible !== false,
1301
+ formatter: (value) => formatAxisValue(radarFormats[0], value, locale),
1302
+ },
1172
1303
  },
1173
1304
  legend: applyLegendOverrides({
1174
1305
  ...legend,
@@ -1532,135 +1663,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1532
1663
  : [];
1533
1664
  if (numericValues.length === 0)
1534
1665
  return;
1535
- const seriesColor = series.color ?? theme.themeColors.accent1;
1536
- const useSecondary = series.useSecondaryAxis ? { yAxisIndex: 1 } : {};
1537
- // Trendlines — one overlay line series per spec.
1538
- for (const tl of series.trendlines ?? []) {
1539
- const result = computeTrendline(numericValues, {
1540
- type: tl.type,
1541
- order: tl.order,
1542
- period: tl.period,
1543
- forecastForward: tl.forecastForward,
1544
- forecastBackward: tl.forecastBackward,
1545
- });
1546
- if (!result || result.points.length < 2)
1547
- continue;
1548
- const tlName = tl.name ??
1549
- `${dataLabel ? dataLabel + " " : ""}${tl.type ?? "linear"} trend`;
1550
- chartSeries.push({
1551
- type: "line",
1552
- name: tlName,
1553
- // Index x lets the trendline overlay the category axis even
1554
- // when forecasting extends beyond the data range.
1555
- data: result.points.map((p) => [p.x, p.y]),
1556
- showSymbol: false,
1557
- smooth: tl.type === "poly",
1558
- lineStyle: {
1559
- color: tl.color ?? seriesColor,
1560
- type: "dashed",
1561
- width: 2,
1562
- },
1563
- itemStyle: { color: tl.color ?? seriesColor },
1564
- tooltip: { show: false },
1565
- z: 5,
1566
- ...useSecondary,
1567
- });
1568
- }
1569
- // Error bars — drawn as a custom series of vertical/horizontal
1570
- // whiskers. valueType "custom" reads per-point magnitudes from the
1571
- // spec's plus/minus ranges, resolved through the same extractor
1572
- // the series values use.
1573
- const magnitudes = computeErrorBars(numericValues, resolveCustomErrorBarValues(series.errorBars, (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy)));
1574
- if (magnitudes) {
1575
- const direction = series.errorBars?.direction ?? "y";
1576
- const noEndCap = series.errorBars?.noEndCap ?? false;
1577
- const errorData = numericValues.map((v, i) => [
1578
- i,
1579
- typeof v === "number" ? v : 0,
1580
- magnitudes.plus[i] ?? 0,
1581
- magnitudes.minus[i] ?? 0,
1582
- ]);
1583
- chartSeries.push({
1584
- type: "custom",
1585
- name: `${dataLabel ? dataLabel + " " : ""}error`,
1586
- data: errorData,
1587
- z: 6,
1588
- tooltip: { show: false },
1589
- ...useSecondary,
1590
- renderItem: (_params, api) => {
1591
- const value = api.value(1);
1592
- const plus = api.value(2);
1593
- const minus = api.value(3);
1594
- const capHalf = 5;
1595
- if (direction === "x") {
1596
- const center = api.coord([value, api.value(0)]);
1597
- const hi = api.coord([value + plus, api.value(0)]);
1598
- const lo = api.coord([value - minus, api.value(0)]);
1599
- const children = [
1600
- {
1601
- type: "line",
1602
- shape: { x1: lo[0], y1: center[1], x2: hi[0], y2: center[1] },
1603
- style: { stroke: seriesColor, lineWidth: 1 },
1604
- },
1605
- ];
1606
- if (!noEndCap) {
1607
- children.push({
1608
- type: "line",
1609
- shape: {
1610
- x1: hi[0],
1611
- y1: center[1] - capHalf,
1612
- x2: hi[0],
1613
- y2: center[1] + capHalf,
1614
- },
1615
- style: { stroke: seriesColor, lineWidth: 1 },
1616
- }, {
1617
- type: "line",
1618
- shape: {
1619
- x1: lo[0],
1620
- y1: center[1] - capHalf,
1621
- x2: lo[0],
1622
- y2: center[1] + capHalf,
1623
- },
1624
- style: { stroke: seriesColor, lineWidth: 1 },
1625
- });
1626
- }
1627
- return { type: "group", children };
1628
- }
1629
- const center = api.coord([api.value(0), value]);
1630
- const hi = api.coord([api.value(0), value + plus]);
1631
- const lo = api.coord([api.value(0), value - minus]);
1632
- const children = [
1633
- {
1634
- type: "line",
1635
- shape: { x1: center[0], y1: lo[1], x2: center[0], y2: hi[1] },
1636
- style: { stroke: seriesColor, lineWidth: 1 },
1637
- },
1638
- ];
1639
- if (!noEndCap) {
1640
- children.push({
1641
- type: "line",
1642
- shape: {
1643
- x1: center[0] - capHalf,
1644
- y1: hi[1],
1645
- x2: center[0] + capHalf,
1646
- y2: hi[1],
1647
- },
1648
- style: { stroke: seriesColor, lineWidth: 1 },
1649
- }, {
1650
- type: "line",
1651
- shape: {
1652
- x1: center[0] - capHalf,
1653
- y1: lo[1],
1654
- x2: center[0] + capHalf,
1655
- y2: lo[1],
1656
- },
1657
- style: { stroke: seriesColor, lineWidth: 1 },
1658
- });
1659
- }
1660
- return { type: "group", children };
1661
- },
1662
- });
1663
- }
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
+ }));
1664
1675
  };
1665
1676
  // Excel `<c:overlap>` / `<c:gapWidth>` → echarts barGap /
1666
1677
  // barCategoryGap (see utils/bar-layout.ts for the exact mapping).
@@ -1788,7 +1799,8 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1788
1799
  // marker-less line with labels (Excel's default "Line" with values
1789
1800
  // shown) keeps an invisible 0-size symbol per point for the label
1790
1801
  // to hang off (#1049).
1791
- const labelsWithoutMarkers = !wantsMarkers && Boolean(spec.dataLabels?.show);
1802
+ const labelsWithoutMarkers = !wantsMarkers &&
1803
+ Boolean(resolveSeriesDataLabels(spec.dataLabels, seriesAny)?.show);
1792
1804
  seriesEntry.showSymbol = wantsMarkers || labelsWithoutMarkers;
1793
1805
  if (wantsMarkers) {
1794
1806
  seriesEntry.symbol =
@@ -2132,17 +2144,30 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2132
2144
  nameGap: 30,
2133
2145
  });
2134
2146
  chartSeries.length = 0;
2147
+ // The spacer bars are layout only: no labels, no tooltip entry.
2135
2148
  chartSeries.push({
2136
2149
  type: "bar",
2150
+ id: WATERFALL_PLACEHOLDER_ID,
2137
2151
  stack: "waterfall",
2138
2152
  itemStyle: { color: "transparent" },
2139
2153
  emphasis: { itemStyle: { color: "transparent" } },
2140
2154
  data: steps.map((s) => s.placeholder),
2141
2155
  silent: true,
2156
+ label: { show: false },
2157
+ tooltip: { show: false },
2142
2158
  });
2143
2159
  const accent = seriesEntry?.color ?? theme.themeColors.accent1;
2144
2160
  const decreaseColor = seriesEntry?.negativeColor ?? theme.themeColors.accent2 ?? accent;
2145
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
+ }
2146
2171
  // Excel-style connector lines: a horizontal segment at the running
2147
2172
  // total after bar i, spanning across to bar i+1. Within the bars
2148
2173
  // the segment overlaps their top/bottom edges, so only the gap
@@ -2153,16 +2178,22 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2153
2178
  ]);
2154
2179
  chartSeries.push({
2155
2180
  type: "bar",
2181
+ id: chartSeriesId(0, 0),
2156
2182
  stack: "waterfall",
2157
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.
2158
2187
  data: steps.map((s, i) => ({
2159
2188
  value: s.value,
2189
+ originalValue: rawValues[i],
2160
2190
  itemStyle: {
2161
- color: s.isTotal
2162
- ? totalColor
2163
- : rawValues[i] < 0
2164
- ? decreaseColor
2165
- : accent,
2191
+ color: pointColors.get(i) ??
2192
+ (s.isTotal
2193
+ ? totalColor
2194
+ : rawValues[i] < 0
2195
+ ? decreaseColor
2196
+ : accent),
2166
2197
  },
2167
2198
  })),
2168
2199
  markLine: {
@@ -2192,14 +2223,64 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2192
2223
  : [];
2193
2224
  chartDomains.length = 0;
2194
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
+ };
2195
2255
  chartSeries.push({
2196
2256
  type: "funnel",
2257
+ id: chartSeriesId(0, 0),
2197
2258
  name: seriesEntry?.dataLabel ?? "",
2198
- data: rawValues.map((value, i) => ({
2199
- name: labels[i] ?? String(i),
2200
- value,
2201
- })),
2202
- 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,
2203
2284
  });
2204
2285
  legend.data = [];
2205
2286
  }
@@ -2456,19 +2537,37 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2456
2537
  }
2457
2538
  : legendBand.grid;
2458
2539
  // Data labels fall back to the series' axis format when the spec has
2459
- // no explicit `<c:dLbls><c:numFmt>` — Excel's "Linked to source".
2460
- const dataLabelOptions = spec.dataLabels;
2461
- const seriesLabelOptionFor = (s) => buildSeriesLabelOption(dataLabelOptions && !dataLabelOptions.numberFormatPattern
2462
- ? {
2463
- ...dataLabelOptions,
2464
- numberFormatPattern: seriesNumberFormatFor(s),
2465
- }
2466
- : dataLabelOptions, s?.type, locale);
2540
+ // no explicit `<c:dLbls><c:numFmt>` — Excel's "Linked to source". A
2541
+ // series' own `dataLabels` replace the chart-level ones for it (#1059).
2542
+ const chartDataLabels = spec.dataLabels;
2543
+ const seriesLabelOptionFor = (s) => {
2544
+ const specIndex = chartSeriesSpecIndex(s?.id);
2545
+ const dataLabelOptions = resolveSeriesDataLabels(chartDataLabels, specIndex === undefined ? undefined : spec.series?.[specIndex]);
2546
+ return buildSeriesLabelOption(dataLabelOptions && !dataLabelOptions.numberFormatPattern
2547
+ ? {
2548
+ ...dataLabelOptions,
2549
+ numberFormatPattern: seriesNumberFormatFor(s),
2550
+ }
2551
+ : dataLabelOptions, s?.type, locale);
2552
+ };
2467
2553
  // For doughnut, surface the inner-radius hole on each pie series.
2468
2554
  // Excel persists `holeSize` as a 0-90 percentage; we normalize to a
2469
2555
  // 0-1 fraction (doughnutHoleSize). Default to 0.5 when unset.
2470
2556
  const isDoughnut = spec.chartType === "doughnut";
2471
2557
  const doughnutHoleSize = spec.doughnutHoleSize;
2558
+ // Excel nests a doughnut's series as concentric rings, series 0
2559
+ // innermost (#1065): the ring each pie series draws is its rank among
2560
+ // the spec-tagged pie series.
2561
+ const doughnutSeriesOrder = isDoughnut
2562
+ ? chartSeries
2563
+ .filter((s) => s?.type === "pie")
2564
+ .map((s) => chartSeriesSpecIndex(s?.id))
2565
+ .filter((i) => i !== undefined)
2566
+ .sort((a, b) => a - b)
2567
+ : [];
2568
+ const doughnutRingSet = isDoughnut
2569
+ ? doughnutRings(clampDoughnutHole(doughnutHoleSize), doughnutSeriesOrder.length)
2570
+ : [];
2472
2571
  // Exploded pie / doughnut: every slice sits in the `select` state,
2473
2572
  // offset outward by the spec's percentage of the pie radius.
2474
2573
  const explosion = isCircularChart && !isOfPie ? resolveExplosion(spec) : 0;
@@ -2515,19 +2614,43 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2515
2614
  }
2516
2615
  const seriesWithLabels = chartSeries.map((s) => {
2517
2616
  const next = { ...s };
2617
+ if (DECORATION_SERIES_IDS.has(s?.id)) {
2618
+ return next;
2619
+ }
2518
2620
  const seriesLabelOption = seriesLabelOptionFor(s);
2519
2621
  if (seriesLabelOption) {
2520
2622
  next.label = { ...(s?.label ?? {}), ...seriesLabelOption };
2521
2623
  }
2624
+ else if (s?.type === "pie") {
2625
+ // echarts labels every pie slice by default (category name on a
2626
+ // leader line); Excel draws nothing until data labels are on (#1062).
2627
+ next.label = { ...(s?.label ?? {}), show: false };
2628
+ next.labelLine = { ...(s?.labelLine ?? {}), show: false };
2629
+ }
2522
2630
  // Tooltip values of the value-axis series (a boxplot / candlestick
2523
2631
  // tooltip lists its own statistics and keeps echarts' default).
2524
2632
  const seriesTooltipValueFormatter = VALUE_TOOLTIP_SERIES_TYPES.has(s?.type)
2525
2633
  ? valueAxisLabelFormatter(seriesNumberFormatFor(s), locale)
2526
2634
  : undefined;
2527
- 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));
2528
2651
  next.tooltip = {
2529
2652
  ...(s?.tooltip ?? {}),
2530
- valueFormatter: seriesTooltipValueFormatter,
2653
+ valueFormatter: (value, dataIndex) => formatValue(originalValueAt(dataIndex) ?? value),
2531
2654
  };
2532
2655
  }
2533
2656
  if (next.type === "pie") {
@@ -2547,10 +2670,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2547
2670
  }
2548
2671
  }
2549
2672
  if (isDoughnut && next.type === "pie") {
2550
- const inner = typeof doughnutHoleSize === "number"
2551
- ? Math.round(Math.max(0, Math.min(0.9, doughnutHoleSize)) * 100)
2552
- : 50;
2553
- next.radius = [`${inner}%`, "75%"];
2673
+ // The hole is a fraction of the OUTER radius (Excel's `<c:holeSize>`),
2674
+ // and the doughnut's outer edge sits at 75% of the chart — so every
2675
+ // ring edge is its fraction of 75%, not of the whole chart (#1064:
2676
+ // Excel's default 0.75 used to meet the outer edge, an empty ring).
2677
+ const rank = doughnutSeriesOrder.indexOf(chartSeriesSpecIndex(s?.id));
2678
+ const ring = doughnutRingSet[rank] ??
2679
+ doughnutRings(clampDoughnutHole(doughnutHoleSize), 1)[0];
2680
+ const pct = (fraction) => `${Math.round(fraction * 75 * 100) / 100}%`;
2681
+ next.radius = [pct(ring.inner), pct(ring.outer)];
2554
2682
  }
2555
2683
  return next;
2556
2684
  });