@rowsncolumns/charts 27.0.34 → 28.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/dist/cjs/atoms/pivot-chart.atom.d.ts +14 -0
  2. package/dist/cjs/atoms/pivot-chart.atom.d.ts.map +1 -0
  3. package/dist/cjs/atoms/pivot-chart.atom.js +9 -0
  4. package/dist/cjs/basic-chart/basic-chart.d.ts +1 -1
  5. package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
  6. package/dist/cjs/basic-chart/basic-chart.js +863 -371
  7. package/dist/cjs/components/dialogs/pivot-chart-dialog.d.ts +39 -0
  8. package/dist/cjs/components/dialogs/pivot-chart-dialog.d.ts.map +1 -0
  9. package/dist/cjs/components/dialogs/pivot-chart-dialog.js +100 -0
  10. package/dist/cjs/components/editors/chart-editor.d.ts +13 -1
  11. package/dist/cjs/components/editors/chart-editor.d.ts.map +1 -1
  12. package/dist/cjs/components/editors/chart-editor.js +225 -59
  13. package/dist/cjs/config.d.ts.map +1 -1
  14. package/dist/cjs/config.js +149 -12
  15. package/dist/cjs/hooks/use-charts.d.ts +21 -6
  16. package/dist/cjs/hooks/use-charts.d.ts.map +1 -1
  17. package/dist/cjs/hooks/use-charts.js +13 -1
  18. package/dist/cjs/hooks/use-on-create-chart.d.ts.map +1 -1
  19. package/dist/cjs/hooks/use-on-create-chart.js +7 -1
  20. package/dist/cjs/hooks/use-on-move-chart-to-sheet.d.ts +12 -0
  21. package/dist/cjs/hooks/use-on-move-chart-to-sheet.d.ts.map +1 -0
  22. package/dist/cjs/hooks/use-on-move-chart-to-sheet.js +26 -0
  23. package/dist/cjs/hooks/use-on-move-chart.d.ts +1 -1
  24. package/dist/cjs/hooks/use-on-move-chart.d.ts.map +1 -1
  25. package/dist/cjs/hooks/use-on-move-chart.js +2 -2
  26. package/dist/cjs/hooks/use-on-resize-chart.d.ts +1 -1
  27. package/dist/cjs/hooks/use-on-resize-chart.d.ts.map +1 -1
  28. package/dist/cjs/hooks/use-on-resize-chart.js +2 -2
  29. package/dist/cjs/index.d.ts +11 -0
  30. package/dist/cjs/index.d.ts.map +1 -1
  31. package/dist/cjs/index.js +34 -1
  32. package/dist/cjs/interface/create-chart.d.ts.map +1 -1
  33. package/dist/cjs/interface/create-chart.js +33 -42
  34. package/dist/cjs/interface/delete-chart.d.ts.map +1 -1
  35. package/dist/cjs/interface/delete-chart.js +4 -0
  36. package/dist/cjs/interface/move-chart-to-sheet.d.ts +13 -0
  37. package/dist/cjs/interface/move-chart-to-sheet.d.ts.map +1 -0
  38. package/dist/cjs/interface/move-chart-to-sheet.js +136 -0
  39. package/dist/cjs/interface/move-chart.d.ts +2 -2
  40. package/dist/cjs/interface/move-chart.d.ts.map +1 -1
  41. package/dist/cjs/interface/move-chart.js +7 -1
  42. package/dist/cjs/interface/resize-chart.d.ts +2 -2
  43. package/dist/cjs/interface/resize-chart.d.ts.map +1 -1
  44. package/dist/cjs/interface/resize-chart.js +7 -1
  45. package/dist/cjs/interface/update-chart.d.ts.map +1 -1
  46. package/dist/cjs/interface/update-chart.js +4 -0
  47. package/dist/cjs/plotly/basic-chart/basic-chart.d.ts +1 -1
  48. package/dist/cjs/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  49. package/dist/cjs/plotly/basic-chart/basic-chart.js +970 -585
  50. package/dist/cjs/plotly/basic-chart/helpers.d.ts.map +1 -1
  51. package/dist/cjs/plotly/basic-chart/helpers.js +3 -0
  52. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  53. package/dist/cjs/types.d.ts +7 -0
  54. package/dist/cjs/types.d.ts.map +1 -1
  55. package/dist/cjs/utils/axis-number-format.d.ts +69 -0
  56. package/dist/cjs/utils/axis-number-format.d.ts.map +1 -0
  57. package/dist/cjs/utils/axis-number-format.js +217 -0
  58. package/dist/cjs/utils/category-levels.d.ts +27 -0
  59. package/dist/cjs/utils/category-levels.d.ts.map +1 -0
  60. package/dist/cjs/utils/category-levels.js +80 -0
  61. package/dist/cjs/utils/chart-image.d.ts +8 -0
  62. package/dist/cjs/utils/chart-image.d.ts.map +1 -0
  63. package/dist/cjs/utils/chart-image.js +28 -0
  64. package/dist/cjs/utils/chart-orientation.d.ts +67 -0
  65. package/dist/cjs/utils/chart-orientation.d.ts.map +1 -0
  66. package/dist/cjs/utils/chart-orientation.js +265 -0
  67. package/dist/cjs/utils/chart-spec-utils.d.ts +3 -0
  68. package/dist/cjs/utils/chart-spec-utils.d.ts.map +1 -1
  69. package/dist/cjs/utils/chart-spec-utils.js +12 -4
  70. package/dist/cjs/utils/chart-styles.d.ts +75 -0
  71. package/dist/cjs/utils/chart-styles.d.ts.map +1 -0
  72. package/dist/cjs/utils/chart-styles.js +283 -0
  73. package/dist/cjs/utils/empty-cells.d.ts +30 -0
  74. package/dist/cjs/utils/empty-cells.d.ts.map +1 -0
  75. package/dist/cjs/utils/empty-cells.js +91 -0
  76. package/dist/cjs/utils/overrides.d.ts +6 -1
  77. package/dist/cjs/utils/overrides.d.ts.map +1 -1
  78. package/dist/cjs/utils/overrides.js +19 -7
  79. package/dist/cjs/utils/pie-explosion.d.ts +30 -0
  80. package/dist/cjs/utils/pie-explosion.d.ts.map +1 -0
  81. package/dist/cjs/utils/pie-explosion.js +55 -0
  82. package/dist/cjs/utils/plotly-click.d.ts +34 -0
  83. package/dist/cjs/utils/plotly-click.d.ts.map +1 -0
  84. package/dist/cjs/utils/plotly-click.js +49 -0
  85. package/dist/cjs/utils/plotly-surface.d.ts +14 -0
  86. package/dist/cjs/utils/plotly-surface.d.ts.map +1 -0
  87. package/dist/cjs/utils/plotly-surface.js +64 -0
  88. package/dist/cjs/utils/plotly-title.d.ts +12 -0
  89. package/dist/cjs/utils/plotly-title.d.ts.map +1 -0
  90. package/dist/cjs/utils/plotly-title.js +24 -0
  91. package/dist/cjs/utils/series-click.d.ts +39 -0
  92. package/dist/cjs/utils/series-click.d.ts.map +1 -0
  93. package/dist/cjs/utils/series-click.js +75 -0
  94. package/dist/cjs/utils/three-d.d.ts +17 -1
  95. package/dist/cjs/utils/three-d.d.ts.map +1 -1
  96. package/dist/cjs/utils/three-d.js +32 -4
  97. package/dist/esm/atoms/pivot-chart.atom.d.ts +14 -0
  98. package/dist/esm/atoms/pivot-chart.atom.d.ts.map +1 -0
  99. package/dist/esm/atoms/pivot-chart.atom.js +4 -0
  100. package/dist/esm/basic-chart/basic-chart.d.ts +1 -1
  101. package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
  102. package/dist/esm/basic-chart/basic-chart.js +680 -178
  103. package/dist/esm/components/dialogs/pivot-chart-dialog.d.ts +39 -0
  104. package/dist/esm/components/dialogs/pivot-chart-dialog.d.ts.map +1 -0
  105. package/dist/esm/components/dialogs/pivot-chart-dialog.js +89 -0
  106. package/dist/esm/components/editors/chart-editor.d.ts +13 -1
  107. package/dist/esm/components/editors/chart-editor.d.ts.map +1 -1
  108. package/dist/esm/components/editors/chart-editor.js +164 -20
  109. package/dist/esm/config.d.ts.map +1 -1
  110. package/dist/esm/config.js +149 -12
  111. package/dist/esm/hooks/use-charts.d.ts +21 -6
  112. package/dist/esm/hooks/use-charts.d.ts.map +1 -1
  113. package/dist/esm/hooks/use-charts.js +13 -1
  114. package/dist/esm/hooks/use-on-create-chart.d.ts.map +1 -1
  115. package/dist/esm/hooks/use-on-create-chart.js +7 -1
  116. package/dist/esm/hooks/use-on-move-chart-to-sheet.d.ts +12 -0
  117. package/dist/esm/hooks/use-on-move-chart-to-sheet.d.ts.map +1 -0
  118. package/dist/esm/hooks/use-on-move-chart-to-sheet.js +19 -0
  119. package/dist/esm/hooks/use-on-move-chart.d.ts +1 -1
  120. package/dist/esm/hooks/use-on-move-chart.d.ts.map +1 -1
  121. package/dist/esm/hooks/use-on-move-chart.js +2 -2
  122. package/dist/esm/hooks/use-on-resize-chart.d.ts +1 -1
  123. package/dist/esm/hooks/use-on-resize-chart.d.ts.map +1 -1
  124. package/dist/esm/hooks/use-on-resize-chart.js +2 -2
  125. package/dist/esm/index.d.ts +11 -0
  126. package/dist/esm/index.d.ts.map +1 -1
  127. package/dist/esm/index.js +14 -0
  128. package/dist/esm/interface/create-chart.d.ts.map +1 -1
  129. package/dist/esm/interface/create-chart.js +36 -58
  130. package/dist/esm/interface/delete-chart.d.ts.map +1 -1
  131. package/dist/esm/interface/delete-chart.js +4 -0
  132. package/dist/esm/interface/move-chart-to-sheet.d.ts +13 -0
  133. package/dist/esm/interface/move-chart-to-sheet.d.ts.map +1 -0
  134. package/dist/esm/interface/move-chart-to-sheet.js +126 -0
  135. package/dist/esm/interface/move-chart.d.ts +2 -2
  136. package/dist/esm/interface/move-chart.d.ts.map +1 -1
  137. package/dist/esm/interface/move-chart.js +7 -1
  138. package/dist/esm/interface/resize-chart.d.ts +2 -2
  139. package/dist/esm/interface/resize-chart.d.ts.map +1 -1
  140. package/dist/esm/interface/resize-chart.js +7 -1
  141. package/dist/esm/interface/update-chart.d.ts.map +1 -1
  142. package/dist/esm/interface/update-chart.js +4 -0
  143. package/dist/esm/plotly/basic-chart/basic-chart.d.ts +1 -1
  144. package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  145. package/dist/esm/plotly/basic-chart/basic-chart.js +858 -463
  146. package/dist/esm/plotly/basic-chart/helpers.d.ts.map +1 -1
  147. package/dist/esm/plotly/basic-chart/helpers.js +3 -0
  148. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  149. package/dist/esm/types.d.ts +7 -0
  150. package/dist/esm/types.d.ts.map +1 -1
  151. package/dist/esm/utils/axis-number-format.d.ts +69 -0
  152. package/dist/esm/utils/axis-number-format.d.ts.map +1 -0
  153. package/dist/esm/utils/axis-number-format.js +170 -0
  154. package/dist/esm/utils/category-levels.d.ts +27 -0
  155. package/dist/esm/utils/category-levels.d.ts.map +1 -0
  156. package/dist/esm/utils/category-levels.js +65 -0
  157. package/dist/esm/utils/chart-image.d.ts +8 -0
  158. package/dist/esm/utils/chart-image.d.ts.map +1 -0
  159. package/dist/esm/utils/chart-image.js +24 -0
  160. package/dist/esm/utils/chart-orientation.d.ts +67 -0
  161. package/dist/esm/utils/chart-orientation.d.ts.map +1 -0
  162. package/dist/esm/utils/chart-orientation.js +234 -0
  163. package/dist/esm/utils/chart-spec-utils.d.ts +3 -0
  164. package/dist/esm/utils/chart-spec-utils.d.ts.map +1 -1
  165. package/dist/esm/utils/chart-spec-utils.js +10 -2
  166. package/dist/esm/utils/chart-styles.d.ts +75 -0
  167. package/dist/esm/utils/chart-styles.d.ts.map +1 -0
  168. package/dist/esm/utils/chart-styles.js +357 -0
  169. package/dist/esm/utils/empty-cells.d.ts +30 -0
  170. package/dist/esm/utils/empty-cells.d.ts.map +1 -0
  171. package/dist/esm/utils/empty-cells.js +67 -0
  172. package/dist/esm/utils/overrides.d.ts +6 -1
  173. package/dist/esm/utils/overrides.d.ts.map +1 -1
  174. package/dist/esm/utils/overrides.js +14 -2
  175. package/dist/esm/utils/pie-explosion.d.ts +30 -0
  176. package/dist/esm/utils/pie-explosion.d.ts.map +1 -0
  177. package/dist/esm/utils/pie-explosion.js +43 -0
  178. package/dist/esm/utils/plotly-click.d.ts +34 -0
  179. package/dist/esm/utils/plotly-click.d.ts.map +1 -0
  180. package/dist/esm/utils/plotly-click.js +44 -0
  181. package/dist/esm/utils/plotly-surface.d.ts +14 -0
  182. package/dist/esm/utils/plotly-surface.d.ts.map +1 -0
  183. package/dist/esm/utils/plotly-surface.js +45 -0
  184. package/dist/esm/utils/plotly-title.d.ts +12 -0
  185. package/dist/esm/utils/plotly-title.d.ts.map +1 -0
  186. package/dist/esm/utils/plotly-title.js +20 -0
  187. package/dist/esm/utils/series-click.d.ts +39 -0
  188. package/dist/esm/utils/series-click.d.ts.map +1 -0
  189. package/dist/esm/utils/series-click.js +66 -0
  190. package/dist/esm/utils/three-d.d.ts +17 -1
  191. package/dist/esm/utils/three-d.d.ts.map +1 -1
  192. package/dist/esm/utils/three-d.js +29 -3
  193. package/package.json +16 -12
@@ -1,21 +1,27 @@
1
1
  import { ChevronRightIcon, DotsVerticalIcon } from "@rowsncolumns/icons";
2
- import { getStringifiedColor, useFocusSheet } from "@rowsncolumns/spreadsheet";
2
+ import { getStringifiedColor, useFocusSheet, useRegisterChartActions, useSetSelectedChartSeries, } from "@rowsncolumns/spreadsheet";
3
3
  import { useSubscribeToMultipleRanges } from "@rowsncolumns/spreadsheet-state";
4
4
  import { createDownloadableFile } from "@rowsncolumns/toolkit";
5
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, DropdownRightSlot, IconButton, useIsomorphicLayoutEffect, useOnVisibilityChange, } from "@rowsncolumns/ui";
6
6
  import { debug, isAFormula, isNil } from "@rowsncolumns/utils";
7
- import { BarChart, BoxplotChart, CandlestickChart, CustomChart, FunnelChart, LineChart, PieChart, RadarChart as EchartsRadarChart, ScatterChart, SunburstChart, TreemapChart as EchartsTreemapChart, } from "echarts/charts";
8
- import { DatasetComponent, GridComponent, LegendComponent, RadarComponent, TitleComponent, TooltipComponent, } from "echarts/components";
7
+ import { BarChart, BoxplotChart, CandlestickChart, CustomChart, FunnelChart, HeatmapChart, LineChart, PieChart, RadarChart as EchartsRadarChart, ScatterChart, SunburstChart, TreemapChart as EchartsTreemapChart, } from "echarts/charts";
8
+ import { DatasetComponent, GridComponent, LegendComponent, RadarComponent, TitleComponent, TooltipComponent, VisualMapComponent, } from "echarts/components";
9
9
  import * as echarts from "echarts/core";
10
10
  import { CanvasRenderer } from "echarts/renderers";
11
11
  import React, { useCallback, useMemo, useRef, useState } from "react";
12
12
  import { aggregateSeriesByDomain, getDomainValuesForAxis, sanitizeDomainValues, shouldAggregateSeries, } from "../utils/aggregation";
13
+ import { circularTooltipFormatter, formatAxisValue, resolveSeriesNumberFormats, resolveSourceNumberFormat, resolveValueAxisNumberFormat, tooltipValueFormatter, valueAxisLabelFormatter, } from "../utils/axis-number-format";
13
14
  import { gapWidthToBarCategoryGap, overlapToBarGap, } from "../utils/bar-layout";
14
15
  import { computeBoxplotSummary } from "../utils/boxplot";
15
16
  import { createBubbleSizeScale, prepareBubbleSeriesData, } from "../utils/bubble";
16
17
  import { coerceCalculatedLabel, getChartDataRanges, } from "../utils/chart-spec-utils";
18
+ import { canSwitchRowColumn, cellLabelGetterFromRangeReader, switchRowColumn, } from "../utils/chart-orientation";
19
+ import { dataUrlToBlob } from "../utils/chart-image";
20
+ import { getChartPalette } from "../utils/chart-styles";
21
+ import { applyEmptyCellsPolicy, connectsAcrossEmptyCells, isLineLikeChartType, } from "../utils/empty-cells";
17
22
  import { getEchartsGlState, loadEchartsGl, } from "../utils/echarts-gl-loader";
18
23
  import { computeErrorBars, resolveCustomErrorBarValues, } from "../utils/error-bars";
24
+ import { echartsGroupedCategoryAxes, resolveCategoryLevels, } from "../utils/category-levels";
19
25
  import { gradientToEcharts } from "../utils/gradient";
20
26
  import { buildHierarchyTree, extractHierarchyRows, getHierarchyHeaderTitle, } from "../utils/hierarchy";
21
27
  import { binHistogram, computePareto, isParetoSpec, } from "../utils/histogram";
@@ -24,8 +30,10 @@ import { applyAxisOverrides, applyLegendOverrides, buildSeriesLabelOption, } fro
24
30
  import { buildRadarIndicators, buildRadarSeriesData } from "../utils/radar";
25
31
  import { buildRegionMapData, buildSequentialColorRamp, isRegionMapSpec, resolveGeoScope, warnOnceForUnmatchedRegions, } from "../utils/region-map";
26
32
  import { loadRegionMapGeo } from "../utils/region-map-loader";
27
- import { toCandlestick, toLineSeries, toVolumeSeries, } from "../utils/stock";
28
- import { buildBar3DData, buildLine3DSeries, buildSurfaceData, getValueExtent, resolve3DRenderKind, SURFACE_COLOR_RAMP, } from "../utils/three-d";
33
+ import { STOCK_ROLES, toCandlestick, toLineSeries, toVolumeSeries, } from "../utils/stock";
34
+ import { explosionOffsetPx, pieRadiusPx, resolveExplosion, } from "../utils/pie-explosion";
35
+ import { chartSeriesId, chartSeriesSpecIndex, resolveClickedDataIndex, resolveClickedSeriesIndex, } from "../utils/series-click";
36
+ import { buildBar3DData, buildContourData, buildLine3DSeries, buildSurfaceData, getCategoryCount, getValueExtent, isContourSpec, resolve3DRenderKind, SURFACE_COLOR_RAMP, } from "../utils/three-d";
29
37
  import { computeTrendline } from "../utils/trendline";
30
38
  import { computeWaterfall } from "../utils/waterfall";
31
39
  import { isWebGLAvailable } from "../utils/webgl";
@@ -164,13 +172,15 @@ const buildHierarchyLevels = (maxDepth) => {
164
172
  }
165
173
  return levels;
166
174
  };
175
+ /** Series whose tooltip value is one number on a value axis. */
176
+ const VALUE_TOOLTIP_SERIES_TYPES = new Set(["bar", "line", "scatter", "funnel"]);
167
177
  const defaultLegendConfig = {
168
178
  padding: 10,
169
179
  itemWidth: 10,
170
180
  itemHeight: 4,
171
181
  top: 20,
172
182
  };
173
- export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, onDeleteChart, onRequestEdit, onRequestCalculate, isSelected, }) => {
183
+ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, getFormattingFromRange, onDeleteChart, onUpdateChart, onRequestEdit, onRequestCalculate, isSelected, fieldButtons, }) => {
174
184
  const spec = chart.spec;
175
185
  const explicitChartTitle = chart.spec.title ?? undefined;
176
186
  const hierarchyTitleRef = useRef(explicitChartTitle);
@@ -178,6 +188,47 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
178
188
  const el = useRef(null);
179
189
  const chartRef = useRef(undefined);
180
190
  const focusSheet = useFocusSheet();
191
+ // Excel selects a data series when it is clicked inside the chart and
192
+ // the grid highlights that series' source range; clicking empty chart
193
+ // area steps back to the whole chart. Published for the grid to draw.
194
+ const setSelectedChartSeries = useSetSelectedChartSeries();
195
+ const chartForClickRef = useRef(chart);
196
+ chartForClickRef.current = chart;
197
+ // An exploded pie draws every slice in echarts' `select` state (the only
198
+ // per-slice offset it has); echarts unselects a slice on click, so that
199
+ // click is folded back into a select to keep the slice out.
200
+ const explodedRef = useRef(false);
201
+ const bindSeriesSelection = (instance) => {
202
+ instance.on("click", (params) => {
203
+ const current = chartForClickRef.current;
204
+ const clickParams = params;
205
+ const seriesIndex = resolveClickedSeriesIndex(clickParams, current.spec.series?.length ?? 0);
206
+ if (seriesIndex === undefined)
207
+ return;
208
+ const dataIndex = resolveClickedDataIndex(clickParams);
209
+ setSelectedChartSeries({
210
+ chartId: current.chartId,
211
+ seriesIndex,
212
+ ...(dataIndex === undefined ? {} : { dataIndex }),
213
+ });
214
+ });
215
+ instance.on("selectchanged", (event) => {
216
+ const { fromAction, isFromClick, fromActionPayload } = event;
217
+ if (!explodedRef.current || !isFromClick || fromAction !== "unselect") {
218
+ return;
219
+ }
220
+ instance.dispatchAction({
221
+ type: "select",
222
+ seriesIndex: fromActionPayload?.seriesIndex,
223
+ dataIndex: fromActionPayload?.dataIndexInside ?? fromActionPayload?.dataIndex,
224
+ });
225
+ });
226
+ instance.getZr().on("click", (event) => {
227
+ if (!event.target) {
228
+ setSelectedChartSeries(undefined);
229
+ }
230
+ });
231
+ };
181
232
  const shouldFocusSheetRef = useRef(false);
182
233
  const previousChartType = useRef(undefined);
183
234
  const previousSeriesCount = useRef(undefined);
@@ -191,10 +242,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
191
242
  // visualMap) doesn't merge back into a cartesian one, so the instance
192
243
  // is re-initialized when entering/leaving the region-map rendering.
193
244
  const renderedAsRegionMap = useRef(false);
245
+ // Contour (Excel's flat Surface view) renders as a heatmap with a
246
+ // visualMap; that component doesn't merge back into a plain cartesian
247
+ // option, so the instance is re-initialized when entering/leaving it.
248
+ const renderedAsContour = useRef(false);
194
249
  // Initialize line chart
195
250
  const initializeChart = () => {
196
251
  if (el.current && !chartRef.current) {
197
252
  chartRef.current = echarts.init(el.current);
253
+ bindSeriesSelection(chartRef.current);
198
254
  chartRef.current.setOption({
199
255
  title: {
200
256
  left: "top",
@@ -260,6 +316,16 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
260
316
  }
261
317
  const numericValues = seriesValues;
262
318
  if (spec.chartType === "pie") {
319
+ if (options.pieLabels) {
320
+ const { domainLabels, offset } = options.pieLabels;
321
+ const labels = offset === undefined
322
+ ? domainLabels
323
+ : domainLabels.slice(offset, offset + numericValues.length);
324
+ return labels.map((name, idx) => ({
325
+ value: numericValues[idx],
326
+ name,
327
+ }));
328
+ }
263
329
  const domainSheetRange = spec.domains[0]?.sources[0];
264
330
  if (!domainSheetRange) {
265
331
  return [];
@@ -270,7 +336,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
270
336
  name,
271
337
  }));
272
338
  }
273
- return numericValues;
339
+ // Excel's "Hidden and Empty Cells": gaps / zero / connect, and
340
+ // whether #N/A counts as empty.
341
+ return applyEmptyCellsPolicy(numericValues, rawSeriesValues, spec, options.lineLike);
274
342
  }, [getSeriesValuesFromRange, getDomainValuesFromRange]);
275
343
  // Resize a chart
276
344
  const resizeChart = () => {
@@ -353,6 +421,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
353
421
  }
354
422
  if (el.current && !chartRef.current) {
355
423
  chartRef.current = echarts.init(el.current);
424
+ bindSeriesSelection(chartRef.current);
356
425
  }
357
426
  if (!geo) {
358
427
  // Map asset failed to load (or unknown scope) — the loader
@@ -633,15 +702,22 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
633
702
  const seriesDisplayName = explicitChartTitle ?? headerTitle ?? rootName ?? "";
634
703
  const hierarchyDepth = getHierarchyDepth(hierarchyData);
635
704
  const levels = buildHierarchyLevels(hierarchyDepth);
705
+ // Sizes format like the cells they were read from (Excel's "Linked
706
+ // to source"), unless the data labels carry their own pattern.
707
+ const sizeNumberFormat = resolveSourceNumberFormat(spec.sizeData?.[0]
708
+ ?.sources, getFormattingFromRange, spec.hiddenDimensionStrategy);
636
709
  const tooltipFormatter = (params) => {
637
710
  const value = Array.isArray(params.value)
638
711
  ? params.value[params.value.length - 1]
639
712
  : params.value ?? "";
640
- return `${params.name}: ${value}`;
713
+ return `${params.name}: ${formatAxisValue(sizeNumberFormat, value)}`;
641
714
  };
642
715
  // Spec-level overrides — treemap/sunburst default to no legend,
643
716
  // but an authored legend (visibility/position) still wins.
644
- const hierarchySeriesLabel = buildSeriesLabelOption(spec.dataLabels);
717
+ const hierarchyDataLabels = spec.dataLabels;
718
+ const hierarchySeriesLabel = buildSeriesLabelOption(hierarchyDataLabels && !hierarchyDataLabels.numberFormatPattern
719
+ ? { ...hierarchyDataLabels, numberFormatPattern: sizeNumberFormat }
720
+ : hierarchyDataLabels);
645
721
  chartRef.current?.setOption({
646
722
  xAxis: [
647
723
  {
@@ -708,6 +784,129 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
708
784
  debug("Unsupported chart type", spec.chartType);
709
785
  return;
710
786
  }
787
+ // --------------------------- Contour ---------------------------
788
+ // Excel's Contour (`surfaceChart`) is the 3-D Surface seen from above:
789
+ // a categories × series grid colored by value. Drawn as a cartesian
790
+ // heatmap (no WebGL), distinct from the SURFACE_3D branch below.
791
+ const isContour = isContourSpec(spec);
792
+ if (renderedAsContour.current && !isContour) {
793
+ chartRef.current?.dispose();
794
+ chartRef.current = undefined;
795
+ renderedAsContour.current = false;
796
+ previousChartType.current = undefined;
797
+ previousSeriesCount.current = undefined;
798
+ initializeChart();
799
+ applyThemeOptions();
800
+ }
801
+ if (isContour) {
802
+ const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? ""));
803
+ const contourSeriesInputs = [];
804
+ for (const seriesEntry of spec.series ?? []) {
805
+ const dataLabel = !isNil(seriesEntry.dataLabel) && isAFormula(seriesEntry.dataLabel)
806
+ ? coerceCalculatedLabel(await onRequestCalculate(seriesEntry.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
807
+ : seriesEntry.dataLabel ?? "";
808
+ const values = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map(sanitizeChartValue);
809
+ contourSeriesInputs.push({ name: dataLabel, values });
810
+ }
811
+ const seriesNames = contourSeriesInputs.map((entry, index) => entry.name || `Series ${index + 1}`);
812
+ const contourPoints = buildContourData(contourSeriesInputs);
813
+ const [zMin, zMax] = getValueExtent(contourPoints.filter((point) => typeof point[2] === "number"));
814
+ // Values format like the cells of the series (row) they came from.
815
+ const contourFormats = resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy);
816
+ const contourLabel = buildSeriesLabelOption(spec.dataLabels);
817
+ if (!renderedAsContour.current && chartRef.current) {
818
+ chartRef.current.dispose();
819
+ chartRef.current = undefined;
820
+ }
821
+ if (el.current && !chartRef.current) {
822
+ chartRef.current = echarts.init(el.current);
823
+ bindSeriesSelection(chartRef.current);
824
+ }
825
+ chartRef.current?.setOption({
826
+ tooltip: {
827
+ textStyle: { fontSize: 12 },
828
+ trigger: "item",
829
+ formatter: (params) => {
830
+ const [x, y, z] = (params?.value ?? []);
831
+ const value = z === "-" || z === undefined
832
+ ? ""
833
+ : formatAxisValue(contourFormats[y], z);
834
+ return `${seriesNames[y] ?? ""}<br/>${categories[x] ?? ""}: ${value}`;
835
+ },
836
+ },
837
+ legend: applyLegendOverrides({ ...defaultLegendConfig, show: false }, spec.legend),
838
+ grid: { left: "3%", right: "12%", bottom: "3%", containLabel: true },
839
+ xAxis: [
840
+ applyAxisOverrides({
841
+ type: "category",
842
+ data: categories,
843
+ splitArea: { show: true },
844
+ name: spec.horizontalAxisTitle ?? "",
845
+ nameLocation: "middle",
846
+ nameGap: 30,
847
+ }, spec.categoryAxisOptions),
848
+ ],
849
+ yAxis: [
850
+ {
851
+ type: "category",
852
+ data: seriesNames,
853
+ splitArea: { show: true },
854
+ name: spec.verticalAxisTitle ?? "",
855
+ nameLocation: "middle",
856
+ nameGap: 50,
857
+ },
858
+ ],
859
+ visualMap: [
860
+ {
861
+ type: "continuous",
862
+ min: zMin,
863
+ max: zMax,
864
+ calculable: false,
865
+ orient: "vertical",
866
+ right: 0,
867
+ top: "center",
868
+ inRange: { color: [...SURFACE_COLOR_RAMP] },
869
+ formatter: (value) => formatAxisValue(contourFormats[0], value),
870
+ },
871
+ ],
872
+ series: [
873
+ {
874
+ type: "heatmap",
875
+ id: chartSeriesId(0, 0),
876
+ data: contourPoints,
877
+ ...(contourLabel
878
+ ? {
879
+ label: {
880
+ ...contourLabel,
881
+ position: "inside",
882
+ formatter: (params) => {
883
+ const [, y, z] = (params?.value ?? []);
884
+ return z === "-" || z === undefined
885
+ ? ""
886
+ : formatAxisValue(contourFormats[y], z);
887
+ },
888
+ },
889
+ }
890
+ : {}),
891
+ },
892
+ ],
893
+ }, {
894
+ replaceMerge: [
895
+ "series",
896
+ "xAxis",
897
+ "yAxis",
898
+ "visualMap",
899
+ "legend",
900
+ "grid",
901
+ "tooltip",
902
+ ],
903
+ });
904
+ renderedAsContour.current = true;
905
+ applyThemeOptions();
906
+ previousChartType.current = spec.chartType;
907
+ previousSeriesCount.current = 1;
908
+ return;
909
+ }
711
910
  // ----------------------------- 3-D -----------------------------
712
911
  // True 3-D rendering for degraded Excel 3-D / surface payloads,
713
912
  // keyed off `sourceChartType` (see the gating block above):
@@ -729,14 +928,14 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
729
928
  threeDSeriesInputs.push({ name: dataLabel, values });
730
929
  }
731
930
  const seriesNames = threeDSeriesInputs.map((entry, index) => entry.name || `Series ${index + 1}`);
732
- const threeDPalette = [
931
+ const threeDPalette = getChartPalette(spec, [
733
932
  theme.themeColors.accent1,
734
933
  theme.themeColors.accent2,
735
934
  theme.themeColors.accent3,
736
935
  theme.themeColors.accent4,
737
936
  theme.themeColors.accent5,
738
937
  theme.themeColors.accent6,
739
- ];
938
+ ]);
740
939
  const seriesColorAt = (index) => spec.series?.[index]?.color ??
741
940
  threeDPalette[index % threeDPalette.length];
742
941
  // Entering 3-D from a 2-D render: the cartesian option doesn't
@@ -748,22 +947,71 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
748
947
  }
749
948
  if (el.current && !chartRef.current) {
750
949
  chartRef.current = echarts.init(el.current);
950
+ bindSeriesSelection(chartRef.current);
751
951
  }
752
952
  // Value axes label integer positions with the category / series
753
953
  // name they map to; fractional ticks stay blank.
754
954
  const categoryAxisLabel = (value) => Number.isInteger(value) ? categories[value] ?? "" : "";
755
955
  const seriesAxisLabel = (value) => Number.isInteger(value) ? seriesNames[value] ?? "" : "";
956
+ // Excel's 3-D Stacked / 3-D 100% Stacked column and bar: the series
957
+ // share one depth row and stack on the value axis (echarts-gl bar3D
958
+ // stacks through echarts' data stacking) instead of fanning out one
959
+ // depth row per series like 3-D Column.
960
+ const stackedBar3D = threeDRenderKind === "bar3D" &&
961
+ (spec.stackedType === "STACKED" ||
962
+ spec.stackedType === "PERCENT_STACKED");
963
+ // Tooltip values are "Linked to source": each series' own cells.
964
+ const threeDFormats = resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy);
756
965
  const threeDTooltipFormatter = (params) => {
757
966
  const value = (params?.value ?? []);
758
967
  const categoryLabel = categories[Math.round(value[0] ?? 0)] ?? String(value[0] ?? "");
759
- const seriesLabel = seriesNames[Math.round(value[1] ?? 0)] ?? params?.seriesName ?? "";
760
- return `${seriesLabel ? `${seriesLabel}<br/>` : ""}${categoryLabel}: ${value[2] ?? ""}`;
968
+ // Stacked bars and line3D are one echarts series per spec series;
969
+ // a single bar3D / surface series carries the spec index on y.
970
+ const seriesIndex = stackedBar3D || threeDRenderKind === "line3D"
971
+ ? params?.seriesIndex
972
+ : Math.round(value[1] ?? 0);
973
+ const seriesLabel = stackedBar3D
974
+ ? params?.seriesName ?? ""
975
+ : seriesNames[Math.round(value[1] ?? 0)] ?? params?.seriesName ?? "";
976
+ const valueLabel = spec.stackedType === "PERCENT_STACKED" && stackedBar3D
977
+ ? `${Math.round((value[2] ?? 0) * 10) / 10}%`
978
+ : formatAxisValue(threeDFormats[seriesIndex], value[2]);
979
+ return `${seriesLabel ? `${seriesLabel}<br/>` : ""}${categoryLabel}: ${valueLabel}`;
761
980
  };
762
981
  let threeDSeries;
763
982
  let visualMap;
764
983
  let xAxis3D;
765
984
  let yAxis3D;
766
- if (threeDRenderKind === "bar3D") {
985
+ if (stackedBar3D) {
986
+ xAxis3D = { type: "category", data: categories };
987
+ yAxis3D = { type: "category", data: [""] };
988
+ const categoryCount = getCategoryCount(threeDSeriesInputs);
989
+ const categoryTotals = new Array(categoryCount).fill(0);
990
+ for (const entry of threeDSeriesInputs) {
991
+ entry.values.forEach((value, categoryIndex) => {
992
+ if (typeof value === "number" && Number.isFinite(value)) {
993
+ categoryTotals[categoryIndex] += value;
994
+ }
995
+ });
996
+ }
997
+ const isPercent = spec.stackedType === "PERCENT_STACKED";
998
+ threeDSeries = threeDSeriesInputs.map((entry, index) => ({
999
+ type: "bar3D",
1000
+ name: seriesNames[index],
1001
+ stack: "total",
1002
+ shading: "lambert",
1003
+ itemStyle: { color: seriesColorAt(index) },
1004
+ data: entry.values.flatMap((value, categoryIndex) => {
1005
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1006
+ return [];
1007
+ }
1008
+ const total = categoryTotals[categoryIndex];
1009
+ const z = isPercent ? (total === 0 ? 0 : (value / total) * 100) : value;
1010
+ return [{ value: [categoryIndex, 0, z] }];
1011
+ }),
1012
+ }));
1013
+ }
1014
+ else if (threeDRenderKind === "bar3D") {
767
1015
  xAxis3D = { type: "category", data: categories };
768
1016
  yAxis3D = { type: "category", data: seriesNames };
769
1017
  threeDSeries = [
@@ -834,7 +1082,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
834
1082
  textStyle: { fontSize: 12 },
835
1083
  formatter: threeDTooltipFormatter,
836
1084
  },
837
- legend: threeDRenderKind === "line3D"
1085
+ legend: threeDRenderKind === "line3D" || stackedBar3D
838
1086
  ? applyLegendOverrides({
839
1087
  ...defaultLegendConfig,
840
1088
  show: seriesNames.length > 1,
@@ -889,8 +1137,23 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
889
1137
  const radarStyle = spec.radarStyle ?? "standard";
890
1138
  const radarColors = radarSeriesInputs.map((_, i) => spec.series?.[i]?.color);
891
1139
  // Spec-level overrides — radar is polar (no cartesian axes), so
892
- // only legend + dataLabel overrides apply here.
893
- const radarSeriesLabel = buildSeriesLabelOption(spec.dataLabels);
1140
+ // only legend + dataLabel overrides apply here. Each data item is
1141
+ // one spec series, so labels and tooltip values format with that
1142
+ // series' own source cells.
1143
+ const radarFormats = resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy);
1144
+ const radarDataLabels = spec.dataLabels;
1145
+ const radarSeriesLabelFor = (seriesIndex) => buildSeriesLabelOption(radarDataLabels && !radarDataLabels.numberFormatPattern
1146
+ ? {
1147
+ ...radarDataLabels,
1148
+ numberFormatPattern: radarFormats[seriesIndex],
1149
+ }
1150
+ : radarDataLabels);
1151
+ const radarTooltipFormatter = (params) => {
1152
+ const values = Array.isArray(params?.value) ? params.value : [];
1153
+ const pattern = radarFormats[params?.dataIndex ?? 0];
1154
+ const lines = indicators.map((indicator, i) => `${indicator.name}: ${formatAxisValue(pattern, values[i])}`);
1155
+ return [params?.name ?? "", ...lines].filter(Boolean).join("<br/>");
1156
+ };
894
1157
  chartRef.current?.setOption({
895
1158
  radar: {
896
1159
  indicator: indicators.length
@@ -902,15 +1165,17 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
902
1165
  show: radarData.length > 1,
903
1166
  data: radarData.map((d) => d.name),
904
1167
  }, spec.legend),
905
- tooltip: { trigger: "item" },
1168
+ tooltip: { trigger: "item", formatter: radarTooltipFormatter },
906
1169
  series: [
907
1170
  {
908
1171
  type: "radar",
909
1172
  symbol: radarStyle === "standard" ? "none" : "circle",
910
- ...(radarSeriesLabel ? { label: radarSeriesLabel } : {}),
911
1173
  data: radarData.map((d, i) => ({
912
1174
  value: d.value,
913
1175
  name: d.name,
1176
+ ...(radarSeriesLabelFor(i)
1177
+ ? { label: radarSeriesLabelFor(i) }
1178
+ : {}),
914
1179
  ...(radarStyle === "filled" ? { areaStyle: {} } : {}),
915
1180
  ...(radarColors[i]
916
1181
  ? {
@@ -952,7 +1217,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
952
1217
  // volume pane exists), the value-axis overrides on the price
953
1218
  // pane's y-axis only — the volume pane keeps its own hidden-label
954
1219
  // value axis.
955
- const stockSeriesLabel = buildSeriesLabelOption(spec.dataLabels);
1220
+ // Each price line / the volume column maps back to one spec series
1221
+ // (`STOCK_ROLES` order), whose cells give its number format.
1222
+ const stockFormats = resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy);
1223
+ const stockRoleIndex = (role) => STOCK_ROLES[variant].indexOf(role);
1224
+ const stockFormatFor = (role) => stockFormats[stockRoleIndex(role)];
1225
+ const stockDataLabels = spec.dataLabels;
1226
+ const stockSeriesLabelFor = (role) => buildSeriesLabelOption(stockDataLabels && !stockDataLabels.numberFormatPattern
1227
+ ? { ...stockDataLabels, numberFormatPattern: stockFormatFor(role) }
1228
+ : stockDataLabels);
956
1229
  // V-variants (VHLC / VOHLC) carry a volume series that renders as
957
1230
  // a column sub-pane under the price plot: two grids stacked
958
1231
  // vertically (price ~70% of the plot, volume below on its own
@@ -1017,6 +1290,13 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1017
1290
  value: p.value,
1018
1291
  itemStyle: { color: p.up ? upColor : downColor },
1019
1292
  })),
1293
+ ...(tooltipValueFormatter(stockFormatFor("volume"))
1294
+ ? {
1295
+ tooltip: {
1296
+ valueFormatter: tooltipValueFormatter(stockFormatFor("volume")),
1297
+ },
1298
+ }
1299
+ : {}),
1020
1300
  },
1021
1301
  ]
1022
1302
  : [];
@@ -1059,6 +1339,13 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1059
1339
  borderColor: upColor,
1060
1340
  borderColor0: downColor,
1061
1341
  },
1342
+ ...(tooltipValueFormatter(stockFormatFor("close"))
1343
+ ? {
1344
+ tooltip: {
1345
+ valueFormatter: tooltipValueFormatter(stockFormatFor("close")),
1346
+ },
1347
+ }
1348
+ : {}),
1062
1349
  },
1063
1350
  ...volumeSeries,
1064
1351
  ],
@@ -1077,12 +1364,12 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1077
1364
  return;
1078
1365
  }
1079
1366
  const lineSeries = toLineSeries(seriesValues, variant);
1080
- const stockColors = [
1367
+ const stockColors = getChartPalette(spec, [
1081
1368
  theme.themeColors.accent1,
1082
1369
  theme.themeColors.accent2,
1083
1370
  theme.themeColors.accent3,
1084
1371
  theme.themeColors.accent4,
1085
- ];
1372
+ ]);
1086
1373
  chartRef.current?.setOption({
1087
1374
  xAxis: stockXAxis,
1088
1375
  yAxis: [
@@ -1112,7 +1399,16 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1112
1399
  showSymbol: false,
1113
1400
  itemStyle: { color: stockColors[i % stockColors.length] },
1114
1401
  lineStyle: { color: stockColors[i % stockColors.length] },
1115
- ...(stockSeriesLabel ? { label: stockSeriesLabel } : {}),
1402
+ ...(stockSeriesLabelFor(s.role)
1403
+ ? { label: stockSeriesLabelFor(s.role) }
1404
+ : {}),
1405
+ ...(tooltipValueFormatter(stockFormatFor(s.role))
1406
+ ? {
1407
+ tooltip: {
1408
+ valueFormatter: tooltipValueFormatter(stockFormatFor(s.role)),
1409
+ },
1410
+ }
1411
+ : {}),
1116
1412
  })),
1117
1413
  ...volumeSeries,
1118
1414
  ],
@@ -1145,32 +1441,51 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1145
1441
  ? sanitizeDomainValues(getDomainValuesFromRange(primaryDomainSource, spec.hiddenDimensionStrategy) ?? [])
1146
1442
  : [];
1147
1443
  const aggregateDomainValues = getDomainValuesForAxis(rawPrimaryDomainValues, shouldAggregate);
1148
- spec.domains?.forEach((domain, domainIndex) => {
1149
- domain.sources.forEach((sheetRange, sourceIndex) => {
1150
- let axisData = [];
1444
+ // A hierarchical category axis (PivotChart over nested row fields): the
1445
+ // innermost level is the category axis, the outer levels label-only axes
1446
+ // grouped beneath it — the domain cells are not read.
1447
+ const categoryLevels = isCircularChart
1448
+ ? undefined
1449
+ : resolveCategoryLevels(spec.categoryLevels?.map((level) => flattenChartDataValues([level], getDomainValuesFromRange, spec.hiddenDimensionStrategy, false)));
1450
+ if (categoryLevels) {
1451
+ chartDomains.push(...echartsGroupedCategoryAxes(categoryLevels, {
1452
+ type: "category",
1453
+ show: showAxis,
1454
+ axisLine: { show: true },
1455
+ axisTick: { show: true },
1456
+ splitLine: { show: true },
1457
+ name: isHorizontalBarChart
1458
+ ? spec.verticalAxisTitle ?? ""
1459
+ : spec.horizontalAxisTitle ?? "",
1460
+ nameLocation: "middle",
1461
+ nameGap: 30 + 24 * categoryLevels.outer.length,
1462
+ }));
1463
+ }
1464
+ // One category axis per domain (Excel): a domain drawn from several
1465
+ // ranges lists their categories in sequence on that single axis.
1466
+ (categoryLevels ? [] : spec.domains ?? []).forEach((domain, domainIndex) => {
1467
+ const axisData = domain.sources.flatMap((sheetRange, sourceIndex) => {
1151
1468
  if (domainIndex === 0 && sourceIndex === 0) {
1152
- axisData = aggregateDomainValues;
1469
+ return aggregateDomainValues;
1153
1470
  }
1154
- else {
1155
- const rawValues = sanitizeDomainValues(getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? []);
1156
- axisData = getDomainValuesForAxis(rawValues, shouldAggregate);
1157
- }
1158
- const axisTitle = domainIndex === 0 && sourceIndex === 0 && !isCircularChart
1159
- ? isHorizontalBarChart
1160
- ? spec.verticalAxisTitle ?? ""
1161
- : spec.horizontalAxisTitle ?? ""
1162
- : undefined;
1163
- chartDomains.push({
1164
- type: "category",
1165
- data: axisData,
1166
- show: showAxis && !isCircularChart,
1167
- axisLine: { show: !isCircularChart },
1168
- axisTick: { show: !isCircularChart },
1169
- splitLine: { show: !isCircularChart },
1170
- name: axisTitle,
1171
- nameLocation: "middle",
1172
- nameGap: 30,
1173
- });
1471
+ const rawValues = sanitizeDomainValues(getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? []);
1472
+ return getDomainValuesForAxis(rawValues, shouldAggregate);
1473
+ });
1474
+ const axisTitle = domainIndex === 0 && !isCircularChart
1475
+ ? isHorizontalBarChart
1476
+ ? spec.verticalAxisTitle ?? ""
1477
+ : spec.horizontalAxisTitle ?? ""
1478
+ : undefined;
1479
+ chartDomains.push({
1480
+ type: "category",
1481
+ data: axisData,
1482
+ show: showAxis && !isCircularChart,
1483
+ axisLine: { show: !isCircularChart },
1484
+ axisTick: { show: !isCircularChart },
1485
+ splitLine: { show: !isCircularChart },
1486
+ name: axisTitle,
1487
+ nameLocation: "middle",
1488
+ nameGap: 30,
1174
1489
  });
1175
1490
  });
1176
1491
  if (chartDomains.length === 0 && !isCircularChart) {
@@ -1347,7 +1662,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1347
1662
  : (spec.series ?? []).reduce((count, s) => {
1348
1663
  const seriesType = s.seriesChartType ?? spec.chartType;
1349
1664
  return seriesType === "bar" || seriesType === "column"
1350
- ? count + Math.max(1, s.sources?.length ?? 0)
1665
+ ? count + 1
1351
1666
  : count;
1352
1667
  }, 0);
1353
1668
  const specBarGap = overlapToBarGap(spec.overlap);
@@ -1355,7 +1670,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1355
1670
  seriesCount: sideBySideBarCount,
1356
1671
  overlap: isStacked ? 0 : spec.overlap,
1357
1672
  });
1358
- for (const series of spec.series ?? []) {
1673
+ for (const [specSeriesIndex, series] of (spec.series ?? []).entries()) {
1359
1674
  const dataLabel = !isNil(series.dataLabel) && isAFormula(series.dataLabel)
1360
1675
  ? coerceCalculatedLabel(await onRequestCalculate(series.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
1361
1676
  : series.dataLabel ?? "";
@@ -1363,133 +1678,203 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1363
1678
  if (dataLabel) {
1364
1679
  legend.data?.push(dataLabel);
1365
1680
  }
1366
- for (const sheetRange of series.sources) {
1367
- const data = getSeriesData(spec, sheetRange, {
1681
+ // Support per-series chart type for combo charts
1682
+ const effectiveChartType = series.seriesChartType ?? spec.chartType;
1683
+ const lineLike = isLineLikeChartType(effectiveChartType);
1684
+ // Excel plots a series drawn from several ranges as ONE series over
1685
+ // their union — every source's points in sequence, in step with the
1686
+ // single category axis that lists every domain source's categories
1687
+ // (C-68) — not one echarts series (and legend entry) per range.
1688
+ // Pie slices over several ranges label from the domain UNION — every
1689
+ // domain range's labels in sequence, each series range taking its own
1690
+ // window — not the primary domain range alone (C-78).
1691
+ const domainSources = spec.domains?.[0]?.sources ?? [];
1692
+ const pieDomainLabels = spec.chartType === "pie" &&
1693
+ (domainSources.length > 1 || series.sources.length > 1)
1694
+ ? domainSources.flatMap((sheetRange) => getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? [])
1695
+ : undefined;
1696
+ let piePointOffset = 0;
1697
+ const data = series.sources.flatMap((sheetRange) => {
1698
+ const part = getSeriesData(spec, sheetRange, {
1368
1699
  aggregateEnabled: shouldAggregate,
1369
1700
  aggregateType: series.aggregateType,
1370
1701
  rawDomainValues: rawPrimaryDomainValues,
1371
1702
  orderedDomainValues: aggregateDomainValues,
1703
+ lineLike,
1704
+ pieLabels: pieDomainLabels
1705
+ ? {
1706
+ domainLabels: pieDomainLabels,
1707
+ offset: series.sources.length > 1 ? piePointOffset : undefined,
1708
+ }
1709
+ : undefined,
1372
1710
  });
1373
- // Support per-series chart type for combo charts
1374
- const effectiveChartType = series.seriesChartType ?? spec.chartType;
1375
- const seriesEchartsType = chartTypeToEchartsType(effectiveChartType);
1376
- const seriesStyleAttrs = getEchartsSeriesStyle(effectiveChartType);
1377
- const seriesEntry = {
1378
- type: seriesEchartsType,
1379
- name: dataLabel,
1380
- data,
1381
- stack: isStacked ? "dataLabel" : undefined,
1382
- smooth: spec.smooth ?? undefined,
1383
- radius: spec.radius ?? (isCircularChart ? [0, "75%"] : undefined),
1384
- ...seriesStyleAttrs,
1385
- };
1386
- // Plot this series against a secondary value axis. The 2nd
1387
- // yAxis element is appended below (when any series opts in);
1388
- // here we point the series at index 1.
1389
- if (series.useSecondaryAxis) {
1390
- seriesEntry.yAxisIndex = 1;
1711
+ if (pieDomainLabels)
1712
+ piePointOffset += part.length;
1713
+ return part;
1714
+ });
1715
+ const seriesEchartsType = chartTypeToEchartsType(effectiveChartType);
1716
+ const seriesStyleAttrs = getEchartsSeriesStyle(effectiveChartType);
1717
+ const seriesEntry = {
1718
+ type: seriesEchartsType,
1719
+ // Ties the rendered series back to spec.series for click selection
1720
+ // One rendered series per spec series (source index 0).
1721
+ id: chartSeriesId(specSeriesIndex, 0),
1722
+ name: dataLabel,
1723
+ data,
1724
+ stack: isStacked ? "dataLabel" : undefined,
1725
+ smooth: spec.smooth ?? undefined,
1726
+ radius: spec.radius ?? (isCircularChart ? [0, "75%"] : undefined),
1727
+ // "Connect data points with line" bridges the empty cells.
1728
+ ...(connectsAcrossEmptyCells(spec, lineLike)
1729
+ ? { connectNulls: true }
1730
+ : {}),
1731
+ ...seriesStyleAttrs,
1732
+ };
1733
+ // Plot this series against a secondary value axis. The 2nd
1734
+ // yAxis element is appended below (when any series opts in);
1735
+ // here we point the series at index 1.
1736
+ if (series.useSecondaryAxis) {
1737
+ seriesEntry.yAxisIndex = 1;
1738
+ }
1739
+ // Handle invertIfNegative for bar/column charts
1740
+ const isBarOrColumn = effectiveChartType === "bar" || effectiveChartType === "column";
1741
+ // Bar layout (overlap/gapWidth) — echarts reads barGap /
1742
+ // barCategoryGap from bar series (the last bar series wins for
1743
+ // the whole coordinate system), so apply them to every
1744
+ // bar/column series. Set before the gradient/noFill branches so
1745
+ // every push path carries them.
1746
+ if (isBarOrColumn) {
1747
+ if (specBarGap !== undefined) {
1748
+ seriesEntry.barGap = specBarGap;
1391
1749
  }
1392
- // Handle invertIfNegative for bar/column charts
1393
- const isBarOrColumn = effectiveChartType === "bar" || effectiveChartType === "column";
1394
- // Bar layout (overlap/gapWidth) — echarts reads barGap /
1395
- // barCategoryGap from bar series (the last bar series wins for
1396
- // the whole coordinate system), so apply them to every
1397
- // bar/column series. Set before the gradient/noFill branches so
1398
- // every push path carries them.
1399
- if (isBarOrColumn) {
1400
- if (specBarGap !== undefined) {
1401
- seriesEntry.barGap = specBarGap;
1402
- }
1403
- if (specBarCategoryGap !== undefined) {
1404
- seriesEntry.barCategoryGap = specBarCategoryGap;
1405
- }
1750
+ if (specBarCategoryGap !== undefined) {
1751
+ seriesEntry.barCategoryGap = specBarCategoryGap;
1406
1752
  }
1407
- const seriesAny = series;
1408
- // Gradient fill — overrides solid color when present. echarts
1409
- // paints it natively via a LinearGradient on itemStyle.color
1410
- // (and areaStyle for area series / lineStyle for lines).
1411
- const gradientArgs = gradientToEcharts(series.gradient);
1412
- if (gradientArgs && !seriesAny.noFill) {
1413
- const gradient = new echarts.graphic.LinearGradient(gradientArgs.x, gradientArgs.y, gradientArgs.x2, gradientArgs.y2, gradientArgs.colorStops);
1414
- seriesEntry.itemStyle = {
1415
- ...(seriesEntry.itemStyle ?? {}),
1416
- color: gradient,
1417
- };
1418
- if (effectiveChartType === "area") {
1419
- seriesEntry.areaStyle = {
1420
- ...(seriesEntry.areaStyle ?? {}),
1421
- color: gradient,
1422
- };
1423
- }
1424
- chartSeries.push(seriesEntry);
1425
- pushSeriesOverlays(series, data, dataLabel);
1426
- continue;
1753
+ }
1754
+ const seriesAny = series;
1755
+ if (effectiveChartType === "line") {
1756
+ // Excel's "Line" draws no markers; "Line with Markers"
1757
+ // (spec.showMarkers) draws one per point. A series' own marker
1758
+ // wins either way: an authored symbol shows, "none" hides.
1759
+ const markerSymbol = seriesAny.marker?.symbol;
1760
+ const wantsMarkers = markerSymbol === "none"
1761
+ ? false
1762
+ : Boolean(spec.showMarkers) || !isNil(markerSymbol);
1763
+ seriesEntry.showSymbol = wantsMarkers;
1764
+ if (wantsMarkers) {
1765
+ seriesEntry.symbol =
1766
+ mapMarkerSymbolToEcharts(markerSymbol) ?? "circle";
1767
+ seriesEntry.symbolSize = seriesAny.marker?.size ?? 6;
1427
1768
  }
1428
- if (seriesAny.noFill) {
1429
- // Transparent series (Gantt lag offset, etc.) — fill it
1430
- // with `transparent` so layout is preserved but nothing
1431
- // paints. Legend entries are also suppressed via `name: ""`
1432
- // since the series carries no semantic information.
1433
- seriesEntry.itemStyle = {
1434
- ...(seriesEntry.itemStyle ?? {}),
1435
- color: "transparent",
1436
- borderColor: "transparent",
1769
+ }
1770
+ // Gradient fill — overrides solid color when present. echarts
1771
+ // paints it natively via a LinearGradient on itemStyle.color
1772
+ // (and areaStyle for area series / lineStyle for lines).
1773
+ const gradientArgs = gradientToEcharts(series.gradient);
1774
+ if (gradientArgs && !seriesAny.noFill) {
1775
+ const gradient = new echarts.graphic.LinearGradient(gradientArgs.x, gradientArgs.y, gradientArgs.x2, gradientArgs.y2, gradientArgs.colorStops);
1776
+ seriesEntry.itemStyle = {
1777
+ ...(seriesEntry.itemStyle ?? {}),
1778
+ color: gradient,
1779
+ };
1780
+ if (effectiveChartType === "area") {
1781
+ seriesEntry.areaStyle = {
1782
+ ...(seriesEntry.areaStyle ?? {}),
1783
+ color: gradient,
1437
1784
  };
1438
1785
  }
1439
- else if (isBarOrColumn &&
1440
- Array.isArray(seriesAny.dataPointColors) &&
1441
- seriesAny.dataPointColors.length) {
1442
- // Per-data-point color overrides — Gantt-style charts colour
1443
- // each task differently via `<c:dPt>`. Echarts accepts an
1444
- // `itemStyle.color` function that receives the data index;
1445
- // we look up the override or fall back to the series color.
1446
- const overrides = new Map();
1447
- for (const dp of seriesAny.dataPointColors) {
1448
- if (typeof dp.idx === "number" && typeof dp.color === "string") {
1449
- overrides.set(dp.idx, dp.color);
1450
- }
1786
+ }
1787
+ else if (seriesAny.noFill) {
1788
+ // Transparent series (Gantt lag offset, etc.) — fill it
1789
+ // with `transparent` so layout is preserved but nothing
1790
+ // paints. Legend entries are also suppressed via `name: ""`
1791
+ // since the series carries no semantic information.
1792
+ seriesEntry.itemStyle = {
1793
+ ...(seriesEntry.itemStyle ?? {}),
1794
+ color: "transparent",
1795
+ borderColor: "transparent",
1796
+ };
1797
+ }
1798
+ else if (isBarOrColumn &&
1799
+ Array.isArray(seriesAny.dataPointColors) &&
1800
+ seriesAny.dataPointColors.length) {
1801
+ // Per-data-point color overrides — Gantt-style charts colour
1802
+ // each task differently via `<c:dPt>`. Echarts accepts an
1803
+ // `itemStyle.color` function that receives the data index;
1804
+ // we look up the override or fall back to the series color.
1805
+ const overrides = new Map();
1806
+ for (const dp of seriesAny.dataPointColors) {
1807
+ if (typeof dp.idx === "number" && typeof dp.color === "string") {
1808
+ overrides.set(dp.idx, dp.color);
1451
1809
  }
1452
- const fallback = series.color ?? theme.themeColors.accent1;
1453
- seriesEntry.itemStyle = {
1454
- ...(seriesEntry.itemStyle ?? {}),
1455
- color: (params) => overrides.get(params.dataIndex) ?? fallback,
1456
- };
1457
1810
  }
1458
- else if (isBarOrColumn && series.invertIfNegative) {
1459
- const positiveColor = series.color ?? theme.themeColors.accent1;
1460
- const negativeColor = series.negativeColor ?? "#FF0000";
1461
- // Use a color function that returns different colors based on value
1462
- seriesEntry.itemStyle = {
1463
- ...(seriesEntry.itemStyle ?? {}),
1464
- color: (params) => {
1465
- const value = Array.isArray(params.data)
1466
- ? params.data[1]
1467
- : params.data?.value ?? params.data;
1468
- return typeof value === "number" && value < 0
1469
- ? negativeColor
1470
- : positiveColor;
1471
- },
1472
- };
1811
+ const fallback = series.color ?? theme.themeColors.accent1;
1812
+ seriesEntry.itemStyle = {
1813
+ ...(seriesEntry.itemStyle ?? {}),
1814
+ color: (params) => overrides.get(params.dataIndex) ?? fallback,
1815
+ };
1816
+ }
1817
+ else if (isCircularChart &&
1818
+ Array.isArray(seriesAny.dataPointColors) &&
1819
+ seriesAny.dataPointColors.length) {
1820
+ // Per-slice colours (`<c:dPt>` on a pie / doughnut series): an
1821
+ // authored slice colour wins, the other slices keep the palette
1822
+ // cycle the chart-level `color` option would give them.
1823
+ const overrides = new Map();
1824
+ for (const dp of seriesAny.dataPointColors) {
1825
+ if (typeof dp.idx === "number" && typeof dp.color === "string") {
1826
+ overrides.set(dp.idx, dp.color);
1827
+ }
1473
1828
  }
1474
- else if (series.color) {
1475
- seriesEntry.itemStyle = {
1476
- ...(seriesEntry.itemStyle ?? {}),
1477
- color: series.color,
1478
- };
1479
- seriesEntry.lineStyle = {
1480
- ...(seriesEntry.lineStyle ?? {}),
1829
+ const slicePalette = getChartPalette(spec, [
1830
+ theme.themeColors.accent1,
1831
+ theme.themeColors.accent2,
1832
+ theme.themeColors.accent3,
1833
+ theme.themeColors.accent4,
1834
+ theme.themeColors.accent5,
1835
+ theme.themeColors.accent6,
1836
+ ]);
1837
+ seriesEntry.itemStyle = {
1838
+ ...(seriesEntry.itemStyle ?? {}),
1839
+ color: (params) => overrides.get(params.dataIndex) ??
1840
+ series.color ??
1841
+ slicePalette[params.dataIndex % slicePalette.length],
1842
+ };
1843
+ }
1844
+ else if (isBarOrColumn && series.invertIfNegative) {
1845
+ const positiveColor = series.color ?? theme.themeColors.accent1;
1846
+ const negativeColor = series.negativeColor ?? "#FF0000";
1847
+ // Use a color function that returns different colors based on value
1848
+ seriesEntry.itemStyle = {
1849
+ ...(seriesEntry.itemStyle ?? {}),
1850
+ color: (params) => {
1851
+ const value = Array.isArray(params.data)
1852
+ ? params.data[1]
1853
+ : params.data?.value ?? params.data;
1854
+ return typeof value === "number" && value < 0
1855
+ ? negativeColor
1856
+ : positiveColor;
1857
+ },
1858
+ };
1859
+ }
1860
+ else if (series.color) {
1861
+ seriesEntry.itemStyle = {
1862
+ ...(seriesEntry.itemStyle ?? {}),
1863
+ color: series.color,
1864
+ };
1865
+ seriesEntry.lineStyle = {
1866
+ ...(seriesEntry.lineStyle ?? {}),
1867
+ color: series.color,
1868
+ };
1869
+ if (effectiveChartType === "area") {
1870
+ seriesEntry.areaStyle = {
1871
+ ...(seriesEntry.areaStyle ?? {}),
1481
1872
  color: series.color,
1482
1873
  };
1483
- if (effectiveChartType === "area") {
1484
- seriesEntry.areaStyle = {
1485
- ...(seriesEntry.areaStyle ?? {}),
1486
- color: series.color,
1487
- };
1488
- }
1489
1874
  }
1490
- chartSeries.push(seriesEntry);
1491
- pushSeriesOverlays(series, data, dataLabel);
1492
1875
  }
1876
+ chartSeries.push(seriesEntry);
1877
+ pushSeriesOverlays(series, data, dataLabel);
1493
1878
  }
1494
1879
  if (isOfPie) {
1495
1880
  // Pie-of-pie / bar-of-pie — Excel splits the minor slices into a
@@ -1540,14 +1925,14 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1540
1925
  chartSeries.length = 0;
1541
1926
  // Palette continues from the main plot into the secondary one so
1542
1927
  // slices keep distinct colors across both plots (Excel behavior).
1543
- const ofPiePalette = [
1928
+ const ofPiePalette = getChartPalette(spec, [
1544
1929
  theme.themeColors.accent1,
1545
1930
  theme.themeColors.accent2,
1546
1931
  theme.themeColors.accent3,
1547
1932
  theme.themeColors.accent4,
1548
1933
  theme.themeColors.accent5,
1549
1934
  theme.themeColors.accent6,
1550
- ];
1935
+ ]);
1551
1936
  const paletteAt = (index) => ofPiePalette[index % ofPiePalette.length];
1552
1937
  chartSeries.push({
1553
1938
  type: "pie",
@@ -1951,30 +2336,82 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1951
2336
  const hasSecondaryAxis = !isCircularChart &&
1952
2337
  !isHorizontalBarChart &&
1953
2338
  ((spec.series ?? []).some((s) => s.useSecondaryAxis) || isPareto);
2339
+ // "Linked to source" number formats. A percent-stacked value axis
2340
+ // plots normalized 0-100 percentages, so it never links to the
2341
+ // source cells; its data labels still format `originalValue` below.
2342
+ const isPercentStackedBars = spec.stackedType === "PERCENT_STACKED" &&
2343
+ (spec.chartType === "bar" || spec.chartType === "column");
2344
+ const primaryAxisNumberFormat = isPercentStackedBars
2345
+ ? undefined
2346
+ : resolveValueAxisNumberFormat({
2347
+ axisOptions: spec.valueAxisOptions,
2348
+ series: (spec.series ?? []).filter((s) => !s.useSecondaryAxis),
2349
+ getFormattingFromRange,
2350
+ hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
2351
+ });
2352
+ const secondaryAxisNumberFormat = resolveValueAxisNumberFormat({
2353
+ axisOptions: spec.secondaryValueAxisOptions,
2354
+ series: (spec.series ?? []).filter((s) => s.useSecondaryAxis),
2355
+ getFormattingFromRange,
2356
+ hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
2357
+ });
2358
+ const axisNumberFormatFor = (s) => s?.yAxisIndex === 1
2359
+ ? secondaryAxisNumberFormat
2360
+ : primaryAxisNumberFormat;
2361
+ // Data labels and tooltips are linked to each series' OWN source
2362
+ // cells (Excel's `<c:dLbls><c:numFmt sourceLinked="1">`); the axis
2363
+ // format only steps in for a series whose cells are General.
2364
+ const seriesNumberFormats = resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy);
2365
+ const seriesNumberFormatFor = (s) => {
2366
+ const specIndex = chartSeriesSpecIndex(s?.id);
2367
+ return ((specIndex === undefined ? undefined : seriesNumberFormats[specIndex]) ??
2368
+ axisNumberFormatFor(s));
2369
+ };
1954
2370
  const tooltipConfig = {
1955
2371
  textStyle: {
1956
2372
  fontSize: 12,
1957
2373
  },
1958
2374
  trigger: isCircularChart ? "item" : "axis",
1959
- formatter: isCircularChart ? "{b}: {c} ({d}%)" : undefined,
2375
+ formatter: isCircularChart
2376
+ ? primaryAxisNumberFormat
2377
+ ? circularTooltipFormatter(primaryAxisNumberFormat)
2378
+ : "{b}: {c} ({d}%)"
2379
+ : undefined,
1960
2380
  };
1961
2381
  // Phase 5.1: apply legend / axis / dataLabels / doughnut overrides.
1962
2382
  const finalLegend = applyLegendOverrides(legend, spec.legend);
1963
- const seriesLabelOption = buildSeriesLabelOption(spec.dataLabels);
2383
+ // Data labels fall back to the series' axis format when the spec has
2384
+ // no explicit `<c:dLbls><c:numFmt>` — Excel's "Linked to source".
2385
+ const dataLabelOptions = spec.dataLabels;
2386
+ const seriesLabelOptionFor = (s) => buildSeriesLabelOption(dataLabelOptions && !dataLabelOptions.numberFormatPattern
2387
+ ? {
2388
+ ...dataLabelOptions,
2389
+ numberFormatPattern: seriesNumberFormatFor(s),
2390
+ }
2391
+ : dataLabelOptions);
1964
2392
  // For doughnut, surface the inner-radius hole on each pie series.
1965
2393
  // Excel persists `holeSize` as a 0-90 percentage; we normalize to a
1966
2394
  // 0-1 fraction (doughnutHoleSize). Default to 0.5 when unset.
1967
2395
  const isDoughnut = spec.chartType === "doughnut";
1968
2396
  const doughnutHoleSize = spec.doughnutHoleSize;
2397
+ // Exploded pie / doughnut: every slice sits in the `select` state,
2398
+ // offset outward by the spec's percentage of the pie radius.
2399
+ const explosion = isCircularChart && !isOfPie ? resolveExplosion(spec) : 0;
2400
+ explodedRef.current = explosion > 0;
2401
+ const explosionOffset = explosionOffsetPx(explosion, pieRadiusPx(isDoughnut ? [0, "75%"] : spec.radius, width ?? 0, height ?? 0));
1969
2402
  // PERCENT_STACKED — echarts has no built-in `barnorm: 'percent'`,
1970
2403
  // so we normalize per category here: each cell becomes
1971
2404
  // (value / category_total) * 100, while the original value is
1972
2405
  // tucked into a sibling field so the label formatter can still
1973
- // surface the dollar amount. Touches only bar/column series in
1974
- // the percent-stacked spec; other charts pass through unchanged.
2406
+ // surface the dollar amount. Touches only the stacked base series
2407
+ // (bar/column, and the line series of 100% stacked line / area
2408
+ // charts); overlays and other charts pass through unchanged.
1975
2409
  if (spec.stackedType === "PERCENT_STACKED" &&
1976
- (spec.chartType === "bar" || spec.chartType === "column")) {
1977
- const barSeries = chartSeries.filter((s) => s?.type === "bar");
2410
+ (spec.chartType === "bar" ||
2411
+ spec.chartType === "column" ||
2412
+ spec.chartType === "line" ||
2413
+ spec.chartType === "area")) {
2414
+ const barSeries = chartSeries.filter((s) => s?.stack !== undefined && (s?.type === "bar" || s?.type === "line"));
1978
2415
  // Length of the value axis = max length across stacked series.
1979
2416
  const len = barSeries.reduce((m, s) => Math.max(m, Array.isArray(s.data) ? s.data.length : 0), 0);
1980
2417
  const categoryTotals = new Array(len).fill(0);
@@ -2003,9 +2440,37 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2003
2440
  }
2004
2441
  const seriesWithLabels = chartSeries.map((s) => {
2005
2442
  const next = { ...s };
2443
+ const seriesLabelOption = seriesLabelOptionFor(s);
2006
2444
  if (seriesLabelOption) {
2007
2445
  next.label = { ...(s?.label ?? {}), ...seriesLabelOption };
2008
2446
  }
2447
+ // Tooltip values of the value-axis series (a boxplot / candlestick
2448
+ // tooltip lists its own statistics and keeps echarts' default).
2449
+ const seriesTooltipValueFormatter = VALUE_TOOLTIP_SERIES_TYPES.has(s?.type)
2450
+ ? valueAxisLabelFormatter(seriesNumberFormatFor(s))
2451
+ : undefined;
2452
+ if (seriesTooltipValueFormatter && s?.tooltip?.show !== false) {
2453
+ next.tooltip = {
2454
+ ...(s?.tooltip ?? {}),
2455
+ valueFormatter: seriesTooltipValueFormatter,
2456
+ };
2457
+ }
2458
+ if (next.type === "pie") {
2459
+ const pieNumberFormat = seriesNumberFormatFor(s);
2460
+ if (pieNumberFormat && s?.tooltip?.show !== false) {
2461
+ next.tooltip = {
2462
+ ...(s?.tooltip ?? {}),
2463
+ formatter: circularTooltipFormatter(pieNumberFormat),
2464
+ };
2465
+ }
2466
+ if (explosionOffset > 0 && Array.isArray(next.data)) {
2467
+ next.selectedMode = "multiple";
2468
+ next.selectedOffset = explosionOffset;
2469
+ next.data = next.data.map((item) => item !== null && typeof item === "object"
2470
+ ? { ...item, selected: true }
2471
+ : { value: item, selected: true });
2472
+ }
2473
+ }
2009
2474
  if (isDoughnut && next.type === "pie") {
2010
2475
  const inner = typeof doughnutHoleSize === "number"
2011
2476
  ? Math.round(Math.max(0, Math.min(0.9, doughnutHoleSize)) * 100)
@@ -2061,7 +2526,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2061
2526
  applyAxisOverrides({
2062
2527
  ...valueAxisBase,
2063
2528
  name: spec.horizontalAxisTitle ?? "",
2064
- }, spec.valueAxisOptions),
2529
+ }, spec.valueAxisOptions, primaryAxisNumberFormat),
2065
2530
  ];
2066
2531
  }
2067
2532
  const base = chartDomains.length > 0
@@ -2136,7 +2601,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2136
2601
  return base.map((axis) => applyAxisOverrides(axis, spec.categoryAxisOptions));
2137
2602
  }
2138
2603
  const axes = [
2139
- applyAxisOverrides({ ...valueAxisBase, name: spec.verticalAxisTitle ?? "" }, spec.valueAxisOptions),
2604
+ applyAxisOverrides({ ...valueAxisBase, name: spec.verticalAxisTitle ?? "" }, spec.valueAxisOptions, primaryAxisNumberFormat),
2140
2605
  ];
2141
2606
  if (hasSecondaryAxis) {
2142
2607
  axes.push(applyAxisOverrides({
@@ -2151,7 +2616,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2151
2616
  axisLabel: { formatter: "{value}%" },
2152
2617
  }
2153
2618
  : {}),
2154
- }, spec.secondaryValueAxisOptions));
2619
+ }, spec.secondaryValueAxisOptions, secondaryAxisNumberFormat));
2155
2620
  }
2156
2621
  return axes;
2157
2622
  })(),
@@ -2175,6 +2640,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2175
2640
  CandlestickChart,
2176
2641
  CustomChart,
2177
2642
  FunnelChart,
2643
+ HeatmapChart,
2178
2644
  LineChart,
2179
2645
  PieChart,
2180
2646
  EchartsRadarChart,
@@ -2183,6 +2649,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2183
2649
  SunburstChart,
2184
2650
  CanvasRenderer,
2185
2651
  DatasetComponent,
2652
+ VisualMapComponent,
2186
2653
  ]);
2187
2654
  initializeChart();
2188
2655
  }, []);
@@ -2244,14 +2711,17 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2244
2711
  color: textColor,
2245
2712
  },
2246
2713
  },
2247
- color: [
2714
+ color: getChartPalette(chart.spec, [
2248
2715
  theme.themeColors.accent1,
2249
2716
  theme.themeColors.accent2,
2250
2717
  theme.themeColors.accent3,
2251
2718
  theme.themeColors.accent4,
2252
2719
  theme.themeColors.accent5,
2253
2720
  theme.themeColors.accent6,
2254
- ],
2721
+ ]),
2722
+ // Excel's Format Chart Area fill; `transparent` (echarts' own
2723
+ // default) so clearing the fill undoes an earlier setOption.
2724
+ backgroundColor: chart.spec.backgroundColor ?? "transparent",
2255
2725
  };
2256
2726
  // Skip when rendering 3-D or a region map: merging a cartesian
2257
2727
  // yAxis into a grid3D / map option would create a stray empty
@@ -2273,7 +2743,14 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2273
2743
  // Update chart properties
2274
2744
  useIsomorphicLayoutEffect(() => {
2275
2745
  applyThemeOptions();
2276
- }, [theme, title, isDarkMode, chart.spec.chartType]);
2746
+ }, [
2747
+ theme,
2748
+ title,
2749
+ isDarkMode,
2750
+ chart.spec.chartType,
2751
+ chart.spec.palette,
2752
+ chart.spec.backgroundColor,
2753
+ ]);
2277
2754
  // Draw chart. `glUpgradeTick` re-runs the draw when the lazily loaded
2278
2755
  // echarts-gl module resolves, upgrading a 3-D spec's immediate 2-D
2279
2756
  // fallback to the true 3-D rendering.
@@ -2283,6 +2760,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2283
2760
  chart.spec,
2284
2761
  getDomainValuesFromRange,
2285
2762
  getSeriesValuesFromRange,
2763
+ getFormattingFromRange,
2286
2764
  glUpgradeTick,
2287
2765
  ]);
2288
2766
  // Resize chart
@@ -2297,15 +2775,38 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2297
2775
  useIsomorphicLayoutEffect(() => {
2298
2776
  chartRef.current?.resize({ silent: true });
2299
2777
  }, [width, height]);
2778
+ const chartPngDataUrl = () => chartRef.current?.getDataURL({
2779
+ type: "png",
2780
+ pixelRatio: 2,
2781
+ backgroundColor: chart.spec.backgroundColor ??
2782
+ getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme),
2783
+ });
2300
2784
  const onDownloadChart = () => {
2301
- if (chartRef.current) {
2302
- const url = chartRef.current.getDataURL({
2303
- type: "png",
2304
- backgroundColor: getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme),
2305
- });
2785
+ const url = chartPngDataUrl();
2786
+ if (url) {
2306
2787
  createDownloadableFile(chart.spec.title ?? "Chart", url);
2307
2788
  }
2308
2789
  };
2790
+ // Copy as Picture / print preview — the same raster as the download.
2791
+ const exportChartPng = async () => dataUrlToBlob(chartPngDataUrl());
2792
+ // Excel's "Switch Row/Column" — offered to the grid's right-click menu
2793
+ // when the host can persist the chart and the sources transpose.
2794
+ const switchRowColumnAction = onUpdateChart && !readonly && canSwitchRowColumn(spec)
2795
+ ? () => {
2796
+ const next = switchRowColumn(spec, {
2797
+ getFormattedValue: cellLabelGetterFromRangeReader(getDomainValuesFromRange),
2798
+ });
2799
+ if (next)
2800
+ onUpdateChart({ ...chart, spec: next });
2801
+ }
2802
+ : undefined;
2803
+ // The grid's right-click menu offers these without knowing the renderer.
2804
+ useRegisterChartActions(chart.chartId, {
2805
+ requestEdit: onRequestEdit ? () => onRequestEdit(chart) : undefined,
2806
+ downloadPng: onDownloadChart,
2807
+ exportPng: exportChartPng,
2808
+ switchRowColumn: switchRowColumnAction,
2809
+ });
2309
2810
  // Excel's "Rounded Corners" chart style (`<c:roundedCorners val="1"/>`
2310
2811
  // at the chartSpace root). Default per OOXML §21.2.2.183 is square,
2311
2812
  // so only opt in when the workbook explicitly set the flag — otherwise
@@ -2314,6 +2815,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2314
2815
  ? { borderRadius: 8, overflow: "hidden" }
2315
2816
  : undefined;
2316
2817
  return (React.createElement("div", { className: "flex flex-1", style: roundedCornersStyle },
2818
+ fieldButtons,
2317
2819
  isSelected ? (React.createElement(DropdownMenu, null,
2318
2820
  React.createElement(DropdownMenuTrigger, { asChild: true },
2319
2821
  React.createElement(IconButton, { className: "absolute right-1 top-1 z-10" },