@rowsncolumns/charts 28.0.0 → 28.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +13 -9
@@ -1,16 +1,27 @@
1
1
  import { DotsVerticalIcon } from "@rowsncolumns/icons";
2
- import { getStringifiedColor, useFocusSheet } from "@rowsncolumns/spreadsheet";
2
+ import { getStringifiedColor, useFocusSheet, useRegisterChartActions, useSetSelectedChartSeries, } from "@rowsncolumns/spreadsheet";
3
3
  import { useSubscribeToSheet } from "@rowsncolumns/spreadsheet-state";
4
4
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger, IconButton, useIsomorphicLayoutEffect, useOnVisibilityChange, } from "@rowsncolumns/ui";
5
5
  import { debug, isAFormula, isNil, ssfFormat } from "@rowsncolumns/utils";
6
- import React, { useCallback, useRef, useState } from "react";
6
+ import React, { useCallback, useMemo, useRef, useState } from "react";
7
7
  import Plot from "react-plotly.js";
8
8
  import { aggregateSeriesByDomain, getDomainValuesForAxis, sanitizeDomainValues, shouldAggregateSeries, } from "../../utils/aggregation";
9
+ import { formatAxisValue, numberFormatToPlotlyTicks, resolveSeriesNumberFormats, resolveValueAxisNumberFormat, } from "../../utils/axis-number-format";
9
10
  import { computeBoxplotSummary } from "../../utils/boxplot";
10
11
  import { createBubbleSizeScale, prepareBubbleSeriesData, toLabelString, } from "../../utils/bubble";
12
+ import { canSwitchRowColumn, cellLabelGetterFromRangeReader, switchRowColumn, } from "../../utils/chart-orientation";
13
+ import { dataUrlToBlob } from "../../utils/chart-image";
14
+ import { getChartPalette } from "../../utils/chart-styles";
11
15
  import { coerceCalculatedLabel } from "../../utils/chart-spec-utils";
16
+ import { applyEmptyCellsPolicy, connectsAcrossEmptyCells, isLineLikeChartType, } from "../../utils/empty-cells";
12
17
  import { computeErrorBars, resolveCustomErrorBarValues, } from "../../utils/error-bars";
18
+ import { plotlyMulticategoryX, resolveCategoryLevels, } from "../../utils/category-levels";
13
19
  import { gradientToPlotly } from "../../utils/gradient";
20
+ import { plotlyTitleText } from "../../utils/plotly-title";
21
+ import { explosionToPlotlyPull, resolveExplosion, } from "../../utils/pie-explosion";
22
+ import { resolvePlotlyClickSelection, wasDragged, } from "../../utils/plotly-click";
23
+ import { buildPlotlySurfaceTrace } from "../../utils/plotly-surface";
24
+ import { normalize3DSourceType, } from "../../utils/three-d";
14
25
  import { extractHierarchyRows, getHierarchyHeaderTitle, toPlotlyHierarchy, } from "../../utils/hierarchy";
15
26
  import { binHistogram, computePareto, isParetoSpec, } from "../../utils/histogram";
16
27
  import { clampSecondPieSize, isOfPieChartSpec, otherSliceHalfAngle, resolveOfPieType, splitOfPie, } from "../../utils/of-pie";
@@ -104,10 +115,20 @@ const applyLegendOverridesPlotly = (legend, opts) => {
104
115
  next.font = applyTextFormatToFont(next.font, opts.textFormat);
105
116
  return { legend: next, showlegend: true };
106
117
  };
107
- const applyAxisOverridesPlotly = (axis, opts) => {
108
- if (!opts)
118
+ /**
119
+ * `numberFormatPattern` is the axis' RESOLVED format (explicit pattern or
120
+ * the linked source-cell format). Plotly formats ticks AND hover values
121
+ * through the axis `tickformat` / prefix / suffix, so converting the
122
+ * pattern once covers both; patterns outside the d3 subset keep plotly's
123
+ * default labels.
124
+ */
125
+ const applyAxisOverridesPlotly = (axis, opts, numberFormatPattern) => {
126
+ const ticks = numberFormatToPlotlyTicks(numberFormatPattern);
127
+ if (!opts && !ticks)
109
128
  return axis;
110
- const next = { ...(axis ?? {}) };
129
+ const next = { ...(axis ?? {}), ...(ticks ?? {}) };
130
+ if (!opts)
131
+ return next;
111
132
  if (opts.logarithmic)
112
133
  next.type = "log";
113
134
  if (opts.reverse)
@@ -148,7 +169,7 @@ const PLOTLY_DLBL_POS_BAR = {
148
169
  outsideEnd: "outside",
149
170
  bestFit: "outside",
150
171
  };
151
- const applyDataLabelsToTracePlotly = (trace, opts, chartType) => {
172
+ const applyDataLabelsToTracePlotly = (trace, opts, chartType, sourceNumberFormat) => {
152
173
  if (!opts?.show)
153
174
  return trace;
154
175
  const next = { ...trace };
@@ -166,18 +187,33 @@ const applyDataLabelsToTracePlotly = (trace, opts, chartType) => {
166
187
  parts.push("percent");
167
188
  next.textinfo = parts.length ? parts.join("+") : "value";
168
189
  next.textposition = position;
190
+ // Values format like their source cells (or the authored `<c:numFmt>`):
191
+ // plotly's own `%{value}` is a raw number, so the formatted strings
192
+ // ride on `text` and a `texttemplate` mirrors the textinfo parts.
193
+ const pattern = opts.numberFormatPattern ?? sourceNumberFormat;
194
+ if (pattern) {
195
+ const values = Array.isArray(next.values) ? next.values : [];
196
+ next.text = values.map((v) => formatAxisValue(pattern, v));
197
+ next.texttemplate = next.textinfo
198
+ .split("+")
199
+ .map((part) => (part === "value" ? "%{text}" : `%{${part}}`))
200
+ .join("<br>");
201
+ }
169
202
  }
170
203
  else {
171
204
  // <c:dLbls><c:numFmt formatCode=…> — when authored, run each value
172
205
  // through the same numfmt grammar the spreadsheet uses (`"$"#,##0`
173
206
  // → `$1,831`). Plotly supports this natively via `texttemplate`
174
207
  // with the d3-format mini-language, but our patterns are
175
- // SpreadsheetML — easier to pre-format the strings.
176
- const pattern = opts.numberFormatPattern;
177
- const yVals = Array.isArray(next.y) ? next.y : [];
208
+ // SpreadsheetML — easier to pre-format the strings. Without an
209
+ // authored pattern the labels link to the source-cell format.
210
+ const pattern = opts.numberFormatPattern ?? sourceNumberFormat;
211
+ // Horizontal bars carry their values on `x`.
212
+ const values = next.orientation === "h" ? next.x : next.y;
213
+ const vals = Array.isArray(values) ? values : [];
178
214
  next.text = pattern
179
- ? yVals.map((v) => (v === null || v === undefined ? "" : ssfFormat(pattern, v)))
180
- : next.y;
215
+ ? vals.map((v) => (v === null || v === undefined ? "" : ssfFormat(pattern, v)))
216
+ : values;
181
217
  next.textposition = position;
182
218
  }
183
219
  return next;
@@ -251,6 +287,30 @@ const sanitizeChartValue = (value) => {
251
287
  }
252
288
  return undefined;
253
289
  };
290
+ /**
291
+ * A series drawn from several ranges is ONE series over their union
292
+ * (Excel): every range's points in sequence. A single range passes through
293
+ * untouched so the common path renders exactly as before.
294
+ */
295
+ const mergeSeriesData = (parts) => {
296
+ if (parts.length === 1)
297
+ return parts[0];
298
+ if (parts.some((part) => !Array.isArray(part))) {
299
+ const values = [];
300
+ const labels = [];
301
+ for (const part of parts) {
302
+ if (Array.isArray(part)) {
303
+ values.push(...part);
304
+ }
305
+ else {
306
+ values.push(...part.values);
307
+ labels.push(...part.labels);
308
+ }
309
+ }
310
+ return { values, labels };
311
+ }
312
+ return parts.flatMap((part) => part);
313
+ };
254
314
  /**
255
315
  * Add alpha transparency to a color (hex or rgb)
256
316
  */
@@ -274,13 +334,36 @@ const addAlphaToColor = (color, alpha) => {
274
334
  }
275
335
  return color;
276
336
  };
277
- export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, onDeleteChart, onRequestEdit, onRequestCalculate, isSelected, }) => {
337
+ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, getFormattingFromRange, onDeleteChart, onUpdateChart, onRequestEdit, onRequestCalculate, isSelected, fieldButtons, }) => {
278
338
  const spec = chart.spec;
279
339
  const hierarchyTitleRef = useRef(undefined);
280
340
  const explicitChartTitle = chart.spec.title ?? undefined;
281
341
  const title = explicitChartTitle ?? hierarchyTitleRef.current;
282
342
  const plotRef = useRef(null);
343
+ // The plotted `<div>` plotly hands back on init — what `Plotly.toImage`
344
+ // rasterises for Copy as Picture and the print preview.
345
+ const graphDivRef = useRef(null);
283
346
  const focusSheet = useFocusSheet();
347
+ // Excel selects a data series (or one data point) when it is clicked
348
+ // inside the chart and the grid highlights its source cells. The plot
349
+ // is interactive with `dragmode: false`, so plotly reports the click but
350
+ // never starts its own drag — a body drag still moves the frame. plotly
351
+ // does not see that drag either, so its mouseup still counts as a click:
352
+ // the pointer-down position tells the two apart.
353
+ const setSelectedChartSeries = useSetSelectedChartSeries();
354
+ const pointerDownRef = useRef(undefined);
355
+ const handlePointerDownCapture = useCallback((event) => {
356
+ pointerDownRef.current = { x: event.clientX, y: event.clientY };
357
+ }, []);
358
+ const handlePlotClick = useCallback((event) => {
359
+ if (wasDragged(pointerDownRef.current, event.event)) {
360
+ return;
361
+ }
362
+ const selection = resolvePlotlyClickSelection(event.points, chart.spec.series?.length ?? 0);
363
+ if (!selection)
364
+ return;
365
+ setSelectedChartSeries({ chartId: chart.chartId, ...selection });
366
+ }, [chart, setSelectedChartSeries]);
284
367
  const shouldFocusSheetRef = useRef(false);
285
368
  const [plotData, setPlotData] = useState([]);
286
369
  const [layout, setLayout] = useState({});
@@ -307,6 +390,15 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
307
390
  // Use sanitized values - errors like #N/A become undefined
308
391
  const seriesValues = sanitizedValues;
309
392
  if ((spec.chartType === "pie" || spec.chartType === "doughnut")) {
393
+ if (options.pieLabels) {
394
+ const { domainLabels, offset } = options.pieLabels;
395
+ return {
396
+ values: seriesValues,
397
+ labels: offset === undefined
398
+ ? domainLabels
399
+ : domainLabels.slice(offset, offset + seriesValues.length),
400
+ };
401
+ }
310
402
  const domainSheetRange = spec.domains[0]?.sources[0];
311
403
  if (!domainSheetRange) {
312
404
  return { values: [], labels: [] };
@@ -317,7 +409,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
317
409
  labels: domainValues,
318
410
  };
319
411
  }
320
- return seriesValues;
412
+ // Excel's "Hidden and Empty Cells": gaps / zero / connect, and
413
+ // whether #N/A counts as empty.
414
+ return applyEmptyCellsPolicy(seriesValues, rawSeriesValues, spec, options.lineLike);
321
415
  }, [getSeriesValuesFromRange, getDomainValuesFromRange]);
322
416
  /**
323
417
  * Triggers a chart update
@@ -333,7 +427,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
333
427
  theme: 1,
334
428
  tint: isDarkMode ? 1 : 0,
335
429
  }, theme);
336
- const bgColor = getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme);
430
+ // Excel's Format Chart Area fill wins over the theme paper colour.
431
+ const bgColor = spec.backgroundColor ??
432
+ getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme);
337
433
  const gridColor = isDarkMode ? "rgba(255,255,255,0.3)" : "#CCCCCC";
338
434
  if (isBubble) {
339
435
  const skipHeaders = spec.useFirstRowAsHeaders !== false;
@@ -376,14 +472,14 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
376
472
  });
377
473
  const sizeScale = createBubbleSizeScale(sizeValues);
378
474
  const hasStringX = categories.length > 0;
379
- const palette = [
475
+ const palette = getChartPalette(spec, [
380
476
  theme.themeColors.accent1,
381
477
  theme.themeColors.accent2,
382
478
  theme.themeColors.accent3,
383
479
  theme.themeColors.accent4,
384
480
  theme.themeColors.accent5,
385
481
  theme.themeColors.accent6,
386
- ];
482
+ ]);
387
483
  // Scatter and bubble share the same "BubbleChartSpec" shape but
388
484
  // render very differently. Scatter is small fixed-size markers
389
485
  // (optionally connected by a line per `scatterStyle`); bubble
@@ -485,7 +581,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
485
581
  const legendFmt = spec.legend?.textFormat;
486
582
  const bubbleLayout = {
487
583
  title: {
488
- text: title ?? undefined,
584
+ text: plotlyTitleText(title, spec.titleTextFormat),
489
585
  font: {
490
586
  family: titleFmt?.fontFamily ?? "Untitled Sans, apple-system, sans-serif",
491
587
  size: titleFmt?.fontSize ?? 16,
@@ -562,7 +658,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
562
658
  };
563
659
  const layoutForHierarchy = {
564
660
  title: {
565
- text: title ?? undefined,
661
+ text: plotlyTitleText(title, spec.titleTextFormat),
566
662
  font: {
567
663
  family: "Untitled Sans, apple-system, sans-serif",
568
664
  size: 16,
@@ -638,7 +734,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
638
734
  };
639
735
  setPlotData([regionTrace]);
640
736
  setLayout({
641
- title: { text: title ?? undefined },
737
+ title: { text: plotlyTitleText(title, spec.titleTextFormat) },
642
738
  font: { color: textColor },
643
739
  paper_bgcolor: bgColor,
644
740
  plot_bgcolor: bgColor,
@@ -662,6 +758,69 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
662
758
  });
663
759
  return;
664
760
  }
761
+ // ------------------------ Surface / Contour ------------------------
762
+ // Excel's Surface family: `surface3DChart` is a 3-D height field over
763
+ // categories × series (plotly `surface`, WebGL), `surfaceChart` is
764
+ // its top-down Contour view (plotly `contour`, SVG). Both take the
765
+ // same z grid: one row per series, one column per category.
766
+ const surfaceToken = normalize3DSourceType(spec.sourceChartType);
767
+ if (surfaceToken === "SURFACE" || surfaceToken === "SURFACE_3D") {
768
+ const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? ""));
769
+ const surfaceSeriesInputs = [];
770
+ for (const seriesEntry of spec.series ?? []) {
771
+ const dataLabel = !isNil(seriesEntry.dataLabel) && isAFormula(seriesEntry.dataLabel)
772
+ ? coerceCalculatedLabel(await onRequestCalculate(seriesEntry.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
773
+ : seriesEntry.dataLabel ?? "";
774
+ const values = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)));
775
+ surfaceSeriesInputs.push({ name: dataLabel, values });
776
+ }
777
+ const seriesNames = surfaceSeriesInputs.map((entry, index) => entry.name || `Series ${index + 1}`);
778
+ const surfaceTrace = buildPlotlySurfaceTrace(surfaceToken, surfaceSeriesInputs, categories, seriesNames, resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy));
779
+ const surfaceLayout = {
780
+ title: {
781
+ text: plotlyTitleText(title, spec.titleTextFormat),
782
+ font: {
783
+ family: "Untitled Sans, apple-system, sans-serif",
784
+ size: 16,
785
+ color: textColor,
786
+ },
787
+ x: 0.02,
788
+ xanchor: "left",
789
+ },
790
+ font: {
791
+ family: "Untitled Sans, apple-system, sans-serif",
792
+ size: 12,
793
+ color: textColor,
794
+ },
795
+ paper_bgcolor: bgColor,
796
+ plot_bgcolor: bgColor,
797
+ showlegend: false,
798
+ margin: { l: 60, r: 20, t: 50, b: 50 },
799
+ ...(surfaceToken === "SURFACE"
800
+ ? {
801
+ xaxis: {
802
+ title: { text: spec.horizontalAxisTitle ?? "" },
803
+ automargin: true,
804
+ color: textColor,
805
+ },
806
+ yaxis: {
807
+ title: { text: spec.verticalAxisTitle ?? "" },
808
+ automargin: true,
809
+ color: textColor,
810
+ },
811
+ }
812
+ : {
813
+ scene: {
814
+ xaxis: { title: { text: spec.horizontalAxisTitle ?? "" } },
815
+ yaxis: { title: { text: "" } },
816
+ zaxis: { title: { text: spec.verticalAxisTitle ?? "" } },
817
+ },
818
+ }),
819
+ };
820
+ setPlotData([surfaceTrace]);
821
+ setLayout(surfaceLayout);
822
+ return;
823
+ }
665
824
  // ---------------------------- Radar ----------------------------
666
825
  // Plotly renders radar via `scatterpolar` traces: r = the values,
667
826
  // theta = the category names (closed by appending the first point).
@@ -698,7 +857,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
698
857
  const maxR = indicators.reduce((m, ind) => Math.max(m, ind.max), 0);
699
858
  setPlotData(radarTraces);
700
859
  setLayout({
701
- title: { text: title ?? undefined },
860
+ title: { text: plotlyTitleText(title, spec.titleTextFormat) },
702
861
  font: { color: textColor },
703
862
  paper_bgcolor: bgColor,
704
863
  plot_bgcolor: bgColor,
@@ -772,7 +931,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
772
931
  };
773
932
  setPlotData(hasVolume ? [candleTrace, buildVolumeTrace(xs)] : [candleTrace]);
774
933
  setLayout({
775
- title: { text: title ?? undefined },
934
+ title: { text: plotlyTitleText(title, spec.titleTextFormat) },
776
935
  font: { color: textColor },
777
936
  paper_bgcolor: bgColor,
778
937
  plot_bgcolor: bgColor,
@@ -798,12 +957,12 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
798
957
  return;
799
958
  }
800
959
  const lineSeries = toLineSeries(seriesValues, variant);
801
- const stockPalette = [
960
+ const stockPalette = getChartPalette(spec, [
802
961
  theme.themeColors.accent1,
803
962
  theme.themeColors.accent2,
804
963
  theme.themeColors.accent3,
805
964
  theme.themeColors.accent4,
806
- ];
965
+ ]);
807
966
  const xs = categories.length > 0
808
967
  ? categories
809
968
  : (lineSeries[0]?.values ?? []).map((_, i) => i + 1);
@@ -817,7 +976,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
817
976
  }));
818
977
  setPlotData(hasVolume ? [...stockTraces, buildVolumeTrace(xs)] : stockTraces);
819
978
  setLayout({
820
- title: { text: title ?? undefined },
979
+ title: { text: plotlyTitleText(title, spec.titleTextFormat) },
821
980
  font: { color: textColor },
822
981
  paper_bgcolor: bgColor,
823
982
  plot_bgcolor: bgColor,
@@ -843,6 +1002,34 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
843
1002
  }
844
1003
  const isStacked = spec.stackedType && spec.stackedType !== "UNSTACKED";
845
1004
  const isHorizontalBarChart = spec.chartType === "bar";
1005
+ // Line / area gallery variants: 100% stacked normalises each category
1006
+ // to 100 (plotly `groupnorm`); Excel's "Line with Markers"
1007
+ // (spec.showMarkers) or a series' own marker switches the line trace
1008
+ // to lines+markers, and a series marker of "none" hides them.
1009
+ const lineTraceVariant = (series, effectiveChartType) => {
1010
+ if (effectiveChartType !== "line" && effectiveChartType !== "area") {
1011
+ return {};
1012
+ }
1013
+ const extra = {};
1014
+ if (isStacked && spec.stackedType === "PERCENT_STACKED") {
1015
+ extra.groupnorm = "percent";
1016
+ }
1017
+ if (effectiveChartType === "line") {
1018
+ const marker = series.marker;
1019
+ const symbol = marker?.symbol;
1020
+ const wantsMarkers = symbol === "none"
1021
+ ? false
1022
+ : Boolean(spec.showMarkers) || !isNil(symbol);
1023
+ if (wantsMarkers) {
1024
+ extra.mode = "lines+markers";
1025
+ extra.marker = {
1026
+ symbol: mapMarkerSymbolToPlotly(symbol) ?? "circle",
1027
+ size: marker?.size ?? 6,
1028
+ };
1029
+ }
1030
+ }
1031
+ return extra;
1032
+ };
846
1033
  // Get domain data (x-axis)
847
1034
  const shouldAggregate = shouldAggregateSeries(spec.series);
848
1035
  const primaryDomainSource = spec?.domains?.[0]?.sources?.[0];
@@ -850,25 +1037,45 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
850
1037
  ? sanitizeDomainValues(getDomainValuesFromRange(primaryDomainSource, spec.hiddenDimensionStrategy) ?? [])
851
1038
  : [];
852
1039
  let domainData = getDomainValuesForAxis(rawPrimaryDomainValues, shouldAggregate);
853
- spec.domains?.forEach((domain, domainIndex) => {
854
- domain.sources.forEach((sheetRange, sourceIndex) => {
855
- if (domainIndex === 0 && sourceIndex === 0) {
856
- domainData = getDomainValuesForAxis(rawPrimaryDomainValues, shouldAggregate);
857
- }
858
- else {
859
- const rawValues = sanitizeDomainValues(getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? []);
860
- domainData = getDomainValuesForAxis(rawValues, shouldAggregate);
861
- }
862
- });
863
- });
1040
+ // A hierarchical category axis (PivotChart over nested row fields):
1041
+ // plotly's `multicategory` axis takes every level, outermost first.
1042
+ const categoryLevels = resolveCategoryLevels(spec.categoryLevels?.map((level) => flattenChartDataValues([level], getDomainValuesFromRange, spec.hiddenDimensionStrategy, false)));
1043
+ const multicategoryX = categoryLevels
1044
+ ? plotlyMulticategoryX(categoryLevels)
1045
+ : undefined;
1046
+ if (categoryLevels) {
1047
+ domainData = categoryLevels.leaf;
1048
+ }
1049
+ // Aggregation keys series values by the primary domain range alone.
1050
+ const aggregateDomainValues = domainData;
1051
+ // One category axis per domain (Excel): a domain drawn from several
1052
+ // ranges lists their categories in sequence on that single axis.
1053
+ // `domains[0]` is the primary category axis; `domains[1]` is the
1054
+ // secondary one, which Excel shows only for the secondary axis group —
1055
+ // the series with `useSecondaryAxis` plot against it.
1056
+ const domainAxisData = (categoryLevels ? [] : spec.domains ?? []).map((domain, domainIndex) => domain.sources.flatMap((sheetRange, sourceIndex) => {
1057
+ if (domainIndex === 0 && sourceIndex === 0) {
1058
+ return aggregateDomainValues;
1059
+ }
1060
+ const rawValues = sanitizeDomainValues(getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? []);
1061
+ return getDomainValuesForAxis(rawValues, shouldAggregate);
1062
+ }));
1063
+ if (domainAxisData[0])
1064
+ domainData = domainAxisData[0];
1065
+ const secondaryDomainData = domainAxisData[1];
1066
+ /** The category list a series plots against. */
1067
+ const categoriesFor = (series) => series.useSecondaryAxis && secondaryDomainData
1068
+ ? secondaryDomainData
1069
+ : domainData;
864
1070
  const assignCartesianData = (categories, values) => {
865
1071
  // Convert undefined to null for Plotly (Datum type accepts null but not undefined)
866
1072
  const plotlyValues = values.map((v) => v ?? null);
867
1073
  // When categories is empty but we have values, generate index-based x values
868
1074
  // so Plotly can render the chart
869
- const effectiveCategories = categories.length === 0 && plotlyValues.length > 0
870
- ? Array.from({ length: plotlyValues.length }, (_, i) => i + 1)
871
- : categories;
1075
+ const effectiveCategories = multicategoryX ??
1076
+ (categories.length === 0 && plotlyValues.length > 0
1077
+ ? Array.from({ length: plotlyValues.length }, (_, i) => i + 1)
1078
+ : categories);
872
1079
  if (isHorizontalBarChart) {
873
1080
  return {
874
1081
  x: plotlyValues,
@@ -879,14 +1086,14 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
879
1086
  return { x: effectiveCategories, y: plotlyValues, orientation: undefined };
880
1087
  };
881
1088
  // Theme color palette for fallback
882
- const themeColorPalette = [
1089
+ const themeColorPalette = getChartPalette(spec, [
883
1090
  theme.themeColors.accent1,
884
1091
  theme.themeColors.accent2,
885
1092
  theme.themeColors.accent3,
886
1093
  theme.themeColors.accent4,
887
1094
  theme.themeColors.accent5,
888
1095
  theme.themeColors.accent6,
889
- ];
1096
+ ]);
890
1097
  const applyTraceColor = (trace, seriesColor, options) => {
891
1098
  const target = trace;
892
1099
  const chartTypeForStyling = options?.effectiveChartType ?? spec.chartType;
@@ -917,7 +1124,8 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
917
1124
  // give every task its own color. Build a per-point color array
918
1125
  // by category index, falling back to the series color for points
919
1126
  // without an override.
920
- if (isBarOrColumn &&
1127
+ const isPie = chartTypeForStyling === "pie";
1128
+ if ((isBarOrColumn || isPie) &&
921
1129
  Array.isArray(options?.dataPointColors) &&
922
1130
  options?.dataPointColors.length) {
923
1131
  const overrides = new Map();
@@ -926,6 +1134,24 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
926
1134
  overrides.set(dp.idx, dp.color);
927
1135
  }
928
1136
  }
1137
+ if (isPie) {
1138
+ // Per-slice colours (`<c:dPt>`): an authored slice colour wins,
1139
+ // the other slices keep the palette cycle the trace was built
1140
+ // with (or the series' own colour when one was authored).
1141
+ const existing = Array.isArray(target.marker?.colors)
1142
+ ? target.marker.colors
1143
+ : themeColorPalette;
1144
+ const labelCount = Array.isArray(target.labels)
1145
+ ? target.labels.length
1146
+ : 0;
1147
+ target.marker = {
1148
+ ...(target.marker ?? {}),
1149
+ colors: Array.from({ length: labelCount }, (_, i) => overrides.get(i) ??
1150
+ seriesColor ??
1151
+ existing[i % Math.max(1, existing.length)]),
1152
+ };
1153
+ return trace;
1154
+ }
929
1155
  const fallback = effectiveColor ?? "#4285F4";
930
1156
  const yArray = Array.isArray(target.y)
931
1157
  ? target.y
@@ -988,6 +1214,18 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
988
1214
  // Track whether any series opts into the secondary value axis; the
989
1215
  // yaxis2 layout block is added below when true.
990
1216
  let hasSecondaryAxis = false;
1217
+ // Secondary category axis — added to the layout when a secondary-axis
1218
+ // series plots against a second chart domain.
1219
+ let hasSecondaryCategoryAxis = false;
1220
+ // Plotly axis references for the secondary axis group (Excel: a
1221
+ // secondary value axis opposite the primary one, plus a secondary
1222
+ // category axis when the group has its own categories). The value
1223
+ // axis is y on a vertical chart and x on a horizontal bar; the
1224
+ // category axis is the other one.
1225
+ const valueAxisKey = isHorizontalBarChart ? "xaxis" : "yaxis";
1226
+ const categoryAxisKey = isHorizontalBarChart ? "yaxis" : "xaxis";
1227
+ const secondaryValueAxisRef = isHorizontalBarChart ? "x2" : "y2";
1228
+ const secondaryCategoryAxisRef = isHorizontalBarChart ? "y2" : "x2";
991
1229
  // Pareto — a histogram whose bins are sorted descending with a
992
1230
  // cumulative-% line overlaid on a secondary 0-100% axis. Arrives
993
1231
  // as a plain histogram spec carrying `sourceChartType: "PARETO"`.
@@ -1005,11 +1243,16 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1005
1243
  */
1006
1244
  const decorateCartesianTrace = (trace, series, values, dataLabel) => {
1007
1245
  const target = trace;
1008
- // Secondary value axis. Plotly overlays a second y-axis named
1009
- // "y2" and the trace points at it via `yaxis: "y2"`.
1246
+ // Secondary value axis. Plotly overlays a second value axis ("y2",
1247
+ // or "x2" for a horizontal bar) and the trace points at it.
1010
1248
  if (series.useSecondaryAxis) {
1011
- target.yaxis = "y2";
1249
+ target[valueAxisKey] = secondaryValueAxisRef;
1012
1250
  hasSecondaryAxis = true;
1251
+ // Secondary category axis for a second chart domain.
1252
+ if (secondaryDomainData) {
1253
+ target[categoryAxisKey] = secondaryCategoryAxisRef;
1254
+ hasSecondaryCategoryAxis = true;
1255
+ }
1013
1256
  }
1014
1257
  // Error bars — native plotly `error_y` / `error_x`. valueType
1015
1258
  // "custom" reads per-point magnitudes from the spec's plus/minus
@@ -1052,7 +1295,8 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1052
1295
  `${dataLabel ? dataLabel + " " : ""}${tl.type ?? "linear"} trend`;
1053
1296
  // Map the fitted x (category index) back onto the category
1054
1297
  // labels where possible so the line aligns with the data.
1055
- const xs = result.points.map((p) => p.x >= 0 && p.x < domainData.length ? domainData[p.x] : p.x);
1298
+ const categories = categoriesFor(series);
1299
+ const xs = result.points.map((p) => p.x >= 0 && p.x < categories.length ? categories[p.x] : p.x);
1056
1300
  const ys = result.points.map((p) => p.y);
1057
1301
  const overlay = {
1058
1302
  type: "scatter",
@@ -1064,7 +1308,12 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1064
1308
  width: 2,
1065
1309
  },
1066
1310
  hoverinfo: "skip",
1067
- ...(series.useSecondaryAxis ? { yaxis: "y2" } : {}),
1311
+ ...(series.useSecondaryAxis
1312
+ ? { [valueAxisKey]: secondaryValueAxisRef }
1313
+ : {}),
1314
+ ...(series.useSecondaryAxis && secondaryDomainData
1315
+ ? { [categoryAxisKey]: secondaryCategoryAxisRef }
1316
+ : {}),
1068
1317
  };
1069
1318
  if (isHorizontalBarChart) {
1070
1319
  overlay.x = ys;
@@ -1078,6 +1327,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1078
1327
  traces.push(overlay);
1079
1328
  }
1080
1329
  };
1330
+ const domainSources = spec.domains?.[0]?.sources ?? [];
1081
1331
  // Create traces for each series
1082
1332
  let seriesIndex = 0;
1083
1333
  for (const series of spec.series ?? []) {
@@ -1085,170 +1335,92 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1085
1335
  const dataLabel = !isNil(series.dataLabel) && isAFormula(series.dataLabel)
1086
1336
  ? coerceCalculatedLabel(await onRequestCalculate(series.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
1087
1337
  : series.dataLabel ?? "";
1088
- for (const sheetRange of series.sources) {
1089
- const seriesData = getSeriesData(spec, sheetRange, {
1338
+ // A series with no ranges draws nothing.
1339
+ if (series.sources.length === 0)
1340
+ continue;
1341
+ // Support per-series chart type for combo charts
1342
+ const effectiveChartType = series.seriesChartType ?? spec.chartType;
1343
+ const lineLike = isLineLikeChartType(effectiveChartType);
1344
+ // Excel plots a series drawn from several ranges as ONE series over
1345
+ // their union — every source's points in sequence, in step with the
1346
+ // single category axis that lists every domain source's categories
1347
+ // (C-76) — not one plotly trace (and legend entry) per range.
1348
+ // Pie / doughnut slices over several ranges label from the domain
1349
+ // UNION — every domain range's labels in sequence, each series range
1350
+ // taking its own window — not the primary domain range alone (C-78).
1351
+ const pieDomainLabels = (spec.chartType === "pie" || spec.chartType === "doughnut") &&
1352
+ (domainSources.length > 1 || series.sources.length > 1)
1353
+ ? domainSources.flatMap((sheetRange) => getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? [])
1354
+ : undefined;
1355
+ let piePointOffset = 0;
1356
+ const seriesData = mergeSeriesData(series.sources.map((sheetRange) => {
1357
+ const part = getSeriesData(spec, sheetRange, {
1090
1358
  aggregateEnabled: shouldAggregate,
1091
1359
  aggregateType: series.aggregateType,
1092
1360
  rawDomainValues: rawPrimaryDomainValues,
1093
- orderedDomainValues: domainData,
1361
+ orderedDomainValues: aggregateDomainValues,
1362
+ lineLike,
1363
+ pieLabels: pieDomainLabels
1364
+ ? {
1365
+ domainLabels: pieDomainLabels,
1366
+ offset: series.sources.length > 1 ? piePointOffset : undefined,
1367
+ }
1368
+ : undefined,
1094
1369
  });
1095
- // Support per-series chart type for combo charts
1096
- const effectiveChartType = series.seriesChartType ?? spec.chartType;
1097
- const seriesPlotlyType = chartTypeToPlotlyType(effectiveChartType);
1098
- const seriesTraceStyle = getPlotlyTraceStyle(effectiveChartType);
1099
- if (effectiveChartType === "histogram") {
1100
- const rawValues = "values" in seriesData
1101
- ? seriesData.values
1102
- : seriesData;
1103
- const binCount = spec.binCount;
1104
- if (isPareto) {
1105
- // Plotly's native histogram trace bins in axis order and
1106
- // can't reorder bins, so Pareto pre-bins with the shared
1107
- // util, sorts descending, and renders a bar trace plus the
1108
- // cumulative-% line pointed at the y2 overlay axis.
1109
- const { labels, counts, cumulativePercents } = computePareto(binHistogram(rawValues, binCount));
1110
- const barTrace = {
1111
- type: "bar",
1112
- x: labels,
1113
- y: counts,
1114
- name: dataLabel,
1115
- };
1116
- applyTraceColor(barTrace, series.color ?? undefined, {
1117
- noFill: series.noFill,
1118
- dataPointColors: series.dataPointColors,
1119
- seriesIndex: currentSeriesIndex,
1120
- effectiveChartType,
1121
- });
1122
- traces.push(barTrace);
1123
- traces.push({
1124
- type: "scatter",
1125
- mode: "lines+markers",
1126
- x: labels,
1127
- y: cumulativePercents,
1128
- name: "Cumulative %",
1129
- yaxis: "y2",
1130
- });
1131
- hasSecondaryAxis = true;
1132
- }
1133
- else {
1134
- const trace = {
1135
- type: "histogram",
1136
- x: rawValues.map((v) => (v ?? null)),
1137
- name: dataLabel,
1138
- ...(typeof binCount === "number" && binCount > 0
1139
- ? { nbinsx: binCount }
1140
- : {}),
1141
- };
1142
- applyTraceColor(trace, series.color ?? undefined, {
1143
- noFill: series.noFill,
1144
- dataPointColors: series.dataPointColors,
1145
- seriesIndex: currentSeriesIndex,
1146
- effectiveChartType,
1147
- });
1148
- traces.push(trace);
1149
- }
1150
- }
1151
- else if (effectiveChartType === "boxplot") {
1152
- const rawValues = "values" in seriesData
1153
- ? seriesData.values
1154
- : seriesData;
1155
- const quartileMethod = spec.quartileMethod;
1156
- const showOutliers = spec.showOutliers;
1157
- const showMeanMarker = spec.showMeanMarker === true;
1158
- // Plotly's box trace computes quartiles/fences itself from
1159
- // raw values, so a spec-pinned quartile method (or Excel's
1160
- // "no outliers → whiskers span min/max" semantics) requires
1161
- // switching to plotly's precomputed-statistics signature
1162
- // (q1/median/q3/lowerfence/upperfence arrays). When neither
1163
- // option is set the raw-values trace is kept unchanged.
1164
- const usePrecomputed = quartileMethod != null || showOutliers === false;
1165
- let trace;
1166
- if (usePrecomputed) {
1167
- const summary = computeBoxplotSummary(rawValues, {
1168
- quartileMethod,
1169
- });
1170
- if (!summary)
1171
- continue;
1172
- const hideOutliers = showOutliers === false;
1173
- trace = {
1174
- type: "box",
1175
- name: dataLabel,
1176
- // Precomputed boxes need an explicit category so each
1177
- // series lands on its own slot (raw mode derives it from
1178
- // the trace name).
1179
- x: [dataLabel || `trace ${currentSeriesIndex}`],
1180
- q1: [summary.q1],
1181
- median: [summary.median],
1182
- q3: [summary.q3],
1183
- // Excel: hiding outlier points extends the whiskers to
1184
- // the full min/max; with outliers shown they stop at the
1185
- // most extreme points inside the 1.5×IQR fences.
1186
- lowerfence: [hideOutliers ? summary.min : summary.lowerWhisker],
1187
- upperfence: [hideOutliers ? summary.max : summary.upperWhisker],
1188
- ...(hideOutliers
1189
- ? { boxpoints: false }
1190
- : {
1191
- // Sample points ride along so plotly can still draw
1192
- // the outlier dots against the precomputed fences.
1193
- y: [rawValues.filter((v) => v != null)],
1194
- boxpoints: "outliers",
1195
- }),
1196
- ...(showMeanMarker
1197
- ? { mean: [summary.mean], boxmean: true }
1198
- : {}),
1199
- };
1200
- }
1201
- else {
1202
- trace = {
1203
- type: "box",
1204
- y: rawValues.map((v) => (v ?? null)),
1205
- name: dataLabel,
1206
- // Raw mode is used when outliers are visible (today's
1207
- // default and `showOutliers: true` alike).
1208
- boxpoints: "outliers",
1209
- ...(showMeanMarker ? { boxmean: true } : {}),
1210
- };
1211
- }
1212
- applyTraceColor(trace, series.color ?? undefined, {
1213
- noFill: series.noFill,
1214
- dataPointColors: series.dataPointColors,
1215
- seriesIndex: currentSeriesIndex,
1216
- effectiveChartType,
1217
- });
1218
- traces.push(trace);
1219
- }
1220
- else if (effectiveChartType === "waterfall") {
1221
- const rawValues = "values" in seriesData
1222
- ? seriesData.values
1223
- : seriesData;
1224
- // Plotly's native waterfall draws connector lines itself and
1225
- // renders "total" measures from the zero baseline with its
1226
- // own distinct fill.
1227
- const totalIndices = new Set(spec.totalIndices ?? []);
1228
- const trace = {
1229
- type: "waterfall",
1230
- x: domainData,
1231
- y: rawValues.map((v) => (v ?? null)),
1232
- measure: rawValues.map((_, i) => totalIndices.has(i) ? "total" : "relative"),
1370
+ if (!Array.isArray(part))
1371
+ piePointOffset += part.values.length;
1372
+ return part;
1373
+ }));
1374
+ const seriesPlotlyType = chartTypeToPlotlyType(effectiveChartType);
1375
+ const seriesTraceStyle = {
1376
+ ...getPlotlyTraceStyle(effectiveChartType),
1377
+ // "Connect data points with line" bridges the empty cells.
1378
+ ...(connectsAcrossEmptyCells(spec, lineLike)
1379
+ ? { connectgaps: true }
1380
+ : {}),
1381
+ };
1382
+ if (effectiveChartType === "histogram") {
1383
+ const rawValues = "values" in seriesData
1384
+ ? seriesData.values
1385
+ : seriesData;
1386
+ const binCount = spec.binCount;
1387
+ if (isPareto) {
1388
+ // Plotly's native histogram trace bins in axis order and
1389
+ // can't reorder bins, so Pareto pre-bins with the shared
1390
+ // util, sorts descending, and renders a bar trace plus the
1391
+ // cumulative-% line pointed at the y2 overlay axis.
1392
+ const { labels, counts, cumulativePercents } = computePareto(binHistogram(rawValues, binCount));
1393
+ const barTrace = {
1394
+ type: "bar",
1395
+ x: labels,
1396
+ y: counts,
1233
1397
  name: dataLabel,
1234
1398
  };
1235
- applyTraceColor(trace, series.color ?? undefined, {
1399
+ applyTraceColor(barTrace, series.color ?? undefined, {
1236
1400
  noFill: series.noFill,
1237
1401
  dataPointColors: series.dataPointColors,
1238
1402
  seriesIndex: currentSeriesIndex,
1239
1403
  effectiveChartType,
1240
1404
  });
1241
- traces.push(trace);
1405
+ traces.push(barTrace);
1406
+ traces.push({
1407
+ type: "scatter",
1408
+ mode: "lines+markers",
1409
+ x: labels,
1410
+ y: cumulativePercents,
1411
+ name: "Cumulative %",
1412
+ yaxis: "y2",
1413
+ });
1414
+ hasSecondaryAxis = true;
1242
1415
  }
1243
- else if (effectiveChartType === "funnel") {
1244
- const rawValues = "values" in seriesData
1245
- ? seriesData.values
1246
- : seriesData;
1416
+ else {
1247
1417
  const trace = {
1248
- type: "funnel",
1249
- y: domainData,
1418
+ type: "histogram",
1250
1419
  x: rawValues.map((v) => (v ?? null)),
1251
1420
  name: dataLabel,
1421
+ ...(typeof binCount === "number" && binCount > 0
1422
+ ? { nbinsx: binCount }
1423
+ : {}),
1252
1424
  };
1253
1425
  applyTraceColor(trace, series.color ?? undefined, {
1254
1426
  noFill: series.noFill,
@@ -1258,227 +1430,365 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1258
1430
  });
1259
1431
  traces.push(trace);
1260
1432
  }
1261
- else if (effectiveChartType === "pie" && isOfPie) {
1262
- // Pie-of-pie / bar-of-pie — split the minor slices into a
1263
- // secondary pie or stacked bar beside the main pie, replace
1264
- // them with an aggregated "Other" slice, and join the two
1265
- // plots with connector-line shapes. Only the first pie series
1266
- // participates (of-pie is a single-series chart in Excel).
1267
- if (ofPieHandled)
1268
- continue;
1269
- ofPieHandled = true;
1270
- const pieData = seriesData;
1271
- const { main, secondary, otherTotal, total } = splitOfPie(pieData.values, pieData.labels, {
1272
- splitType: spec.splitType,
1273
- splitPos: spec.splitPos,
1274
- });
1275
- const ofPieType = resolveOfPieType(spec.ofPieType);
1276
- const secondScale = clampSecondPieSize(spec.secondPieSize) / 100;
1277
- const halfAngle = otherSliceHalfAngle(otherTotal, total);
1278
- const paletteAt = (index) => themeColorPalette[index % themeColorPalette.length];
1279
- // Layout in paper coordinates (0-1 across the plot area
1280
- // inside the margins). Main pie on the left, secondary plot
1281
- // on the right.
1282
- const MAIN_DOMAIN_X = [0, 0.55];
1283
- const CY = 0.5;
1284
- const SECONDARY_CX = 0.78;
1285
- const BAR_LEFT = 0.68;
1286
- const BAR_RIGHT = 0.88;
1287
- traces.push({
1288
- type: "pie",
1289
- values: main.map((s) => s.value),
1290
- labels: main.map((s) => s.label),
1291
- name: dataLabel,
1292
- domain: { x: MAIN_DOMAIN_X, y: [0, 1] },
1293
- sort: false,
1294
- direction: "clockwise",
1295
- // Rotate so the "Other" slice's angular midpoint faces the
1296
- // secondary plot (Excel's layout). Other is the last slice
1297
- // of a clockwise sweep starting at `rotation`° from 12
1298
- // o'clock, so its midpoint lands at 3 o'clock when the
1299
- // sweep starts 90° + halfAngle past the top.
1300
- rotation: (90 + (halfAngle * 180) / Math.PI) % 360,
1301
- marker: { colors: main.map((_, i) => paletteAt(i)) },
1433
+ }
1434
+ else if (effectiveChartType === "boxplot") {
1435
+ const rawValues = "values" in seriesData
1436
+ ? seriesData.values
1437
+ : seriesData;
1438
+ const quartileMethod = spec.quartileMethod;
1439
+ const showOutliers = spec.showOutliers;
1440
+ const showMeanMarker = spec.showMeanMarker === true;
1441
+ // Plotly's box trace computes quartiles/fences itself from
1442
+ // raw values, so a spec-pinned quartile method (or Excel's
1443
+ // "no outliers → whiskers span min/max" semantics) requires
1444
+ // switching to plotly's precomputed-statistics signature
1445
+ // (q1/median/q3/lowerfence/upperfence arrays). When neither
1446
+ // option is set the raw-values trace is kept unchanged.
1447
+ const usePrecomputed = quartileMethod != null || showOutliers === false;
1448
+ let trace;
1449
+ if (usePrecomputed) {
1450
+ const summary = computeBoxplotSummary(rawValues, {
1451
+ quartileMethod,
1302
1452
  });
1303
- if (secondary.length > 0) {
1304
- // Paper coords are relative while the pie is circular in
1305
- // pixels — convert the main pie's pixel radius into
1306
- // separate x/y paper fractions to compensate for the
1307
- // aspect ratio. Falls back to fixed fractions when the
1308
- // container size isn't known yet.
1309
- const plotW = typeof width === "number" ? Math.max(1, width - 102) : 0;
1310
- const plotH = typeof height === "number" ? Math.max(1, height - 102) : 0;
1311
- const hasGeometry = plotW > 1 && plotH > 1;
1312
- const r = hasGeometry
1313
- ? Math.min((MAIN_DOMAIN_X[1] - MAIN_DOMAIN_X[0]) * plotW, plotH) / 2
1314
- : 0;
1315
- const r2 = r * secondScale;
1316
- const clampFrac = (v, min, max) => Math.min(max, Math.max(min, v));
1317
- const rx2 = clampFrac(hasGeometry ? r2 / plotW : 0.14 * secondScale, 0.03, 0.19);
1318
- const ry2 = clampFrac(hasGeometry ? r2 / plotH : 0.28 * secondScale, 0.05, 0.45);
1319
- const barHalf = ry2;
1320
- if (ofPieType === "pie") {
1321
- traces.push({
1322
- type: "pie",
1323
- values: secondary.map((s) => s.value),
1324
- labels: secondary.map((s) => s.label),
1325
- name: dataLabel,
1326
- domain: {
1327
- x: [SECONDARY_CX - rx2, SECONDARY_CX + rx2],
1328
- y: [CY - ry2, CY + ry2],
1329
- },
1330
- sort: false,
1331
- direction: "clockwise",
1332
- marker: {
1333
- colors: secondary.map((_, i) => paletteAt(main.length + i)),
1334
- },
1335
- });
1336
- }
1337
- else {
1338
- // Bar-of-pie — a single-category stacked bar on hidden
1339
- // secondary axes; one trace per point so each segment
1340
- // keeps its own color and legend entry.
1341
- secondary.forEach((s, i) => {
1342
- traces.push({
1343
- type: "bar",
1344
- x: [""],
1345
- y: [s.value],
1346
- name: s.label,
1347
- xaxis: "x2",
1348
- yaxis: "y2",
1349
- marker: { color: paletteAt(main.length + i) },
1350
- });
1351
- });
1352
- }
1353
- // Connector lines from the Other slice's edges to the top
1354
- // and bottom of the secondary plot. Skipped (gracefully)
1355
- // when the container size isn't known — pure paper-coord
1356
- // geometry can't compensate for the pixel aspect ratio.
1357
- const shapes = [];
1358
- if (hasGeometry) {
1359
- const connectorColor = isDarkMode
1360
- ? "rgba(255,255,255,0.45)"
1361
- : "#999999";
1362
- const mainCx = (MAIN_DOMAIN_X[0] + MAIN_DOMAIN_X[1]) / 2;
1363
- const px = mainCx + (r * Math.cos(halfAngle)) / plotW;
1364
- const pyTop = CY + (r * Math.sin(halfAngle)) / plotH;
1365
- const pyBottom = CY - (r * Math.sin(halfAngle)) / plotH;
1366
- const tx = ofPieType === "pie" ? SECONDARY_CX : BAR_LEFT;
1367
- const half = ofPieType === "pie" ? ry2 : barHalf;
1368
- const line = {
1369
- type: "line",
1370
- xref: "paper",
1371
- yref: "paper",
1372
- layer: "below",
1373
- line: { color: connectorColor, width: 1 },
1374
- };
1375
- shapes.push({ ...line, x0: px, y0: pyTop, x1: tx, y1: CY + half }, { ...line, x0: px, y0: pyBottom, x1: tx, y1: CY - half });
1376
- }
1377
- ofPieExtras = {
1378
- shapes,
1379
- ...(ofPieType === "bar"
1380
- ? {
1381
- barmode: "stack",
1382
- xaxis2: {
1383
- domain: [BAR_LEFT, BAR_RIGHT],
1384
- anchor: "y2",
1385
- visible: false,
1386
- fixedrange: true,
1387
- },
1388
- yaxis2: {
1389
- domain: [CY - barHalf, CY + barHalf],
1390
- anchor: "x2",
1391
- visible: false,
1392
- fixedrange: true,
1393
- },
1394
- }
1395
- : {}),
1396
- };
1397
- }
1398
- }
1399
- else if (effectiveChartType === "pie") {
1400
- const pieData = seriesData;
1401
- const trace = {
1402
- type: seriesPlotlyType,
1403
- values: pieData.values,
1404
- labels: pieData.labels,
1453
+ if (!summary)
1454
+ continue;
1455
+ const hideOutliers = showOutliers === false;
1456
+ trace = {
1457
+ type: "box",
1405
1458
  name: dataLabel,
1406
- marker: {
1407
- colors: [
1408
- theme.themeColors.accent1,
1409
- theme.themeColors.accent2,
1410
- theme.themeColors.accent3,
1411
- theme.themeColors.accent4,
1412
- theme.themeColors.accent5,
1413
- theme.themeColors.accent6,
1414
- ],
1415
- },
1459
+ // Precomputed boxes need an explicit category so each
1460
+ // series lands on its own slot (raw mode derives it from
1461
+ // the trace name).
1462
+ x: [dataLabel || `trace ${currentSeriesIndex}`],
1463
+ q1: [summary.q1],
1464
+ median: [summary.median],
1465
+ q3: [summary.q3],
1466
+ // Excel: hiding outlier points extends the whiskers to
1467
+ // the full min/max; with outliers shown they stop at the
1468
+ // most extreme points inside the 1.5×IQR fences.
1469
+ lowerfence: [hideOutliers ? summary.min : summary.lowerWhisker],
1470
+ upperfence: [hideOutliers ? summary.max : summary.upperWhisker],
1471
+ ...(hideOutliers
1472
+ ? { boxpoints: false }
1473
+ : {
1474
+ // Sample points ride along so plotly can still draw
1475
+ // the outlier dots against the precomputed fences.
1476
+ y: [rawValues.filter((v) => v != null)],
1477
+ boxpoints: "outliers",
1478
+ }),
1479
+ ...(showMeanMarker
1480
+ ? { mean: [summary.mean], boxmean: true }
1481
+ : {}),
1416
1482
  };
1417
- applyTraceColor(trace, series.color ?? undefined, {
1418
- noFill: series.noFill,
1419
- dataPointColors: series.dataPointColors,
1420
- seriesIndex: currentSeriesIndex,
1421
- effectiveChartType,
1422
- });
1423
- traces.push(trace);
1424
1483
  }
1425
- else if ("values" in seriesData && "labels" in seriesData) {
1426
- const { x, y, orientation } = assignCartesianData(domainData, seriesData.values);
1427
- const trace = {
1428
- type: seriesPlotlyType,
1429
- x,
1430
- y,
1431
- orientation,
1484
+ else {
1485
+ trace = {
1486
+ type: "box",
1487
+ y: rawValues.map((v) => (v ?? null)),
1432
1488
  name: dataLabel,
1433
- stackgroup: isStacked ? "one" : undefined,
1434
- ...seriesTraceStyle,
1489
+ // Raw mode is used when outliers are visible (today's
1490
+ // default and `showOutliers: true` alike).
1491
+ boxpoints: "outliers",
1492
+ ...(showMeanMarker ? { boxmean: true } : {}),
1435
1493
  };
1436
- applyTraceColor(trace, series.color ?? undefined, {
1437
- noFill: series.noFill,
1438
- dataPointColors: series.dataPointColors,
1439
- invertIfNegative: series.invertIfNegative,
1440
- negativeColor: series.negativeColor,
1441
- values: seriesData.values,
1442
- seriesIndex: currentSeriesIndex,
1443
- effectiveChartType,
1444
- gradientColor: gradientToPlotly(series.gradient),
1445
- });
1446
- decorateCartesianTrace(trace, series, seriesData.values, dataLabel);
1447
- traces.push(trace);
1448
1494
  }
1449
- else {
1450
- const numericValues = seriesData;
1451
- const { x, y, orientation } = assignCartesianData(domainData, numericValues);
1452
- const trace = {
1453
- type: seriesPlotlyType,
1454
- x,
1455
- y,
1456
- orientation,
1457
- name: dataLabel,
1458
- stackgroup: isStacked ? "one" : undefined,
1459
- ...seriesTraceStyle,
1495
+ applyTraceColor(trace, series.color ?? undefined, {
1496
+ noFill: series.noFill,
1497
+ dataPointColors: series.dataPointColors,
1498
+ seriesIndex: currentSeriesIndex,
1499
+ effectiveChartType,
1500
+ });
1501
+ traces.push(trace);
1502
+ }
1503
+ else if (effectiveChartType === "waterfall") {
1504
+ const rawValues = "values" in seriesData
1505
+ ? seriesData.values
1506
+ : seriesData;
1507
+ // Plotly's native waterfall draws connector lines itself and
1508
+ // renders "total" measures from the zero baseline with its
1509
+ // own distinct fill.
1510
+ const totalIndices = new Set(spec.totalIndices ?? []);
1511
+ const trace = {
1512
+ type: "waterfall",
1513
+ x: categoriesFor(series),
1514
+ y: rawValues.map((v) => (v ?? null)),
1515
+ measure: rawValues.map((_, i) => totalIndices.has(i) ? "total" : "relative"),
1516
+ name: dataLabel,
1517
+ };
1518
+ applyTraceColor(trace, series.color ?? undefined, {
1519
+ noFill: series.noFill,
1520
+ dataPointColors: series.dataPointColors,
1521
+ seriesIndex: currentSeriesIndex,
1522
+ effectiveChartType,
1523
+ });
1524
+ traces.push(trace);
1525
+ }
1526
+ else if (effectiveChartType === "funnel") {
1527
+ const rawValues = "values" in seriesData
1528
+ ? seriesData.values
1529
+ : seriesData;
1530
+ const trace = {
1531
+ type: "funnel",
1532
+ y: categoriesFor(series),
1533
+ x: rawValues.map((v) => (v ?? null)),
1534
+ name: dataLabel,
1535
+ };
1536
+ applyTraceColor(trace, series.color ?? undefined, {
1537
+ noFill: series.noFill,
1538
+ dataPointColors: series.dataPointColors,
1539
+ seriesIndex: currentSeriesIndex,
1540
+ effectiveChartType,
1541
+ });
1542
+ traces.push(trace);
1543
+ }
1544
+ else if (effectiveChartType === "pie" && isOfPie) {
1545
+ // Pie-of-pie / bar-of-pie — split the minor slices into a
1546
+ // secondary pie or stacked bar beside the main pie, replace
1547
+ // them with an aggregated "Other" slice, and join the two
1548
+ // plots with connector-line shapes. Only the first pie series
1549
+ // participates (of-pie is a single-series chart in Excel).
1550
+ if (ofPieHandled)
1551
+ continue;
1552
+ ofPieHandled = true;
1553
+ const pieData = seriesData;
1554
+ const { main, secondary, otherTotal, total } = splitOfPie(pieData.values, pieData.labels, {
1555
+ splitType: spec.splitType,
1556
+ splitPos: spec.splitPos,
1557
+ });
1558
+ const ofPieType = resolveOfPieType(spec.ofPieType);
1559
+ const secondScale = clampSecondPieSize(spec.secondPieSize) / 100;
1560
+ const halfAngle = otherSliceHalfAngle(otherTotal, total);
1561
+ const paletteAt = (index) => themeColorPalette[index % themeColorPalette.length];
1562
+ // Layout in paper coordinates (0-1 across the plot area
1563
+ // inside the margins). Main pie on the left, secondary plot
1564
+ // on the right.
1565
+ const MAIN_DOMAIN_X = [0, 0.55];
1566
+ const CY = 0.5;
1567
+ const SECONDARY_CX = 0.78;
1568
+ const BAR_LEFT = 0.68;
1569
+ const BAR_RIGHT = 0.88;
1570
+ traces.push({
1571
+ type: "pie",
1572
+ values: main.map((s) => s.value),
1573
+ labels: main.map((s) => s.label),
1574
+ name: dataLabel,
1575
+ domain: { x: MAIN_DOMAIN_X, y: [0, 1] },
1576
+ sort: false,
1577
+ direction: "clockwise",
1578
+ // Rotate so the "Other" slice's angular midpoint faces the
1579
+ // secondary plot (Excel's layout). Other is the last slice
1580
+ // of a clockwise sweep starting at `rotation`° from 12
1581
+ // o'clock, so its midpoint lands at 3 o'clock when the
1582
+ // sweep starts 90° + halfAngle past the top.
1583
+ rotation: (90 + (halfAngle * 180) / Math.PI) % 360,
1584
+ marker: { colors: main.map((_, i) => paletteAt(i)) },
1585
+ });
1586
+ if (secondary.length > 0) {
1587
+ // Paper coords are relative while the pie is circular in
1588
+ // pixels — convert the main pie's pixel radius into
1589
+ // separate x/y paper fractions to compensate for the
1590
+ // aspect ratio. Falls back to fixed fractions when the
1591
+ // container size isn't known yet.
1592
+ const plotW = typeof width === "number" ? Math.max(1, width - 102) : 0;
1593
+ const plotH = typeof height === "number" ? Math.max(1, height - 102) : 0;
1594
+ const hasGeometry = plotW > 1 && plotH > 1;
1595
+ const r = hasGeometry
1596
+ ? Math.min((MAIN_DOMAIN_X[1] - MAIN_DOMAIN_X[0]) * plotW, plotH) / 2
1597
+ : 0;
1598
+ const r2 = r * secondScale;
1599
+ const clampFrac = (v, min, max) => Math.min(max, Math.max(min, v));
1600
+ const rx2 = clampFrac(hasGeometry ? r2 / plotW : 0.14 * secondScale, 0.03, 0.19);
1601
+ const ry2 = clampFrac(hasGeometry ? r2 / plotH : 0.28 * secondScale, 0.05, 0.45);
1602
+ const barHalf = ry2;
1603
+ if (ofPieType === "pie") {
1604
+ traces.push({
1605
+ type: "pie",
1606
+ values: secondary.map((s) => s.value),
1607
+ labels: secondary.map((s) => s.label),
1608
+ name: dataLabel,
1609
+ domain: {
1610
+ x: [SECONDARY_CX - rx2, SECONDARY_CX + rx2],
1611
+ y: [CY - ry2, CY + ry2],
1612
+ },
1613
+ sort: false,
1614
+ direction: "clockwise",
1615
+ marker: {
1616
+ colors: secondary.map((_, i) => paletteAt(main.length + i)),
1617
+ },
1618
+ });
1619
+ }
1620
+ else {
1621
+ // Bar-of-pie — a single-category stacked bar on hidden
1622
+ // secondary axes; one trace per point so each segment
1623
+ // keeps its own color and legend entry.
1624
+ secondary.forEach((s, i) => {
1625
+ traces.push({
1626
+ type: "bar",
1627
+ x: [""],
1628
+ y: [s.value],
1629
+ name: s.label,
1630
+ xaxis: "x2",
1631
+ yaxis: "y2",
1632
+ marker: { color: paletteAt(main.length + i) },
1633
+ });
1634
+ });
1635
+ }
1636
+ // Connector lines from the Other slice's edges to the top
1637
+ // and bottom of the secondary plot. Skipped (gracefully)
1638
+ // when the container size isn't known — pure paper-coord
1639
+ // geometry can't compensate for the pixel aspect ratio.
1640
+ const shapes = [];
1641
+ if (hasGeometry) {
1642
+ const connectorColor = isDarkMode
1643
+ ? "rgba(255,255,255,0.45)"
1644
+ : "#999999";
1645
+ const mainCx = (MAIN_DOMAIN_X[0] + MAIN_DOMAIN_X[1]) / 2;
1646
+ const px = mainCx + (r * Math.cos(halfAngle)) / plotW;
1647
+ const pyTop = CY + (r * Math.sin(halfAngle)) / plotH;
1648
+ const pyBottom = CY - (r * Math.sin(halfAngle)) / plotH;
1649
+ const tx = ofPieType === "pie" ? SECONDARY_CX : BAR_LEFT;
1650
+ const half = ofPieType === "pie" ? ry2 : barHalf;
1651
+ const line = {
1652
+ type: "line",
1653
+ xref: "paper",
1654
+ yref: "paper",
1655
+ layer: "below",
1656
+ line: { color: connectorColor, width: 1 },
1657
+ };
1658
+ shapes.push({ ...line, x0: px, y0: pyTop, x1: tx, y1: CY + half }, { ...line, x0: px, y0: pyBottom, x1: tx, y1: CY - half });
1659
+ }
1660
+ ofPieExtras = {
1661
+ shapes,
1662
+ ...(ofPieType === "bar"
1663
+ ? {
1664
+ barmode: "stack",
1665
+ xaxis2: {
1666
+ domain: [BAR_LEFT, BAR_RIGHT],
1667
+ anchor: "y2",
1668
+ visible: false,
1669
+ fixedrange: true,
1670
+ },
1671
+ yaxis2: {
1672
+ domain: [CY - barHalf, CY + barHalf],
1673
+ anchor: "x2",
1674
+ visible: false,
1675
+ fixedrange: true,
1676
+ },
1677
+ }
1678
+ : {}),
1460
1679
  };
1461
- applyTraceColor(trace, series.color ?? undefined, {
1462
- noFill: series.noFill,
1463
- dataPointColors: series.dataPointColors,
1464
- invertIfNegative: series.invertIfNegative,
1465
- negativeColor: series.negativeColor,
1466
- values: numericValues,
1467
- seriesIndex: currentSeriesIndex,
1468
- effectiveChartType,
1469
- gradientColor: gradientToPlotly(series.gradient),
1470
- });
1471
- decorateCartesianTrace(trace, series, numericValues, dataLabel);
1472
- traces.push(trace);
1473
1680
  }
1474
1681
  }
1682
+ else if (effectiveChartType === "pie" ||
1683
+ effectiveChartType === "doughnut") {
1684
+ const pieData = seriesData;
1685
+ const trace = {
1686
+ type: seriesPlotlyType,
1687
+ values: pieData.values,
1688
+ labels: pieData.labels,
1689
+ name: dataLabel,
1690
+ marker: {
1691
+ colors: themeColorPalette,
1692
+ },
1693
+ };
1694
+ // Ties the trace back to spec.series for click selection and
1695
+ // the series' own number format
1696
+ trace.meta = {
1697
+ rncSeriesIndex: currentSeriesIndex,
1698
+ };
1699
+ applyTraceColor(trace, series.color ?? undefined, {
1700
+ noFill: series.noFill,
1701
+ dataPointColors: series.dataPointColors,
1702
+ seriesIndex: currentSeriesIndex,
1703
+ effectiveChartType,
1704
+ });
1705
+ traces.push(trace);
1706
+ }
1707
+ else if ("values" in seriesData && "labels" in seriesData) {
1708
+ const { x, y, orientation } = assignCartesianData(categoriesFor(series), seriesData.values);
1709
+ const trace = {
1710
+ type: seriesPlotlyType,
1711
+ x,
1712
+ y,
1713
+ orientation,
1714
+ name: dataLabel,
1715
+ stackgroup: isStacked ? "one" : undefined,
1716
+ ...seriesTraceStyle,
1717
+ ...lineTraceVariant(series, effectiveChartType),
1718
+ };
1719
+ // Ties the trace back to spec.series for click selection
1720
+ trace.meta = {
1721
+ rncSeriesIndex: currentSeriesIndex,
1722
+ };
1723
+ applyTraceColor(trace, series.color ?? undefined, {
1724
+ noFill: series.noFill,
1725
+ dataPointColors: series.dataPointColors,
1726
+ invertIfNegative: series.invertIfNegative,
1727
+ negativeColor: series.negativeColor,
1728
+ values: seriesData.values,
1729
+ seriesIndex: currentSeriesIndex,
1730
+ effectiveChartType,
1731
+ gradientColor: gradientToPlotly(series.gradient),
1732
+ });
1733
+ decorateCartesianTrace(trace, series, seriesData.values, dataLabel);
1734
+ traces.push(trace);
1735
+ }
1736
+ else {
1737
+ const numericValues = seriesData;
1738
+ const { x, y, orientation } = assignCartesianData(categoriesFor(series), numericValues);
1739
+ const trace = {
1740
+ type: seriesPlotlyType,
1741
+ x,
1742
+ y,
1743
+ orientation,
1744
+ name: dataLabel,
1745
+ stackgroup: isStacked ? "one" : undefined,
1746
+ ...seriesTraceStyle,
1747
+ ...lineTraceVariant(series, effectiveChartType),
1748
+ };
1749
+ // Ties the trace back to spec.series for click selection
1750
+ trace.meta = {
1751
+ rncSeriesIndex: currentSeriesIndex,
1752
+ };
1753
+ applyTraceColor(trace, series.color ?? undefined, {
1754
+ noFill: series.noFill,
1755
+ dataPointColors: series.dataPointColors,
1756
+ invertIfNegative: series.invertIfNegative,
1757
+ negativeColor: series.negativeColor,
1758
+ values: numericValues,
1759
+ seriesIndex: currentSeriesIndex,
1760
+ effectiveChartType,
1761
+ gradientColor: gradientToPlotly(series.gradient),
1762
+ });
1763
+ decorateCartesianTrace(trace, series, numericValues, dataLabel);
1764
+ traces.push(trace);
1765
+ }
1475
1766
  }
1476
1767
  // Authored title formatting (parsed from <c:title><c:txPr> or
1477
1768
  // <c:title><c:tx><c:rich>) overrides the theme-derived default.
1478
1769
  const authoredTitleFmt = spec.titleTextFormat;
1770
+ // "Linked to source" number formats per value axis. Plotly's
1771
+ // `barnorm: "percent"` axis plots percentages, so it never links to
1772
+ // the source cells.
1773
+ const isPercentStackedBars = spec.stackedType === "PERCENT_STACKED" &&
1774
+ (spec.chartType === "bar" || spec.chartType === "column");
1775
+ const primaryAxisNumberFormat = isPercentStackedBars
1776
+ ? undefined
1777
+ : resolveValueAxisNumberFormat({
1778
+ axisOptions: spec.valueAxisOptions,
1779
+ series: (spec.series ?? []).filter((s) => !s.useSecondaryAxis),
1780
+ getFormattingFromRange,
1781
+ hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
1782
+ });
1783
+ const secondaryAxisNumberFormat = resolveValueAxisNumberFormat({
1784
+ axisOptions: spec.secondaryValueAxisOptions,
1785
+ series: (spec.series ?? []).filter((s) => s.useSecondaryAxis),
1786
+ getFormattingFromRange,
1787
+ hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
1788
+ });
1479
1789
  const newLayout = {
1480
1790
  title: {
1481
- text: title ?? undefined,
1791
+ text: plotlyTitleText(title, spec.titleTextFormat),
1482
1792
  font: applyTextFormatToFont({
1483
1793
  family: "Untitled Sans, apple-system, sans-serif",
1484
1794
  size: 16,
@@ -1503,12 +1813,16 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1503
1813
  showgrid: true,
1504
1814
  gridcolor: gridColor,
1505
1815
  visible: showXAxis(spec?.chartType),
1506
- type: isHorizontalBarChart ? "linear" : undefined,
1816
+ type: isHorizontalBarChart
1817
+ ? "linear"
1818
+ : multicategoryX
1819
+ ? "multicategory"
1820
+ : undefined,
1507
1821
  },
1508
1822
  // For horizontal bar, OOXML's value axis is the X axis.
1509
1823
  isHorizontalBarChart
1510
1824
  ? spec.valueAxisOptions
1511
- : spec.categoryAxisOptions),
1825
+ : spec.categoryAxisOptions, isHorizontalBarChart ? primaryAxisNumberFormat : undefined),
1512
1826
  yaxis: applyAxisOverridesPlotly({
1513
1827
  title: {
1514
1828
  text: spec.verticalAxisTitle ?? "",
@@ -1517,70 +1831,48 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1517
1831
  showgrid: !isHorizontalBarChart,
1518
1832
  gridcolor: gridColor,
1519
1833
  automargin: true,
1520
- type: isHorizontalBarChart ? "category" : undefined,
1834
+ type: isHorizontalBarChart
1835
+ ? multicategoryX
1836
+ ? "multicategory"
1837
+ : "category"
1838
+ : undefined,
1521
1839
  }, isHorizontalBarChart
1522
1840
  ? spec.categoryAxisOptions
1523
- : spec.valueAxisOptions),
1524
- // Secondary value axis — a second y-axis overlaying the first on
1525
- // the right. Series with `useSecondaryAxis` were pointed at "y2".
1841
+ : spec.valueAxisOptions, isHorizontalBarChart ? undefined : primaryAxisNumberFormat),
1842
+ // Secondary value axis — overlaying the primary value axis on the
1843
+ // opposite side (right of a vertical chart; top of a horizontal
1844
+ // bar). Series with `useSecondaryAxis` were pointed at it.
1526
1845
  ...(hasSecondaryAxis
1527
1846
  ? {
1528
- yaxis2: applyAxisOverridesPlotly({
1847
+ [isHorizontalBarChart ? "xaxis2" : "yaxis2"]: applyAxisOverridesPlotly({
1529
1848
  title: { font: { color: textColor } },
1530
- overlaying: "y",
1531
- side: "right",
1849
+ overlaying: isHorizontalBarChart ? "x" : "y",
1850
+ side: isHorizontalBarChart ? "top" : "right",
1532
1851
  showgrid: false,
1533
1852
  automargin: true,
1534
1853
  // Pareto — the cumulative-% axis is clamped 0-100%.
1535
1854
  ...(isPareto
1536
1855
  ? { range: [0, 100], ticksuffix: "%" }
1537
1856
  : {}),
1538
- }, spec.secondaryValueAxisOptions),
1539
- }
1540
- : {}),
1541
- ...(() => {
1542
- const { legend: legendCfg, showlegend } = applyLegendOverridesPlotly({ font: { color: textColor } }, spec.legend);
1543
- return {
1544
- showlegend: showlegend === undefined ? true : showlegend,
1545
- legend: legendCfg,
1546
- };
1547
- })(),
1548
- margin: {
1549
- l: 50,
1550
- r: 50,
1551
- t: 50,
1552
- b: 50,
1553
- },
1554
- autosize: true,
1555
- // Bar/column stacking. Plotly's `barmode: "stack"` stacks bars
1556
- // at the same X (or Y for horizontal). `barnorm: "percent"`
1557
- // normalises each category's stack to 100% — Excel's
1558
- // `percentStacked` grouping. Default is grouped (clustered).
1559
- ...((spec.chartType === "bar" || spec.chartType === "column") && spec.stackedType
1560
- ? {
1561
- barmode: spec.stackedType === "STACKED" ||
1562
- spec.stackedType === "PERCENT_STACKED"
1563
- ? "stack"
1564
- : "group",
1565
- ...(spec.stackedType === "PERCENT_STACKED"
1566
- ? { barnorm: "percent" }
1567
- : {}),
1857
+ }, spec.secondaryValueAxisOptions, secondaryAxisNumberFormat),
1568
1858
  }
1569
1859
  : {}),
1570
- // gapWidth — Excel stores 0..500 as a percentage of bar width.
1571
- // Plotly's `bargap` is a fraction of the slot (0..1). Approx:
1572
- // gapWidth=150 (default) → ~0.4 bargap.
1573
- ...(typeof spec.gapWidth === "number"
1574
- ? { bargap: Math.max(0, Math.min(0.95, spec.gapWidth / 400)) }
1575
- : {}),
1576
- // Of-pie composite — connector-line shapes plus (bar-of-pie) the
1577
- // hidden secondary axes and stacked bar mode.
1578
- ...(ofPieExtras
1860
+ // Secondary category axis — the second chart domain, overlaying the
1861
+ // primary category axis on the opposite side (Excel draws the
1862
+ // secondary group's category axis opposite the primary one). Series
1863
+ // with `useSecondaryAxis` were pointed at it.
1864
+ ...(hasSecondaryCategoryAxis && secondaryDomainData
1579
1865
  ? {
1580
- shapes: ofPieExtras.shapes,
1581
- ...(ofPieExtras.barmode ? { barmode: ofPieExtras.barmode } : {}),
1582
- ...(ofPieExtras.xaxis2 ? { xaxis2: ofPieExtras.xaxis2 } : {}),
1583
- ...(ofPieExtras.yaxis2 ? { yaxis2: ofPieExtras.yaxis2 } : {}),
1866
+ [isHorizontalBarChart ? "yaxis2" : "xaxis2"]: {
1867
+ overlaying: isHorizontalBarChart ? "y" : "x",
1868
+ side: isHorizontalBarChart ? "right" : "top",
1869
+ type: "category",
1870
+ categoryorder: "array",
1871
+ categoryarray: secondaryDomainData,
1872
+ showgrid: false,
1873
+ automargin: true,
1874
+ visible: showXAxis(spec?.chartType),
1875
+ },
1584
1876
  }
1585
1877
  : {}),
1586
1878
  };
@@ -1588,13 +1880,72 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1588
1880
  // when chartType === "doughnut".
1589
1881
  const dlOpts = spec.dataLabels;
1590
1882
  const doughnutHole = spec.doughnutHoleSize;
1883
+ // Data labels are linked to each series' OWN source cells (Excel's
1884
+ // `<c:dLbls><c:numFmt sourceLinked="1">`); the axis format only steps
1885
+ // in for a series whose cells are General.
1886
+ const seriesNumberFormats = resolveSeriesNumberFormats(spec.series, getFormattingFromRange, spec.hiddenDimensionStrategy);
1887
+ const traceNumberFormat = (trace) => {
1888
+ const specIndex = trace?.meta?.rncSeriesIndex;
1889
+ return ((typeof specIndex === "number"
1890
+ ? seriesNumberFormats[specIndex]
1891
+ : undefined) ??
1892
+ (trace?.[valueAxisKey] === secondaryValueAxisRef
1893
+ ? secondaryAxisNumberFormat
1894
+ : primaryAxisNumberFormat));
1895
+ };
1896
+ // Exploded pie / doughnut — plotly's `pull` is a fraction of the radius.
1897
+ const piePull = isOfPie ? undefined : explosionToPlotlyPull(resolveExplosion(spec));
1591
1898
  const formattedTraces = traces.map((trace) => {
1592
- let next = applyDataLabelsToTracePlotly(trace, dlOpts, spec.chartType);
1593
- // Doughnut is a pie trace with `hole` set.
1899
+ const pattern = traceNumberFormat(trace);
1900
+ let next = applyDataLabelsToTracePlotly(trace, dlOpts, spec.chartType, pattern);
1901
+ if (next.type === "pie") {
1902
+ // Hover values format like the slice's source cell.
1903
+ if (pattern && !next.text) {
1904
+ const values = Array.isArray(next.values) ? next.values : [];
1905
+ next = {
1906
+ ...next,
1907
+ text: values.map((v) => formatAxisValue(pattern, v)),
1908
+ hoverinfo: "label+text+percent+name",
1909
+ };
1910
+ }
1911
+ else if (pattern) {
1912
+ next = { ...next, hoverinfo: "label+text+percent+name" };
1913
+ }
1914
+ if (piePull !== undefined) {
1915
+ next = { ...next, pull: piePull };
1916
+ }
1917
+ }
1918
+ else if (!next.hovertemplate &&
1919
+ (next.type === "bar" || next.type === "scatter") &&
1920
+ typeof next.meta?.rncSeriesIndex === "number") {
1921
+ // Hover values read like the series' own cells (Excel's data tip
1922
+ // shows the cell text) — the series format alone, not the axis
1923
+ // fallback the labels use, since that resolves to the first series
1924
+ // and would stamp its format on every other series. Plotly's `%{y}`
1925
+ // is a raw number and its d3 formats cannot express SpreadsheetML,
1926
+ // so the formatted strings ride on `customdata`; `text` stays free
1927
+ // for the data labels.
1928
+ const seriesPattern = seriesNumberFormats[next.meta.rncSeriesIndex];
1929
+ const horizontal = next.orientation === "h";
1930
+ const values = horizontal ? next.x : next.y;
1931
+ if (seriesPattern && Array.isArray(values)) {
1932
+ next = {
1933
+ ...next,
1934
+ customdata: values.map((v) => formatAxisValue(seriesPattern, v)),
1935
+ hovertemplate: `${horizontal ? "%{y}" : "%{x}"}<br>%{customdata}<extra>%{fullData.name}</extra>`,
1936
+ };
1937
+ }
1938
+ }
1939
+ // Doughnut is a pie trace with `hole` set. Excel persists
1940
+ // `holeSize` as a 0-90 percentage, normalized to a 0-1 fraction
1941
+ // (doughnutHoleSize); clamp like the echarts renderer and default
1942
+ // to 0.5 when unset.
1594
1943
  if (spec.chartType === "doughnut" && next.type === "pie") {
1595
1944
  next = {
1596
1945
  ...next,
1597
- hole: typeof doughnutHole === "number" ? doughnutHole : 0.5,
1946
+ hole: typeof doughnutHole === "number"
1947
+ ? Math.max(0, Math.min(0.9, doughnutHole))
1948
+ : 0.5,
1598
1949
  };
1599
1950
  }
1600
1951
  return next;
@@ -1617,7 +1968,12 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1617
1968
  // Update chart on spec changes
1618
1969
  useIsomorphicLayoutEffect(() => {
1619
1970
  updateChart();
1620
- }, [chart.spec, getDomainValuesFromRange, getSeriesValuesFromRange]);
1971
+ }, [
1972
+ chart.spec,
1973
+ getDomainValuesFromRange,
1974
+ getSeriesValuesFromRange,
1975
+ getFormattingFromRange,
1976
+ ]);
1621
1977
  // Update theme
1622
1978
  useIsomorphicLayoutEffect(() => {
1623
1979
  updateChart();
@@ -1644,21 +2000,58 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1644
2000
  });
1645
2001
  const config = {
1646
2002
  responsive: true,
1647
- // Static so a body drag falls through to the EmbedDraggable move handler
1648
- // (Excel/Sheets behavior) instead of triggering Plotly's zoom-box.
1649
- // staticPlot also drops the modebar and the `.plot-container .draglayer`
1650
- // element the move's cancel selector keys off of.
2003
+ // Interactive so plotly reports clicks (series / point selection) and
2004
+ // hover; `dragmode: false` on the layout keeps plotly from starting a
2005
+ // zoom-box or pan on mousedown, so a body drag falls through to the
2006
+ // EmbedDraggable move handler (Excel/Sheets behavior).
1651
2007
  displayModeBar: false,
1652
2008
  displaylogo: false,
1653
- staticPlot: true,
2009
+ staticPlot: false,
2010
+ scrollZoom: false,
2011
+ doubleClick: false,
1654
2012
  };
2013
+ const plotLayout = useMemo(() => ({ ...layout, dragmode: false }), [layout]);
1655
2014
  // Excel's "Rounded Corners" chart frame (`<c:roundedCorners val="1"/>`
1656
2015
  // at the chartSpace root). Default per OOXML §21.2.2.183 is square,
1657
2016
  // so only opt in when the workbook explicitly set the flag.
1658
2017
  const roundedCornersStyle = spec.roundedCorners
1659
2018
  ? { borderRadius: 8, overflow: "hidden" }
1660
2019
  : undefined;
2020
+ // Excel's "Switch Row/Column" — offered to the grid's right-click menu
2021
+ // when the host can persist the chart and the sources transpose.
2022
+ const switchRowColumnAction = onUpdateChart && !readonly && canSwitchRowColumn(spec)
2023
+ ? () => {
2024
+ const next = switchRowColumn(spec, {
2025
+ getFormattedValue: cellLabelGetterFromRangeReader(getDomainValuesFromRange),
2026
+ });
2027
+ if (next)
2028
+ onUpdateChart({ ...chart, spec: next });
2029
+ }
2030
+ : undefined;
2031
+ // Copy as Picture / print preview. `plotly.js/dist/plotly` is the module
2032
+ // `react-plotly.js` itself is built on, loaded on demand so the render
2033
+ // path stays as it was.
2034
+ const exportChartPng = async () => {
2035
+ const graphDiv = graphDivRef.current;
2036
+ if (!graphDiv)
2037
+ return undefined;
2038
+ const { default: Plotly } = await import("plotly.js/dist/plotly");
2039
+ const url = await Plotly.toImage(graphDiv, {
2040
+ format: "png",
2041
+ width: graphDiv.clientWidth || width || 600,
2042
+ height: graphDiv.clientHeight || height || 400,
2043
+ scale: 2,
2044
+ });
2045
+ return dataUrlToBlob(url);
2046
+ };
2047
+ // The grid's right-click menu offers these without knowing the renderer.
2048
+ useRegisterChartActions(chart.chartId, {
2049
+ requestEdit: onRequestEdit ? () => onRequestEdit(chart) : undefined,
2050
+ exportPng: exportChartPng,
2051
+ switchRowColumn: switchRowColumnAction,
2052
+ });
1661
2053
  return (React.createElement("div", { className: "flex flex-1", style: roundedCornersStyle },
2054
+ fieldButtons,
1662
2055
  isSelected ? (React.createElement(DropdownMenu, null,
1663
2056
  React.createElement(DropdownMenuTrigger, { asChild: true },
1664
2057
  React.createElement(IconButton, { className: "absolute right-1 top-1 z-10" },
@@ -1677,6 +2070,8 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1677
2070
  shouldFocusSheetRef.current = true;
1678
2071
  onDeleteChart?.(chart.chartId);
1679
2072
  } }, "Delete chart")))))) : null,
1680
- React.createElement("div", { className: "flex-1 rnc-plotly-chart-container" },
1681
- React.createElement(Plot, { ref: plotRef, data: plotData, layout: layout, config: config, style: { width: "100%", height: "100%" } }))));
2073
+ React.createElement("div", { className: "flex-1 rnc-plotly-chart-container", onPointerDownCapture: handlePointerDownCapture },
2074
+ React.createElement(Plot, { ref: plotRef, data: plotData, layout: plotLayout, config: config, onClick: handlePlotClick, onInitialized: (_figure, graphDiv) => {
2075
+ graphDivRef.current = graphDiv;
2076
+ }, style: { width: "100%", height: "100%" } }))));
1682
2077
  };