@rowsncolumns/charts 28.0.0 → 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.
- package/dist/cjs/atoms/pivot-chart.atom.d.ts +14 -0
- package/dist/cjs/atoms/pivot-chart.atom.d.ts.map +1 -0
- package/dist/cjs/atoms/pivot-chart.atom.js +9 -0
- package/dist/cjs/basic-chart/basic-chart.d.ts +1 -1
- package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/cjs/basic-chart/basic-chart.js +863 -371
- package/dist/cjs/components/dialogs/pivot-chart-dialog.d.ts +39 -0
- package/dist/cjs/components/dialogs/pivot-chart-dialog.d.ts.map +1 -0
- package/dist/cjs/components/dialogs/pivot-chart-dialog.js +100 -0
- package/dist/cjs/components/editors/chart-editor.d.ts +13 -1
- package/dist/cjs/components/editors/chart-editor.d.ts.map +1 -1
- package/dist/cjs/components/editors/chart-editor.js +225 -59
- package/dist/cjs/config.d.ts.map +1 -1
- package/dist/cjs/config.js +149 -12
- package/dist/cjs/hooks/use-charts.d.ts +21 -6
- package/dist/cjs/hooks/use-charts.d.ts.map +1 -1
- package/dist/cjs/hooks/use-charts.js +13 -1
- package/dist/cjs/hooks/use-on-create-chart.d.ts.map +1 -1
- package/dist/cjs/hooks/use-on-create-chart.js +7 -1
- package/dist/cjs/hooks/use-on-move-chart-to-sheet.d.ts +12 -0
- package/dist/cjs/hooks/use-on-move-chart-to-sheet.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-move-chart-to-sheet.js +26 -0
- package/dist/cjs/hooks/use-on-move-chart.d.ts +1 -1
- package/dist/cjs/hooks/use-on-move-chart.d.ts.map +1 -1
- package/dist/cjs/hooks/use-on-move-chart.js +2 -2
- package/dist/cjs/hooks/use-on-resize-chart.d.ts +1 -1
- package/dist/cjs/hooks/use-on-resize-chart.d.ts.map +1 -1
- package/dist/cjs/hooks/use-on-resize-chart.js +2 -2
- package/dist/cjs/index.d.ts +11 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +34 -1
- package/dist/cjs/interface/create-chart.d.ts.map +1 -1
- package/dist/cjs/interface/create-chart.js +33 -42
- package/dist/cjs/interface/delete-chart.d.ts.map +1 -1
- package/dist/cjs/interface/delete-chart.js +4 -0
- package/dist/cjs/interface/move-chart-to-sheet.d.ts +13 -0
- package/dist/cjs/interface/move-chart-to-sheet.d.ts.map +1 -0
- package/dist/cjs/interface/move-chart-to-sheet.js +136 -0
- package/dist/cjs/interface/move-chart.d.ts +2 -2
- package/dist/cjs/interface/move-chart.d.ts.map +1 -1
- package/dist/cjs/interface/move-chart.js +7 -1
- package/dist/cjs/interface/resize-chart.d.ts +2 -2
- package/dist/cjs/interface/resize-chart.d.ts.map +1 -1
- package/dist/cjs/interface/resize-chart.js +7 -1
- package/dist/cjs/interface/update-chart.d.ts.map +1 -1
- package/dist/cjs/interface/update-chart.js +4 -0
- package/dist/cjs/plotly/basic-chart/basic-chart.d.ts +1 -1
- package/dist/cjs/plotly/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/cjs/plotly/basic-chart/basic-chart.js +970 -585
- package/dist/cjs/plotly/basic-chart/helpers.d.ts.map +1 -1
- package/dist/cjs/plotly/basic-chart/helpers.js +3 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/cjs/types.d.ts +7 -0
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/utils/axis-number-format.d.ts +69 -0
- package/dist/cjs/utils/axis-number-format.d.ts.map +1 -0
- package/dist/cjs/utils/axis-number-format.js +217 -0
- package/dist/cjs/utils/category-levels.d.ts +27 -0
- package/dist/cjs/utils/category-levels.d.ts.map +1 -0
- package/dist/cjs/utils/category-levels.js +80 -0
- package/dist/cjs/utils/chart-image.d.ts +8 -0
- package/dist/cjs/utils/chart-image.d.ts.map +1 -0
- package/dist/cjs/utils/chart-image.js +28 -0
- package/dist/cjs/utils/chart-orientation.d.ts +67 -0
- package/dist/cjs/utils/chart-orientation.d.ts.map +1 -0
- package/dist/cjs/utils/chart-orientation.js +265 -0
- package/dist/cjs/utils/chart-spec-utils.d.ts +3 -0
- package/dist/cjs/utils/chart-spec-utils.d.ts.map +1 -1
- package/dist/cjs/utils/chart-spec-utils.js +12 -4
- package/dist/cjs/utils/chart-styles.d.ts +75 -0
- package/dist/cjs/utils/chart-styles.d.ts.map +1 -0
- package/dist/cjs/utils/chart-styles.js +283 -0
- package/dist/cjs/utils/empty-cells.d.ts +30 -0
- package/dist/cjs/utils/empty-cells.d.ts.map +1 -0
- package/dist/cjs/utils/empty-cells.js +91 -0
- package/dist/cjs/utils/overrides.d.ts +6 -1
- package/dist/cjs/utils/overrides.d.ts.map +1 -1
- package/dist/cjs/utils/overrides.js +19 -7
- package/dist/cjs/utils/pie-explosion.d.ts +30 -0
- package/dist/cjs/utils/pie-explosion.d.ts.map +1 -0
- package/dist/cjs/utils/pie-explosion.js +55 -0
- package/dist/cjs/utils/plotly-click.d.ts +34 -0
- package/dist/cjs/utils/plotly-click.d.ts.map +1 -0
- package/dist/cjs/utils/plotly-click.js +49 -0
- package/dist/cjs/utils/plotly-surface.d.ts +14 -0
- package/dist/cjs/utils/plotly-surface.d.ts.map +1 -0
- package/dist/cjs/utils/plotly-surface.js +64 -0
- package/dist/cjs/utils/plotly-title.d.ts +12 -0
- package/dist/cjs/utils/plotly-title.d.ts.map +1 -0
- package/dist/cjs/utils/plotly-title.js +24 -0
- package/dist/cjs/utils/series-click.d.ts +39 -0
- package/dist/cjs/utils/series-click.d.ts.map +1 -0
- package/dist/cjs/utils/series-click.js +75 -0
- package/dist/cjs/utils/three-d.d.ts +17 -1
- package/dist/cjs/utils/three-d.d.ts.map +1 -1
- package/dist/cjs/utils/three-d.js +32 -4
- package/dist/esm/atoms/pivot-chart.atom.d.ts +14 -0
- package/dist/esm/atoms/pivot-chart.atom.d.ts.map +1 -0
- package/dist/esm/atoms/pivot-chart.atom.js +4 -0
- package/dist/esm/basic-chart/basic-chart.d.ts +1 -1
- package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/esm/basic-chart/basic-chart.js +680 -178
- package/dist/esm/components/dialogs/pivot-chart-dialog.d.ts +39 -0
- package/dist/esm/components/dialogs/pivot-chart-dialog.d.ts.map +1 -0
- package/dist/esm/components/dialogs/pivot-chart-dialog.js +89 -0
- package/dist/esm/components/editors/chart-editor.d.ts +13 -1
- package/dist/esm/components/editors/chart-editor.d.ts.map +1 -1
- package/dist/esm/components/editors/chart-editor.js +164 -20
- package/dist/esm/config.d.ts.map +1 -1
- package/dist/esm/config.js +149 -12
- package/dist/esm/hooks/use-charts.d.ts +21 -6
- package/dist/esm/hooks/use-charts.d.ts.map +1 -1
- package/dist/esm/hooks/use-charts.js +13 -1
- package/dist/esm/hooks/use-on-create-chart.d.ts.map +1 -1
- package/dist/esm/hooks/use-on-create-chart.js +7 -1
- package/dist/esm/hooks/use-on-move-chart-to-sheet.d.ts +12 -0
- package/dist/esm/hooks/use-on-move-chart-to-sheet.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-move-chart-to-sheet.js +19 -0
- package/dist/esm/hooks/use-on-move-chart.d.ts +1 -1
- package/dist/esm/hooks/use-on-move-chart.d.ts.map +1 -1
- package/dist/esm/hooks/use-on-move-chart.js +2 -2
- package/dist/esm/hooks/use-on-resize-chart.d.ts +1 -1
- package/dist/esm/hooks/use-on-resize-chart.d.ts.map +1 -1
- package/dist/esm/hooks/use-on-resize-chart.js +2 -2
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +14 -0
- package/dist/esm/interface/create-chart.d.ts.map +1 -1
- package/dist/esm/interface/create-chart.js +36 -58
- package/dist/esm/interface/delete-chart.d.ts.map +1 -1
- package/dist/esm/interface/delete-chart.js +4 -0
- package/dist/esm/interface/move-chart-to-sheet.d.ts +13 -0
- package/dist/esm/interface/move-chart-to-sheet.d.ts.map +1 -0
- package/dist/esm/interface/move-chart-to-sheet.js +126 -0
- package/dist/esm/interface/move-chart.d.ts +2 -2
- package/dist/esm/interface/move-chart.d.ts.map +1 -1
- package/dist/esm/interface/move-chart.js +7 -1
- package/dist/esm/interface/resize-chart.d.ts +2 -2
- package/dist/esm/interface/resize-chart.d.ts.map +1 -1
- package/dist/esm/interface/resize-chart.js +7 -1
- package/dist/esm/interface/update-chart.d.ts.map +1 -1
- package/dist/esm/interface/update-chart.js +4 -0
- package/dist/esm/plotly/basic-chart/basic-chart.d.ts +1 -1
- package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/esm/plotly/basic-chart/basic-chart.js +858 -463
- package/dist/esm/plotly/basic-chart/helpers.d.ts.map +1 -1
- package/dist/esm/plotly/basic-chart/helpers.js +3 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/types.d.ts +7 -0
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/utils/axis-number-format.d.ts +69 -0
- package/dist/esm/utils/axis-number-format.d.ts.map +1 -0
- package/dist/esm/utils/axis-number-format.js +170 -0
- package/dist/esm/utils/category-levels.d.ts +27 -0
- package/dist/esm/utils/category-levels.d.ts.map +1 -0
- package/dist/esm/utils/category-levels.js +65 -0
- package/dist/esm/utils/chart-image.d.ts +8 -0
- package/dist/esm/utils/chart-image.d.ts.map +1 -0
- package/dist/esm/utils/chart-image.js +24 -0
- package/dist/esm/utils/chart-orientation.d.ts +67 -0
- package/dist/esm/utils/chart-orientation.d.ts.map +1 -0
- package/dist/esm/utils/chart-orientation.js +234 -0
- package/dist/esm/utils/chart-spec-utils.d.ts +3 -0
- package/dist/esm/utils/chart-spec-utils.d.ts.map +1 -1
- package/dist/esm/utils/chart-spec-utils.js +10 -2
- package/dist/esm/utils/chart-styles.d.ts +75 -0
- package/dist/esm/utils/chart-styles.d.ts.map +1 -0
- package/dist/esm/utils/chart-styles.js +357 -0
- package/dist/esm/utils/empty-cells.d.ts +30 -0
- package/dist/esm/utils/empty-cells.d.ts.map +1 -0
- package/dist/esm/utils/empty-cells.js +67 -0
- package/dist/esm/utils/overrides.d.ts +6 -1
- package/dist/esm/utils/overrides.d.ts.map +1 -1
- package/dist/esm/utils/overrides.js +14 -2
- package/dist/esm/utils/pie-explosion.d.ts +30 -0
- package/dist/esm/utils/pie-explosion.d.ts.map +1 -0
- package/dist/esm/utils/pie-explosion.js +43 -0
- package/dist/esm/utils/plotly-click.d.ts +34 -0
- package/dist/esm/utils/plotly-click.d.ts.map +1 -0
- package/dist/esm/utils/plotly-click.js +44 -0
- package/dist/esm/utils/plotly-surface.d.ts +14 -0
- package/dist/esm/utils/plotly-surface.d.ts.map +1 -0
- package/dist/esm/utils/plotly-surface.js +45 -0
- package/dist/esm/utils/plotly-title.d.ts +12 -0
- package/dist/esm/utils/plotly-title.d.ts.map +1 -0
- package/dist/esm/utils/plotly-title.js +20 -0
- package/dist/esm/utils/series-click.d.ts +39 -0
- package/dist/esm/utils/series-click.d.ts.map +1 -0
- package/dist/esm/utils/series-click.js +66 -0
- package/dist/esm/utils/three-d.d.ts +17 -1
- package/dist/esm/utils/three-d.d.ts.map +1 -1
- package/dist/esm/utils/three-d.js +29 -3
- package/package.json +10 -6
|
@@ -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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
177
|
-
const
|
|
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
|
-
?
|
|
180
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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 =
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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
|
|
1009
|
-
// "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1089
|
-
|
|
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:
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1244
|
-
const rawValues = "values" in seriesData
|
|
1245
|
-
? seriesData.values
|
|
1246
|
-
: seriesData;
|
|
1416
|
+
else {
|
|
1247
1417
|
const trace = {
|
|
1248
|
-
type: "
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const
|
|
1279
|
-
|
|
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 (
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
1525
|
-
//
|
|
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
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
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"
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
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:
|
|
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:
|
|
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
|
};
|