@rowsncolumns/charts 27.0.30
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/LICENSE.md +100 -0
- package/Readme.md +81 -0
- package/dist/cjs/atoms/editing-chart.atom.d.ts +13 -0
- package/dist/cjs/atoms/editing-chart.atom.d.ts.map +1 -0
- package/dist/cjs/atoms/editing-chart.atom.js +26 -0
- package/dist/cjs/basic-chart/basic-chart.d.ts +5 -0
- package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -0
- package/dist/cjs/basic-chart/basic-chart.js +2250 -0
- package/dist/cjs/basic-chart/helpers.d.ts +12 -0
- package/dist/cjs/basic-chart/helpers.d.ts.map +1 -0
- package/dist/cjs/basic-chart/helpers.js +105 -0
- package/dist/cjs/basic-chart/index.d.ts +5 -0
- package/dist/cjs/basic-chart/index.d.ts.map +1 -0
- package/dist/cjs/basic-chart/index.js +12 -0
- package/dist/cjs/components/dialogs/chart-editor-dialog.d.ts +5 -0
- package/dist/cjs/components/dialogs/chart-editor-dialog.d.ts.map +1 -0
- package/dist/cjs/components/dialogs/chart-editor-dialog.js +25 -0
- package/dist/cjs/components/editors/chart-editor.d.ts +10 -0
- package/dist/cjs/components/editors/chart-editor.d.ts.map +1 -0
- package/dist/cjs/components/editors/chart-editor.js +631 -0
- package/dist/cjs/components/editors/chart-range-input.d.ts +17 -0
- package/dist/cjs/components/editors/chart-range-input.d.ts.map +1 -0
- package/dist/cjs/components/editors/chart-range-input.js +16 -0
- package/dist/cjs/config.d.ts +19 -0
- package/dist/cjs/config.d.ts.map +1 -0
- package/dist/cjs/config.js +196 -0
- package/dist/cjs/hooks/use-chart-editor.d.ts +8 -0
- package/dist/cjs/hooks/use-chart-editor.d.ts.map +1 -0
- package/dist/cjs/hooks/use-chart-editor.js +29 -0
- package/dist/cjs/hooks/use-charts.d.ts +35 -0
- package/dist/cjs/hooks/use-charts.d.ts.map +1 -0
- package/dist/cjs/hooks/use-charts.js +67 -0
- package/dist/cjs/hooks/use-on-create-chart.d.ts +13 -0
- package/dist/cjs/hooks/use-on-create-chart.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-create-chart.js +24 -0
- package/dist/cjs/hooks/use-on-delete-chart.d.ts +8 -0
- package/dist/cjs/hooks/use-on-delete-chart.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-delete-chart.js +19 -0
- package/dist/cjs/hooks/use-on-move-chart.d.ts +8 -0
- package/dist/cjs/hooks/use-on-move-chart.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-move-chart.js +15 -0
- package/dist/cjs/hooks/use-on-resize-chart.d.ts +8 -0
- package/dist/cjs/hooks/use-on-resize-chart.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-resize-chart.js +15 -0
- package/dist/cjs/hooks/use-on-update-chart.d.ts +9 -0
- package/dist/cjs/hooks/use-on-update-chart.d.ts.map +1 -0
- package/dist/cjs/hooks/use-on-update-chart.js +15 -0
- package/dist/cjs/index.d.ts +11 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/interface/create-chart.d.ts +9 -0
- package/dist/cjs/interface/create-chart.d.ts.map +1 -0
- package/dist/cjs/interface/create-chart.js +110 -0
- package/dist/cjs/interface/delete-chart.d.ts +4 -0
- package/dist/cjs/interface/delete-chart.d.ts.map +1 -0
- package/dist/cjs/interface/delete-chart.js +26 -0
- package/dist/cjs/interface/move-chart.d.ts +5 -0
- package/dist/cjs/interface/move-chart.d.ts.map +1 -0
- package/dist/cjs/interface/move-chart.js +28 -0
- package/dist/cjs/interface/resize-chart.d.ts +5 -0
- package/dist/cjs/interface/resize-chart.d.ts.map +1 -0
- package/dist/cjs/interface/resize-chart.js +30 -0
- package/dist/cjs/interface/update-chart.d.ts +4 -0
- package/dist/cjs/interface/update-chart.d.ts.map +1 -0
- package/dist/cjs/interface/update-chart.js +29 -0
- package/dist/cjs/plotly/basic-chart/basic-chart.d.ts +5 -0
- package/dist/cjs/plotly/basic-chart/basic-chart.d.ts.map +1 -0
- package/dist/cjs/plotly/basic-chart/basic-chart.js +1586 -0
- package/dist/cjs/plotly/basic-chart/helpers.d.ts +20 -0
- package/dist/cjs/plotly/basic-chart/helpers.d.ts.map +1 -0
- package/dist/cjs/plotly/basic-chart/helpers.js +114 -0
- package/dist/cjs/plotly/basic-chart/index.d.ts +5 -0
- package/dist/cjs/plotly/basic-chart/index.d.ts.map +1 -0
- package/dist/cjs/plotly/basic-chart/index.js +12 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
- package/dist/cjs/types.d.ts +27 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/utils/aggregation.d.ts +6 -0
- package/dist/cjs/utils/aggregation.d.ts.map +1 -0
- package/dist/cjs/utils/aggregation.js +127 -0
- package/dist/cjs/utils/bar-layout.d.ts +57 -0
- package/dist/cjs/utils/bar-layout.d.ts.map +1 -0
- package/dist/cjs/utils/bar-layout.js +81 -0
- package/dist/cjs/utils/boxplot.d.ts +62 -0
- package/dist/cjs/utils/boxplot.d.ts.map +1 -0
- package/dist/cjs/utils/boxplot.js +105 -0
- package/dist/cjs/utils/bubble.d.ts +52 -0
- package/dist/cjs/utils/bubble.d.ts.map +1 -0
- package/dist/cjs/utils/bubble.js +139 -0
- package/dist/cjs/utils/chart-spec-utils.d.ts +67 -0
- package/dist/cjs/utils/chart-spec-utils.d.ts.map +1 -0
- package/dist/cjs/utils/chart-spec-utils.js +355 -0
- package/dist/cjs/utils/echarts-gl-loader.d.ts +11 -0
- package/dist/cjs/utils/echarts-gl-loader.d.ts.map +1 -0
- package/dist/cjs/utils/echarts-gl-loader.js +59 -0
- package/dist/cjs/utils/error-bars.d.ts +58 -0
- package/dist/cjs/utils/error-bars.d.ts.map +1 -0
- package/dist/cjs/utils/error-bars.js +154 -0
- package/dist/cjs/utils/gradient.d.ts +52 -0
- package/dist/cjs/utils/gradient.d.ts.map +1 -0
- package/dist/cjs/utils/gradient.js +93 -0
- package/dist/cjs/utils/hierarchy.d.ts +28 -0
- package/dist/cjs/utils/hierarchy.d.ts.map +1 -0
- package/dist/cjs/utils/hierarchy.js +186 -0
- package/dist/cjs/utils/histogram.d.ts +46 -0
- package/dist/cjs/utils/histogram.d.ts.map +1 -0
- package/dist/cjs/utils/histogram.js +125 -0
- package/dist/cjs/utils/of-pie.d.ts +101 -0
- package/dist/cjs/utils/of-pie.d.ts.map +1 -0
- package/dist/cjs/utils/of-pie.js +159 -0
- package/dist/cjs/utils/overrides.d.ts +12 -0
- package/dist/cjs/utils/overrides.d.ts.map +1 -0
- package/dist/cjs/utils/overrides.js +171 -0
- package/dist/cjs/utils/radar.d.ts +37 -0
- package/dist/cjs/utils/radar.d.ts.map +1 -0
- package/dist/cjs/utils/radar.js +70 -0
- package/dist/cjs/utils/region-map-loader.d.ts +22 -0
- package/dist/cjs/utils/region-map-loader.d.ts.map +1 -0
- package/dist/cjs/utils/region-map-loader.js +174 -0
- package/dist/cjs/utils/region-map.d.ts +100 -0
- package/dist/cjs/utils/region-map.d.ts.map +1 -0
- package/dist/cjs/utils/region-map.js +327 -0
- package/dist/cjs/utils/stock.d.ts +64 -0
- package/dist/cjs/utils/stock.d.ts.map +1 -0
- package/dist/cjs/utils/stock.js +153 -0
- package/dist/cjs/utils/three-d.d.ts +96 -0
- package/dist/cjs/utils/three-d.d.ts.map +1 -0
- package/dist/cjs/utils/three-d.js +191 -0
- package/dist/cjs/utils/trendline.d.ts +41 -0
- package/dist/cjs/utils/trendline.d.ts.map +1 -0
- package/dist/cjs/utils/trendline.js +303 -0
- package/dist/cjs/utils/waterfall.d.ts +24 -0
- package/dist/cjs/utils/waterfall.d.ts.map +1 -0
- package/dist/cjs/utils/waterfall.js +61 -0
- package/dist/cjs/utils/webgl.d.ts +12 -0
- package/dist/cjs/utils/webgl.d.ts.map +1 -0
- package/dist/cjs/utils/webgl.js +36 -0
- package/dist/cjs/vitest.config.d.ts +3 -0
- package/dist/cjs/vitest.config.d.ts.map +1 -0
- package/dist/cjs/vitest.config.js +9 -0
- package/dist/esm/atoms/editing-chart.atom.d.ts +13 -0
- package/dist/esm/atoms/editing-chart.atom.d.ts.map +1 -0
- package/dist/esm/atoms/editing-chart.atom.js +12 -0
- package/dist/esm/basic-chart/basic-chart.d.ts +5 -0
- package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -0
- package/dist/esm/basic-chart/basic-chart.js +2343 -0
- package/dist/esm/basic-chart/helpers.d.ts +12 -0
- package/dist/esm/basic-chart/helpers.d.ts.map +1 -0
- package/dist/esm/basic-chart/helpers.js +98 -0
- package/dist/esm/basic-chart/index.d.ts +5 -0
- package/dist/esm/basic-chart/index.d.ts.map +1 -0
- package/dist/esm/basic-chart/index.js +7 -0
- package/dist/esm/components/dialogs/chart-editor-dialog.d.ts +5 -0
- package/dist/esm/components/dialogs/chart-editor-dialog.d.ts.map +1 -0
- package/dist/esm/components/dialogs/chart-editor-dialog.js +19 -0
- package/dist/esm/components/editors/chart-editor.d.ts +10 -0
- package/dist/esm/components/editors/chart-editor.d.ts.map +1 -0
- package/dist/esm/components/editors/chart-editor.js +550 -0
- package/dist/esm/components/editors/chart-range-input.d.ts +17 -0
- package/dist/esm/components/editors/chart-range-input.d.ts.map +1 -0
- package/dist/esm/components/editors/chart-range-input.js +10 -0
- package/dist/esm/config.d.ts +19 -0
- package/dist/esm/config.d.ts.map +1 -0
- package/dist/esm/config.js +161 -0
- package/dist/esm/hooks/use-chart-editor.d.ts +8 -0
- package/dist/esm/hooks/use-chart-editor.d.ts.map +1 -0
- package/dist/esm/hooks/use-chart-editor.js +32 -0
- package/dist/esm/hooks/use-charts.d.ts +35 -0
- package/dist/esm/hooks/use-charts.d.ts.map +1 -0
- package/dist/esm/hooks/use-charts.js +62 -0
- package/dist/esm/hooks/use-on-create-chart.d.ts +13 -0
- package/dist/esm/hooks/use-on-create-chart.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-create-chart.js +19 -0
- package/dist/esm/hooks/use-on-delete-chart.d.ts +8 -0
- package/dist/esm/hooks/use-on-delete-chart.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-delete-chart.js +14 -0
- package/dist/esm/hooks/use-on-move-chart.d.ts +8 -0
- package/dist/esm/hooks/use-on-move-chart.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-move-chart.js +10 -0
- package/dist/esm/hooks/use-on-resize-chart.d.ts +8 -0
- package/dist/esm/hooks/use-on-resize-chart.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-resize-chart.js +10 -0
- package/dist/esm/hooks/use-on-update-chart.d.ts +9 -0
- package/dist/esm/hooks/use-on-update-chart.d.ts.map +1 -0
- package/dist/esm/hooks/use-on-update-chart.js +10 -0
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/interface/create-chart.d.ts +9 -0
- package/dist/esm/interface/create-chart.d.ts.map +1 -0
- package/dist/esm/interface/create-chart.js +126 -0
- package/dist/esm/interface/delete-chart.d.ts +4 -0
- package/dist/esm/interface/delete-chart.d.ts.map +1 -0
- package/dist/esm/interface/delete-chart.js +20 -0
- package/dist/esm/interface/move-chart.d.ts +5 -0
- package/dist/esm/interface/move-chart.d.ts.map +1 -0
- package/dist/esm/interface/move-chart.js +22 -0
- package/dist/esm/interface/resize-chart.d.ts +5 -0
- package/dist/esm/interface/resize-chart.d.ts.map +1 -0
- package/dist/esm/interface/resize-chart.js +24 -0
- package/dist/esm/interface/update-chart.d.ts +4 -0
- package/dist/esm/interface/update-chart.d.ts.map +1 -0
- package/dist/esm/interface/update-chart.js +26 -0
- package/dist/esm/plotly/basic-chart/basic-chart.d.ts +5 -0
- package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -0
- package/dist/esm/plotly/basic-chart/basic-chart.js +1682 -0
- package/dist/esm/plotly/basic-chart/helpers.d.ts +20 -0
- package/dist/esm/plotly/basic-chart/helpers.d.ts.map +1 -0
- package/dist/esm/plotly/basic-chart/helpers.js +107 -0
- package/dist/esm/plotly/basic-chart/index.d.ts +5 -0
- package/dist/esm/plotly/basic-chart/index.d.ts.map +1 -0
- package/dist/esm/plotly/basic-chart/index.js +7 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -0
- package/dist/esm/types.d.ts +27 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/utils/aggregation.d.ts +6 -0
- package/dist/esm/utils/aggregation.d.ts.map +1 -0
- package/dist/esm/utils/aggregation.js +96 -0
- package/dist/esm/utils/bar-layout.d.ts +57 -0
- package/dist/esm/utils/bar-layout.d.ts.map +1 -0
- package/dist/esm/utils/bar-layout.js +70 -0
- package/dist/esm/utils/boxplot.d.ts +62 -0
- package/dist/esm/utils/boxplot.d.ts.map +1 -0
- package/dist/esm/utils/boxplot.js +91 -0
- package/dist/esm/utils/bubble.d.ts +52 -0
- package/dist/esm/utils/bubble.d.ts.map +1 -0
- package/dist/esm/utils/bubble.js +122 -0
- package/dist/esm/utils/chart-spec-utils.d.ts +67 -0
- package/dist/esm/utils/chart-spec-utils.d.ts.map +1 -0
- package/dist/esm/utils/chart-spec-utils.js +270 -0
- package/dist/esm/utils/echarts-gl-loader.d.ts +11 -0
- package/dist/esm/utils/echarts-gl-loader.d.ts.map +1 -0
- package/dist/esm/utils/echarts-gl-loader.js +51 -0
- package/dist/esm/utils/error-bars.d.ts +58 -0
- package/dist/esm/utils/error-bars.d.ts.map +1 -0
- package/dist/esm/utils/error-bars.js +134 -0
- package/dist/esm/utils/gradient.d.ts +52 -0
- package/dist/esm/utils/gradient.d.ts.map +1 -0
- package/dist/esm/utils/gradient.js +84 -0
- package/dist/esm/utils/hierarchy.d.ts +28 -0
- package/dist/esm/utils/hierarchy.d.ts.map +1 -0
- package/dist/esm/utils/hierarchy.js +150 -0
- package/dist/esm/utils/histogram.d.ts +46 -0
- package/dist/esm/utils/histogram.d.ts.map +1 -0
- package/dist/esm/utils/histogram.js +95 -0
- package/dist/esm/utils/of-pie.d.ts +101 -0
- package/dist/esm/utils/of-pie.d.ts.map +1 -0
- package/dist/esm/utils/of-pie.js +141 -0
- package/dist/esm/utils/overrides.d.ts +12 -0
- package/dist/esm/utils/overrides.d.ts.map +1 -0
- package/dist/esm/utils/overrides.js +159 -0
- package/dist/esm/utils/radar.d.ts +37 -0
- package/dist/esm/utils/radar.d.ts.map +1 -0
- package/dist/esm/utils/radar.js +49 -0
- package/dist/esm/utils/region-map-loader.d.ts +22 -0
- package/dist/esm/utils/region-map-loader.d.ts.map +1 -0
- package/dist/esm/utils/region-map-loader.js +141 -0
- package/dist/esm/utils/region-map.d.ts +100 -0
- package/dist/esm/utils/region-map.d.ts.map +1 -0
- package/dist/esm/utils/region-map.js +266 -0
- package/dist/esm/utils/stock.d.ts +64 -0
- package/dist/esm/utils/stock.d.ts.map +1 -0
- package/dist/esm/utils/stock.js +123 -0
- package/dist/esm/utils/three-d.d.ts +96 -0
- package/dist/esm/utils/three-d.d.ts.map +1 -0
- package/dist/esm/utils/three-d.js +162 -0
- package/dist/esm/utils/trendline.d.ts +41 -0
- package/dist/esm/utils/trendline.d.ts.map +1 -0
- package/dist/esm/utils/trendline.js +257 -0
- package/dist/esm/utils/waterfall.d.ts +24 -0
- package/dist/esm/utils/waterfall.d.ts.map +1 -0
- package/dist/esm/utils/waterfall.js +55 -0
- package/dist/esm/utils/webgl.d.ts +12 -0
- package/dist/esm/utils/webgl.d.ts.map +1 -0
- package/dist/esm/utils/webgl.js +30 -0
- package/dist/esm/vitest.config.d.ts +3 -0
- package/dist/esm/vitest.config.d.ts.map +1 -0
- package/dist/esm/vitest.config.js +7 -0
- package/package.json +96 -0
|
@@ -0,0 +1,1682 @@
|
|
|
1
|
+
import { DotsVerticalIcon } from "@rowsncolumns/icons";
|
|
2
|
+
import { getStringifiedColor, useFocusSheet } from "@rowsncolumns/spreadsheet";
|
|
3
|
+
import { useSubscribeToSheet } from "@rowsncolumns/spreadsheet-state";
|
|
4
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger, IconButton, useIsomorphicLayoutEffect, useOnVisibilityChange, } from "@rowsncolumns/ui";
|
|
5
|
+
import { debug, isAFormula, isNil, ssfFormat } from "@rowsncolumns/utils";
|
|
6
|
+
import React, { useCallback, useRef, useState } from "react";
|
|
7
|
+
import Plot from "react-plotly.js";
|
|
8
|
+
import { aggregateSeriesByDomain, getDomainValuesForAxis, sanitizeDomainValues, shouldAggregateSeries, } from "../../utils/aggregation";
|
|
9
|
+
import { computeBoxplotSummary } from "../../utils/boxplot";
|
|
10
|
+
import { createBubbleSizeScale, prepareBubbleSeriesData, toLabelString, } from "../../utils/bubble";
|
|
11
|
+
import { coerceCalculatedLabel } from "../../utils/chart-spec-utils";
|
|
12
|
+
import { computeErrorBars, resolveCustomErrorBarValues, } from "../../utils/error-bars";
|
|
13
|
+
import { gradientToPlotly } from "../../utils/gradient";
|
|
14
|
+
import { extractHierarchyRows, getHierarchyHeaderTitle, toPlotlyHierarchy, } from "../../utils/hierarchy";
|
|
15
|
+
import { binHistogram, computePareto, isParetoSpec, } from "../../utils/histogram";
|
|
16
|
+
import { clampSecondPieSize, isOfPieChartSpec, otherSliceHalfAngle, resolveOfPieType, splitOfPie, } from "../../utils/of-pie";
|
|
17
|
+
import { buildRadarIndicators } from "../../utils/radar";
|
|
18
|
+
import { buildSequentialColorRamp, isRegionMapSpec, resolveGeoScope, WORLD_GEO_SCOPE, } from "../../utils/region-map";
|
|
19
|
+
import { assignStockRoles, toLineSeries, toVolumeSeries, } from "../../utils/stock";
|
|
20
|
+
import { computeTrendline } from "../../utils/trendline";
|
|
21
|
+
import { chartTypeToPlotlyType, getPlotlyTraceStyle, mapMarkerSymbolToPlotly, showXAxis, } from "./helpers";
|
|
22
|
+
const BASIC_CHART_TYPES = new Set([
|
|
23
|
+
"bar",
|
|
24
|
+
"column",
|
|
25
|
+
"line",
|
|
26
|
+
"pie",
|
|
27
|
+
"doughnut",
|
|
28
|
+
"area",
|
|
29
|
+
"radar",
|
|
30
|
+
"stock",
|
|
31
|
+
"histogram",
|
|
32
|
+
"boxplot",
|
|
33
|
+
"waterfall",
|
|
34
|
+
"funnel",
|
|
35
|
+
"combo",
|
|
36
|
+
"regionMap",
|
|
37
|
+
]);
|
|
38
|
+
// Plotly ships its own world geometry; these are the `layout.geo.scope`
|
|
39
|
+
// values it accepts. Any other authored geoScope falls back to "world".
|
|
40
|
+
const PLOTLY_GEO_SCOPES = new Set([
|
|
41
|
+
"world",
|
|
42
|
+
"usa",
|
|
43
|
+
"europe",
|
|
44
|
+
"asia",
|
|
45
|
+
"africa",
|
|
46
|
+
"north america",
|
|
47
|
+
"south america",
|
|
48
|
+
]);
|
|
49
|
+
// -------------------------- Phase 5.1 helpers --------------------------
|
|
50
|
+
/** Merge a parsed `ChartTextFormat` onto a plotly font object. */
|
|
51
|
+
const applyTextFormatToFont = (font, fmt) => {
|
|
52
|
+
if (!fmt)
|
|
53
|
+
return font;
|
|
54
|
+
const next = { ...(font ?? {}) };
|
|
55
|
+
if (fmt.color)
|
|
56
|
+
next.color = fmt.color;
|
|
57
|
+
if (fmt.fontFamily)
|
|
58
|
+
next.family = fmt.fontFamily;
|
|
59
|
+
if (typeof fmt.fontSize === "number")
|
|
60
|
+
next.size = fmt.fontSize;
|
|
61
|
+
return next;
|
|
62
|
+
};
|
|
63
|
+
const applyLegendOverridesPlotly = (legend, opts) => {
|
|
64
|
+
if (!opts)
|
|
65
|
+
return { legend, showlegend: undefined };
|
|
66
|
+
if (opts.position === "none") {
|
|
67
|
+
return { legend, showlegend: false };
|
|
68
|
+
}
|
|
69
|
+
const next = { ...(legend ?? {}) };
|
|
70
|
+
switch (opts.position) {
|
|
71
|
+
case "top":
|
|
72
|
+
next.orientation = "h";
|
|
73
|
+
next.x = 0.5;
|
|
74
|
+
next.xanchor = "center";
|
|
75
|
+
next.y = 1.1;
|
|
76
|
+
break;
|
|
77
|
+
case "bottom":
|
|
78
|
+
next.orientation = "h";
|
|
79
|
+
next.x = 0.5;
|
|
80
|
+
next.xanchor = "center";
|
|
81
|
+
next.y = -0.2;
|
|
82
|
+
break;
|
|
83
|
+
case "left":
|
|
84
|
+
next.orientation = "v";
|
|
85
|
+
next.x = -0.2;
|
|
86
|
+
next.y = 0.5;
|
|
87
|
+
next.yanchor = "middle";
|
|
88
|
+
break;
|
|
89
|
+
case "right":
|
|
90
|
+
next.orientation = "v";
|
|
91
|
+
next.x = 1.1;
|
|
92
|
+
next.y = 0.5;
|
|
93
|
+
next.yanchor = "middle";
|
|
94
|
+
break;
|
|
95
|
+
case "topRight":
|
|
96
|
+
next.orientation = "v";
|
|
97
|
+
next.x = 1;
|
|
98
|
+
next.y = 1;
|
|
99
|
+
next.xanchor = "right";
|
|
100
|
+
next.yanchor = "top";
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
// Authored legend `<c:txPr>` overrides the theme-derived default.
|
|
104
|
+
next.font = applyTextFormatToFont(next.font, opts.textFormat);
|
|
105
|
+
return { legend: next, showlegend: true };
|
|
106
|
+
};
|
|
107
|
+
const applyAxisOverridesPlotly = (axis, opts) => {
|
|
108
|
+
if (!opts)
|
|
109
|
+
return axis;
|
|
110
|
+
const next = { ...(axis ?? {}) };
|
|
111
|
+
if (opts.logarithmic)
|
|
112
|
+
next.type = "log";
|
|
113
|
+
if (opts.reverse)
|
|
114
|
+
next.autorange = "reversed";
|
|
115
|
+
if (typeof opts.min === "number" || typeof opts.max === "number") {
|
|
116
|
+
next.range = [opts.min ?? null, opts.max ?? null];
|
|
117
|
+
}
|
|
118
|
+
if (opts.visible === false)
|
|
119
|
+
next.visible = false;
|
|
120
|
+
if (opts.showMajorGridlines === false)
|
|
121
|
+
next.showgrid = false;
|
|
122
|
+
if (opts.showMajorGridlines === true)
|
|
123
|
+
next.showgrid = true;
|
|
124
|
+
// Authored axis-label `<c:txPr>` — affects tick labels (`tickfont`)
|
|
125
|
+
// and the axis title font (`title.font`).
|
|
126
|
+
if (opts.textFormat) {
|
|
127
|
+
next.tickfont = applyTextFormatToFont(next.tickfont, opts.textFormat);
|
|
128
|
+
next.title = {
|
|
129
|
+
...(next.title ?? {}),
|
|
130
|
+
font: applyTextFormatToFont(next.title?.font, opts.textFormat),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return next;
|
|
134
|
+
};
|
|
135
|
+
const PLOTLY_DLBL_POS_PIE = {
|
|
136
|
+
center: "inside",
|
|
137
|
+
inside: "inside",
|
|
138
|
+
outside: "outside",
|
|
139
|
+
insideEnd: "inside",
|
|
140
|
+
outsideEnd: "outside",
|
|
141
|
+
bestFit: "outside",
|
|
142
|
+
};
|
|
143
|
+
const PLOTLY_DLBL_POS_BAR = {
|
|
144
|
+
center: "inside",
|
|
145
|
+
inside: "inside",
|
|
146
|
+
outside: "outside",
|
|
147
|
+
insideEnd: "inside",
|
|
148
|
+
outsideEnd: "outside",
|
|
149
|
+
bestFit: "outside",
|
|
150
|
+
};
|
|
151
|
+
const applyDataLabelsToTracePlotly = (trace, opts, chartType) => {
|
|
152
|
+
if (!opts?.show)
|
|
153
|
+
return trace;
|
|
154
|
+
const next = { ...trace };
|
|
155
|
+
const isCircular = chartType === "pie" || chartType === "doughnut";
|
|
156
|
+
const posMap = isCircular ? PLOTLY_DLBL_POS_PIE : PLOTLY_DLBL_POS_BAR;
|
|
157
|
+
const position = opts.position ? posMap[opts.position] ?? "inside" : "inside";
|
|
158
|
+
if (isCircular) {
|
|
159
|
+
// Plotly's pie trace uses `textinfo` to control which fields render.
|
|
160
|
+
const parts = [];
|
|
161
|
+
if (opts.showCategoryName !== false && opts.showCategoryName)
|
|
162
|
+
parts.push("label");
|
|
163
|
+
if (opts.showValue !== false)
|
|
164
|
+
parts.push("value");
|
|
165
|
+
if (opts.showPercent)
|
|
166
|
+
parts.push("percent");
|
|
167
|
+
next.textinfo = parts.length ? parts.join("+") : "value";
|
|
168
|
+
next.textposition = position;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// <c:dLbls><c:numFmt formatCode=…> — when authored, run each value
|
|
172
|
+
// through the same numfmt grammar the spreadsheet uses (`"$"#,##0`
|
|
173
|
+
// → `$1,831`). Plotly supports this natively via `texttemplate`
|
|
174
|
+
// 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 : [];
|
|
178
|
+
next.text = pattern
|
|
179
|
+
? yVals.map((v) => (v === null || v === undefined ? "" : ssfFormat(pattern, v)))
|
|
180
|
+
: next.y;
|
|
181
|
+
next.textposition = position;
|
|
182
|
+
}
|
|
183
|
+
return next;
|
|
184
|
+
};
|
|
185
|
+
const BUBBLE_CHART_TYPES = new Set([
|
|
186
|
+
"bubble",
|
|
187
|
+
"scatter",
|
|
188
|
+
]);
|
|
189
|
+
const HIERARCHY_CHART_TYPES = new Set([
|
|
190
|
+
"treemap",
|
|
191
|
+
"sunburst",
|
|
192
|
+
]);
|
|
193
|
+
const isBasicChartSpec = (spec) => BASIC_CHART_TYPES.has(spec.chartType);
|
|
194
|
+
const isBubbleChartSpec = (spec) => BUBBLE_CHART_TYPES.has(spec.chartType);
|
|
195
|
+
const isHierarchyChartSpec = (spec) => HIERARCHY_CHART_TYPES.has(spec.chartType);
|
|
196
|
+
const flattenChartDataValues = (chartData, extractor, hiddenStrategy, skipFirstRow = false) => {
|
|
197
|
+
if (!chartData || chartData.length === 0)
|
|
198
|
+
return [];
|
|
199
|
+
const values = [];
|
|
200
|
+
chartData.forEach((entry) => {
|
|
201
|
+
// Inline values (`<c:strLit>` / `<c:numLit>`) take precedence over
|
|
202
|
+
// cell-range sources — only one is populated by spec.
|
|
203
|
+
const inline = entry
|
|
204
|
+
.inlineValues;
|
|
205
|
+
if (inline && inline.length) {
|
|
206
|
+
const trimmed = skipFirstRow ? inline.slice(1) : inline;
|
|
207
|
+
values.push(...trimmed);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
entry.sources.forEach((source) => {
|
|
211
|
+
const extracted = extractor(source, hiddenStrategy) ?? [];
|
|
212
|
+
const trimmed = skipFirstRow && extracted.length > 0 ? extracted.slice(1) : extracted;
|
|
213
|
+
values.push(...trimmed);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
return values;
|
|
217
|
+
};
|
|
218
|
+
// Excel error values that should be treated as null in charts
|
|
219
|
+
const EXCEL_ERROR_VALUES = new Set([
|
|
220
|
+
"#N/A",
|
|
221
|
+
"#REF!",
|
|
222
|
+
"#VALUE!",
|
|
223
|
+
"#DIV/0!",
|
|
224
|
+
"#NAME?",
|
|
225
|
+
"#NULL!",
|
|
226
|
+
"#NUM!",
|
|
227
|
+
"#ERROR!",
|
|
228
|
+
"#GETTING_DATA",
|
|
229
|
+
]);
|
|
230
|
+
/**
|
|
231
|
+
* Check if a value is an Excel error string
|
|
232
|
+
*/
|
|
233
|
+
const isErrorValue = (value) => {
|
|
234
|
+
if (typeof value !== "string")
|
|
235
|
+
return false;
|
|
236
|
+
return EXCEL_ERROR_VALUES.has(value.toUpperCase());
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Sanitize a value for chart display - convert errors to undefined
|
|
240
|
+
*/
|
|
241
|
+
const sanitizeChartValue = (value) => {
|
|
242
|
+
if (value === null || value === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
if (isErrorValue(value))
|
|
245
|
+
return undefined;
|
|
246
|
+
if (typeof value === "number" && !isNaN(value))
|
|
247
|
+
return value;
|
|
248
|
+
if (typeof value === "string") {
|
|
249
|
+
const num = parseFloat(value);
|
|
250
|
+
return isNaN(num) ? undefined : num;
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Add alpha transparency to a color (hex or rgb)
|
|
256
|
+
*/
|
|
257
|
+
const addAlphaToColor = (color, alpha) => {
|
|
258
|
+
if (!color)
|
|
259
|
+
return `rgba(0, 0, 0, ${alpha})`;
|
|
260
|
+
// Handle hex colors
|
|
261
|
+
if (color.startsWith("#")) {
|
|
262
|
+
const hex = color.slice(1);
|
|
263
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
264
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
265
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
266
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
267
|
+
}
|
|
268
|
+
// Handle rgb/rgba colors
|
|
269
|
+
if (color.startsWith("rgb")) {
|
|
270
|
+
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
271
|
+
if (match) {
|
|
272
|
+
return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${alpha})`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return color;
|
|
276
|
+
};
|
|
277
|
+
export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, onDeleteChart, onRequestEdit, onRequestCalculate, isSelected, }) => {
|
|
278
|
+
const spec = chart.spec;
|
|
279
|
+
const hierarchyTitleRef = useRef(undefined);
|
|
280
|
+
const explicitChartTitle = chart.spec.title ?? undefined;
|
|
281
|
+
const title = explicitChartTitle ?? hierarchyTitleRef.current;
|
|
282
|
+
const plotRef = useRef(null);
|
|
283
|
+
const focusSheet = useFocusSheet();
|
|
284
|
+
const shouldFocusSheetRef = useRef(false);
|
|
285
|
+
const [plotData, setPlotData] = useState([]);
|
|
286
|
+
const [layout, setLayout] = useState({});
|
|
287
|
+
const getSeriesData = useCallback((spec, sheetRange, options) => {
|
|
288
|
+
const rawSeriesValues = getSeriesValuesFromRange(sheetRange, spec.hiddenDimensionStrategy);
|
|
289
|
+
// Sanitize values - convert error strings like #N/A to null
|
|
290
|
+
const sanitizedValues = rawSeriesValues.map(sanitizeChartValue);
|
|
291
|
+
const { aggregateEnabled, aggregateType, rawDomainValues, orderedDomainValues, } = options;
|
|
292
|
+
const canAggregate = aggregateEnabled &&
|
|
293
|
+
!!aggregateType &&
|
|
294
|
+
rawDomainValues.length > 0 &&
|
|
295
|
+
orderedDomainValues.length > 0 &&
|
|
296
|
+
rawDomainValues.length === sanitizedValues.length;
|
|
297
|
+
if (canAggregate) {
|
|
298
|
+
const aggregatedValues = aggregateSeriesByDomain(rawDomainValues, sanitizedValues, aggregateType, orderedDomainValues).map((value) => value ?? 0);
|
|
299
|
+
if ((spec.chartType === "pie" || spec.chartType === "doughnut")) {
|
|
300
|
+
return {
|
|
301
|
+
values: aggregatedValues,
|
|
302
|
+
labels: orderedDomainValues,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return aggregatedValues;
|
|
306
|
+
}
|
|
307
|
+
// Use sanitized values - errors like #N/A become undefined
|
|
308
|
+
const seriesValues = sanitizedValues;
|
|
309
|
+
if ((spec.chartType === "pie" || spec.chartType === "doughnut")) {
|
|
310
|
+
const domainSheetRange = spec.domains[0]?.sources[0];
|
|
311
|
+
if (!domainSheetRange) {
|
|
312
|
+
return { values: [], labels: [] };
|
|
313
|
+
}
|
|
314
|
+
const domainValues = getDomainValuesFromRange(domainSheetRange, spec.hiddenDimensionStrategy);
|
|
315
|
+
return {
|
|
316
|
+
values: seriesValues,
|
|
317
|
+
labels: domainValues,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return seriesValues;
|
|
321
|
+
}, [getSeriesValuesFromRange, getDomainValuesFromRange]);
|
|
322
|
+
/**
|
|
323
|
+
* Triggers a chart update
|
|
324
|
+
*/
|
|
325
|
+
const updateChart = async () => {
|
|
326
|
+
debug("Updating chart", chart);
|
|
327
|
+
const traces = [];
|
|
328
|
+
const chartType = chartTypeToPlotlyType(spec?.chartType);
|
|
329
|
+
const position = chart.position;
|
|
330
|
+
const isBubble = isBubbleChartSpec(spec);
|
|
331
|
+
const isHierarchy = isHierarchyChartSpec(spec);
|
|
332
|
+
const textColor = getStringifiedColor({
|
|
333
|
+
theme: 1,
|
|
334
|
+
tint: isDarkMode ? 1 : 0,
|
|
335
|
+
}, theme);
|
|
336
|
+
const bgColor = getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme);
|
|
337
|
+
const gridColor = isDarkMode ? "rgba(255,255,255,0.3)" : "#CCCCCC";
|
|
338
|
+
if (isBubble) {
|
|
339
|
+
const skipHeaders = spec.useFirstRowAsHeaders !== false;
|
|
340
|
+
const labels = flattenChartDataValues(spec.bubbleLabels, getDomainValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders).map(toLabelString);
|
|
341
|
+
// Per-series resolved values. Scatter (always) and bubble (often)
|
|
342
|
+
// carry one xVal/bubbleSize per series in OOXML — the parser
|
|
343
|
+
// produces `spec.domains` and `spec.bubbleSizes` with one entry
|
|
344
|
+
// per series. Earlier code flattened all of them into a single
|
|
345
|
+
// shared X, which broke when series had different ranges. Now
|
|
346
|
+
// each series picks its own slot; we still expose a shared
|
|
347
|
+
// `xValues` for callers that pass a single domain (back-compat).
|
|
348
|
+
const perSeriesX = (spec.domains ?? []).map((dom) => flattenChartDataValues([dom], getSeriesValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders));
|
|
349
|
+
const perSeriesSize = (spec.bubbleSizes ?? []).map((sz) => flattenChartDataValues([sz], getSeriesValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders));
|
|
350
|
+
const seriesInputs = [];
|
|
351
|
+
for (let i = 0; i < spec.series.length; i++) {
|
|
352
|
+
const seriesEntry = spec.series[i];
|
|
353
|
+
let resolvedLabel = seriesEntry.dataLabel ?? "";
|
|
354
|
+
if (resolvedLabel && isAFormula(resolvedLabel)) {
|
|
355
|
+
resolvedLabel = coerceCalculatedLabel(await onRequestCalculate(resolvedLabel, chart.position.sheetId ?? sheetId, chart.position.overlayPosition.anchorCell.rowIndex, chart.position.overlayPosition.anchorCell.columnIndex));
|
|
356
|
+
}
|
|
357
|
+
// Per-series X, with fallback to the first domain so single-X
|
|
358
|
+
// bubble charts still work.
|
|
359
|
+
const xValues = perSeriesX[i] ?? perSeriesX[0];
|
|
360
|
+
const sizeValues = perSeriesSize[i] ?? perSeriesSize[0];
|
|
361
|
+
seriesInputs.push({
|
|
362
|
+
name: resolvedLabel,
|
|
363
|
+
values: flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders),
|
|
364
|
+
...(xValues ? { xValues } : {}),
|
|
365
|
+
...(sizeValues ? { sizeValues } : {}),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// Shared fallbacks (legacy bubble charts with a single domain).
|
|
369
|
+
const sharedXValues = perSeriesX[0] ?? [];
|
|
370
|
+
const sharedSizeValues = perSeriesSize[0] ?? [];
|
|
371
|
+
const { categories, series: preparedSeries, sizeValues, } = prepareBubbleSeriesData({
|
|
372
|
+
xValues: sharedXValues,
|
|
373
|
+
labelValues: labels,
|
|
374
|
+
sizeValues: sharedSizeValues,
|
|
375
|
+
series: seriesInputs,
|
|
376
|
+
});
|
|
377
|
+
const sizeScale = createBubbleSizeScale(sizeValues);
|
|
378
|
+
const hasStringX = categories.length > 0;
|
|
379
|
+
const palette = [
|
|
380
|
+
theme.themeColors.accent1,
|
|
381
|
+
theme.themeColors.accent2,
|
|
382
|
+
theme.themeColors.accent3,
|
|
383
|
+
theme.themeColors.accent4,
|
|
384
|
+
theme.themeColors.accent5,
|
|
385
|
+
theme.themeColors.accent6,
|
|
386
|
+
];
|
|
387
|
+
// Scatter and bubble share the same "BubbleChartSpec" shape but
|
|
388
|
+
// render very differently. Scatter is small fixed-size markers
|
|
389
|
+
// (optionally connected by a line per `scatterStyle`); bubble
|
|
390
|
+
// applies a size scale so the third dimension shows as bubble
|
|
391
|
+
// diameter. Treating scatter through the bubble path produced
|
|
392
|
+
// huge bubbles sized by Y values (the original mismatch with
|
|
393
|
+
// Excel's scatter chart).
|
|
394
|
+
const isScatter = spec.chartType === "scatter";
|
|
395
|
+
const scatterStyle = spec.scatterStyle ?? (isScatter ? "lineMarker" : undefined);
|
|
396
|
+
const plotlyModeForScatterStyle = (style) => {
|
|
397
|
+
switch (style) {
|
|
398
|
+
case "line":
|
|
399
|
+
return "lines";
|
|
400
|
+
case "smooth":
|
|
401
|
+
return "lines";
|
|
402
|
+
case "marker":
|
|
403
|
+
return "markers";
|
|
404
|
+
case "smoothMarker":
|
|
405
|
+
return "lines+markers";
|
|
406
|
+
case "lineMarker":
|
|
407
|
+
default:
|
|
408
|
+
return "lines+markers";
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const lineShapeFor = (style) => style === "smooth" || style === "smoothMarker"
|
|
412
|
+
? "spline"
|
|
413
|
+
: "linear";
|
|
414
|
+
const traces = preparedSeries.map((entry, index) => {
|
|
415
|
+
const xValues = entry.points.map((point) => hasStringX
|
|
416
|
+
? point.xLabel ??
|
|
417
|
+
categories[Math.round(point.value[0])] ??
|
|
418
|
+
point.value[0]
|
|
419
|
+
: point.value[0]);
|
|
420
|
+
const yValues = entry.points.map((point) => point.value[1]);
|
|
421
|
+
const labelValuesForSeries = entry.points.map((point) => point.name);
|
|
422
|
+
const seriesColor = spec.series[index]?.color ?? palette[index % palette.length];
|
|
423
|
+
const perSeriesMarker = spec.series[index]?.marker ?? undefined;
|
|
424
|
+
if (isScatter) {
|
|
425
|
+
// Per-series marker.symbol = "none" → pure line (no markers).
|
|
426
|
+
// Per-series marker.size overrides the default. Falls back to
|
|
427
|
+
// chart-level scatterStyle for the line/marker mix.
|
|
428
|
+
const symbol = perSeriesMarker?.symbol;
|
|
429
|
+
const noMarkers = symbol === "none";
|
|
430
|
+
const mode = noMarkers
|
|
431
|
+
? "lines"
|
|
432
|
+
: plotlyModeForScatterStyle(scatterStyle);
|
|
433
|
+
const markerSize = perSeriesMarker?.size ?? 6;
|
|
434
|
+
const markerColor = perSeriesMarker?.color ?? seriesColor;
|
|
435
|
+
return {
|
|
436
|
+
type: "scatter",
|
|
437
|
+
mode,
|
|
438
|
+
x: xValues,
|
|
439
|
+
y: yValues,
|
|
440
|
+
text: labelValuesForSeries,
|
|
441
|
+
name: entry.name,
|
|
442
|
+
hovertemplate: "%{text}<br>X: %{x}<br>Y: %{y}<extra></extra>",
|
|
443
|
+
line: {
|
|
444
|
+
shape: lineShapeFor(scatterStyle),
|
|
445
|
+
color: seriesColor,
|
|
446
|
+
width: 2,
|
|
447
|
+
},
|
|
448
|
+
...(noMarkers
|
|
449
|
+
? {}
|
|
450
|
+
: {
|
|
451
|
+
marker: {
|
|
452
|
+
size: markerSize,
|
|
453
|
+
color: markerColor,
|
|
454
|
+
symbol: mapMarkerSymbolToPlotly(symbol),
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// Bubble — third dimension scaled into marker diameter.
|
|
460
|
+
const rawSizes = entry.points.map((point) => point.value[2]);
|
|
461
|
+
const scaledSizes = rawSizes.map((value) => sizeScale(value));
|
|
462
|
+
return {
|
|
463
|
+
type: "scatter",
|
|
464
|
+
mode: "markers",
|
|
465
|
+
x: xValues,
|
|
466
|
+
y: yValues,
|
|
467
|
+
text: labelValuesForSeries,
|
|
468
|
+
customdata: rawSizes,
|
|
469
|
+
name: entry.name,
|
|
470
|
+
hovertemplate: "%{text}<br>X: %{x}<br>Y: %{y}<br>Size: %{customdata}<extra></extra>",
|
|
471
|
+
marker: {
|
|
472
|
+
size: scaledSizes,
|
|
473
|
+
sizemode: "diameter",
|
|
474
|
+
color: seriesColor,
|
|
475
|
+
opacity: 0.7,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
// Resolve text formats: chart-authored values (via OOXML <c:txPr>)
|
|
480
|
+
// override the theme-derived defaults. `spec.titleTextFormat`,
|
|
481
|
+
// `spec.categoryAxisOptions.textFormat`, etc. all land here.
|
|
482
|
+
const titleFmt = spec.titleTextFormat;
|
|
483
|
+
const xAxisFmt = spec.categoryAxisOptions?.textFormat;
|
|
484
|
+
const yAxisFmt = spec.valueAxisOptions?.textFormat;
|
|
485
|
+
const legendFmt = spec.legend?.textFormat;
|
|
486
|
+
const bubbleLayout = {
|
|
487
|
+
title: {
|
|
488
|
+
text: title ?? undefined,
|
|
489
|
+
font: {
|
|
490
|
+
family: titleFmt?.fontFamily ?? "Untitled Sans, apple-system, sans-serif",
|
|
491
|
+
size: titleFmt?.fontSize ?? 16,
|
|
492
|
+
color: titleFmt?.color ?? textColor,
|
|
493
|
+
},
|
|
494
|
+
x: 0.02,
|
|
495
|
+
xanchor: "left",
|
|
496
|
+
},
|
|
497
|
+
xaxis: {
|
|
498
|
+
title: {
|
|
499
|
+
text: spec.horizontalAxisTitle ?? "",
|
|
500
|
+
font: { color: xAxisFmt?.color ?? textColor },
|
|
501
|
+
},
|
|
502
|
+
gridcolor: gridColor,
|
|
503
|
+
tickfont: {
|
|
504
|
+
color: xAxisFmt?.color ?? textColor,
|
|
505
|
+
...(xAxisFmt?.fontFamily ? { family: xAxisFmt.fontFamily } : {}),
|
|
506
|
+
...(xAxisFmt?.fontSize ? { size: xAxisFmt.fontSize } : {}),
|
|
507
|
+
},
|
|
508
|
+
type: hasStringX ? "category" : "linear",
|
|
509
|
+
},
|
|
510
|
+
yaxis: {
|
|
511
|
+
title: {
|
|
512
|
+
text: spec.verticalAxisTitle ?? "",
|
|
513
|
+
font: { color: yAxisFmt?.color ?? textColor },
|
|
514
|
+
},
|
|
515
|
+
gridcolor: gridColor,
|
|
516
|
+
tickfont: {
|
|
517
|
+
color: yAxisFmt?.color ?? textColor,
|
|
518
|
+
...(yAxisFmt?.fontFamily ? { family: yAxisFmt.fontFamily } : {}),
|
|
519
|
+
...(yAxisFmt?.fontSize ? { size: yAxisFmt.fontSize } : {}),
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
legend: {
|
|
523
|
+
font: {
|
|
524
|
+
color: legendFmt?.color ?? textColor,
|
|
525
|
+
...(legendFmt?.fontFamily ? { family: legendFmt.fontFamily } : {}),
|
|
526
|
+
...(legendFmt?.fontSize ? { size: legendFmt.fontSize } : {}),
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
paper_bgcolor: bgColor,
|
|
530
|
+
plot_bgcolor: bgColor,
|
|
531
|
+
showlegend: preparedSeries.length > 1,
|
|
532
|
+
margin: { l: 60, r: 20, t: 50, b: 50 },
|
|
533
|
+
};
|
|
534
|
+
setPlotData(traces);
|
|
535
|
+
setLayout(bubbleLayout);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (isHierarchy) {
|
|
539
|
+
const hierarchyRows = extractHierarchyRows(spec, {
|
|
540
|
+
getDomainValuesFromRange,
|
|
541
|
+
getSeriesValuesFromRange,
|
|
542
|
+
hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
|
|
543
|
+
});
|
|
544
|
+
const headerTitle = getHierarchyHeaderTitle(spec, {
|
|
545
|
+
getDomainValuesFromRange,
|
|
546
|
+
getSeriesValuesFromRange,
|
|
547
|
+
hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
|
|
548
|
+
});
|
|
549
|
+
hierarchyTitleRef.current = explicitChartTitle ?? headerTitle;
|
|
550
|
+
const hierarchyData = toPlotlyHierarchy(hierarchyRows);
|
|
551
|
+
const seriesName = hierarchyData.labels.length === 1
|
|
552
|
+
? hierarchyData.labels[0] ?? ""
|
|
553
|
+
: explicitChartTitle ?? headerTitle ?? "";
|
|
554
|
+
const hierarchyTrace = {
|
|
555
|
+
type: chartType,
|
|
556
|
+
labels: hierarchyData.labels,
|
|
557
|
+
parents: hierarchyData.parents,
|
|
558
|
+
values: hierarchyData.values,
|
|
559
|
+
name: seriesName,
|
|
560
|
+
textinfo: "label+value",
|
|
561
|
+
branchvalues: "total",
|
|
562
|
+
};
|
|
563
|
+
const layoutForHierarchy = {
|
|
564
|
+
title: {
|
|
565
|
+
text: title ?? undefined,
|
|
566
|
+
font: {
|
|
567
|
+
family: "Untitled Sans, apple-system, sans-serif",
|
|
568
|
+
size: 16,
|
|
569
|
+
color: textColor,
|
|
570
|
+
},
|
|
571
|
+
x: 0.02,
|
|
572
|
+
xanchor: "left",
|
|
573
|
+
},
|
|
574
|
+
font: {
|
|
575
|
+
family: "Untitled Sans, apple-system, sans-serif",
|
|
576
|
+
size: 12,
|
|
577
|
+
color: textColor,
|
|
578
|
+
},
|
|
579
|
+
paper_bgcolor: bgColor,
|
|
580
|
+
plot_bgcolor: bgColor,
|
|
581
|
+
showlegend: false,
|
|
582
|
+
margin: { l: 20, r: 20, t: 50, b: 20 },
|
|
583
|
+
};
|
|
584
|
+
setPlotData([hierarchyTrace]);
|
|
585
|
+
setLayout(layoutForHierarchy);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
hierarchyTitleRef.current = explicitChartTitle;
|
|
589
|
+
if (!isBasicChartSpec(spec)) {
|
|
590
|
+
debug("Unsupported chart type", spec.chartType);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// ------------------------- Region map -------------------------
|
|
594
|
+
// Filled map / choropleth (`chartType: "regionMap"`, or a payload
|
|
595
|
+
// degraded to another basic type carrying `sourceChartType:
|
|
596
|
+
// "REGION_MAP"`). Plotly ships its own world geometry, so this maps
|
|
597
|
+
// straight onto the built-in `choropleth` trace with
|
|
598
|
+
// `locationmode: "country names"` — plotly does its own (lenient)
|
|
599
|
+
// country-name matching and silently skips labels it can't place.
|
|
600
|
+
if (isRegionMapSpec(spec)) {
|
|
601
|
+
const labels = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? "").trim());
|
|
602
|
+
const seriesEntry = spec.series?.[0];
|
|
603
|
+
const rawValues = seriesEntry
|
|
604
|
+
? flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)))
|
|
605
|
+
: [];
|
|
606
|
+
const locations = [];
|
|
607
|
+
const z = [];
|
|
608
|
+
labels.forEach((label, i) => {
|
|
609
|
+
const value = rawValues[i];
|
|
610
|
+
if (label && typeof value === "number" && Number.isFinite(value)) {
|
|
611
|
+
locations.push(label);
|
|
612
|
+
z.push(value);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
// Sequential single-hue ramp from the theme accent (same helper
|
|
616
|
+
// the echarts renderer uses), expressed as a plotly colorscale.
|
|
617
|
+
const ramp = buildSequentialColorRamp(theme.themeColors.accent1, !!isDarkMode);
|
|
618
|
+
const colorscale = ramp.map((color, i) => [i / (ramp.length - 1), color]);
|
|
619
|
+
const geoScope = resolveGeoScope(spec.geoScope);
|
|
620
|
+
const regionTrace = {
|
|
621
|
+
type: "choropleth",
|
|
622
|
+
locations,
|
|
623
|
+
z,
|
|
624
|
+
locationmode: "country names",
|
|
625
|
+
colorscale,
|
|
626
|
+
marker: {
|
|
627
|
+
line: {
|
|
628
|
+
color: isDarkMode ? "rgba(255,255,255,0.3)" : "#FFFFFF",
|
|
629
|
+
width: 0.5,
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
colorbar: {
|
|
633
|
+
thickness: 10,
|
|
634
|
+
outlinewidth: 0,
|
|
635
|
+
tickfont: { color: textColor, size: 12 },
|
|
636
|
+
},
|
|
637
|
+
hovertemplate: "%{location}: %{z}<extra></extra>",
|
|
638
|
+
};
|
|
639
|
+
setPlotData([regionTrace]);
|
|
640
|
+
setLayout({
|
|
641
|
+
title: { text: title ?? undefined },
|
|
642
|
+
font: { color: textColor },
|
|
643
|
+
paper_bgcolor: bgColor,
|
|
644
|
+
plot_bgcolor: bgColor,
|
|
645
|
+
showlegend: false,
|
|
646
|
+
geo: {
|
|
647
|
+
scope: (PLOTLY_GEO_SCOPES.has(geoScope)
|
|
648
|
+
? geoScope
|
|
649
|
+
: WORLD_GEO_SCOPE),
|
|
650
|
+
bgcolor: bgColor,
|
|
651
|
+
showframe: false,
|
|
652
|
+
showcoastlines: false,
|
|
653
|
+
// Regions without data keep a neutral land fill so "no data"
|
|
654
|
+
// reads differently from "small value".
|
|
655
|
+
showland: true,
|
|
656
|
+
landcolor: isDarkMode
|
|
657
|
+
? "rgba(255,255,255,0.08)"
|
|
658
|
+
: "rgba(0,0,0,0.06)",
|
|
659
|
+
},
|
|
660
|
+
margin: { l: 20, r: 20, t: 50, b: 20 },
|
|
661
|
+
autosize: true,
|
|
662
|
+
});
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// ---------------------------- Radar ----------------------------
|
|
666
|
+
// Plotly renders radar via `scatterpolar` traces: r = the values,
|
|
667
|
+
// theta = the category names (closed by appending the first point).
|
|
668
|
+
if (spec.chartType === "radar") {
|
|
669
|
+
const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? ""));
|
|
670
|
+
const radarStyle = spec.radarStyle ?? "standard";
|
|
671
|
+
const radarTraces = [];
|
|
672
|
+
const radarInputs = [];
|
|
673
|
+
for (const seriesEntry of spec.series ?? []) {
|
|
674
|
+
const label = seriesEntry.dataLabel && !isAFormula(seriesEntry.dataLabel)
|
|
675
|
+
? seriesEntry.dataLabel
|
|
676
|
+
: "";
|
|
677
|
+
const values = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)));
|
|
678
|
+
radarInputs.push({ name: label, values });
|
|
679
|
+
}
|
|
680
|
+
// buildRadarIndicators keeps category/value alignment consistent
|
|
681
|
+
// with the echarts renderer (and validates we have axes).
|
|
682
|
+
const indicators = buildRadarIndicators(categories, radarInputs);
|
|
683
|
+
for (let i = 0; i < radarInputs.length; i++) {
|
|
684
|
+
const input = radarInputs[i];
|
|
685
|
+
const seriesColor = spec.series?.[i]?.color ?? undefined;
|
|
686
|
+
const r = indicators.map((_, idx) => input.values[idx] ?? 0);
|
|
687
|
+
const theta = indicators.map((ind) => ind.name);
|
|
688
|
+
radarTraces.push({
|
|
689
|
+
type: "scatterpolar",
|
|
690
|
+
r: r.length ? [...r, r[0]] : r,
|
|
691
|
+
theta: theta.length ? [...theta, theta[0]] : theta,
|
|
692
|
+
name: input.name,
|
|
693
|
+
mode: radarStyle === "standard" ? "lines" : "lines+markers",
|
|
694
|
+
...(radarStyle === "filled" ? { fill: "toself" } : {}),
|
|
695
|
+
...(seriesColor ? { line: { color: seriesColor } } : {}),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const maxR = indicators.reduce((m, ind) => Math.max(m, ind.max), 0);
|
|
699
|
+
setPlotData(radarTraces);
|
|
700
|
+
setLayout({
|
|
701
|
+
title: { text: title ?? undefined },
|
|
702
|
+
font: { color: textColor },
|
|
703
|
+
paper_bgcolor: bgColor,
|
|
704
|
+
plot_bgcolor: bgColor,
|
|
705
|
+
showlegend: radarTraces.length > 1,
|
|
706
|
+
polar: {
|
|
707
|
+
radialaxis: {
|
|
708
|
+
visible: true,
|
|
709
|
+
range: [0, maxR > 0 ? maxR : 1],
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
margin: { l: 50, r: 50, t: 50, b: 50 },
|
|
713
|
+
autosize: true,
|
|
714
|
+
});
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
// ---------------------------- Stock ----------------------------
|
|
718
|
+
// Line-derived stock: one scatter line trace per price role. The
|
|
719
|
+
// V-variants additionally get a volume column sub-pane below the
|
|
720
|
+
// price plot.
|
|
721
|
+
if (spec.chartType === "stock") {
|
|
722
|
+
const variant = (spec.stockVariant ?? "HLC");
|
|
723
|
+
const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? ""));
|
|
724
|
+
const seriesValues = (spec.series ?? []).map((seriesEntry) => flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v))));
|
|
725
|
+
// V-variants (VHLC / VOHLC) carry a volume series drawn as a
|
|
726
|
+
// column sub-pane under the price plot: the shared x-axis is
|
|
727
|
+
// split into two stacked y-axis domains (price ~70%, volume
|
|
728
|
+
// ~22%) and the columns are colored by up/down day to match the
|
|
729
|
+
// price plot.
|
|
730
|
+
const volumePoints = toVolumeSeries(seriesValues, variant);
|
|
731
|
+
const hasVolume = (volumePoints?.length ?? 0) > 0;
|
|
732
|
+
const buildVolumeTrace = (xs) => ({
|
|
733
|
+
type: "bar",
|
|
734
|
+
x: xs,
|
|
735
|
+
y: (volumePoints ?? []).map((p) => p.value),
|
|
736
|
+
name: "Volume",
|
|
737
|
+
yaxis: "y2",
|
|
738
|
+
showlegend: false,
|
|
739
|
+
marker: {
|
|
740
|
+
color: (volumePoints ?? []).map((p) => p.up ? theme.themeColors.accent1 : theme.themeColors.accent2),
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
const volumeLayout = hasVolume
|
|
744
|
+
? {
|
|
745
|
+
yaxis2: {
|
|
746
|
+
domain: [0, 0.22],
|
|
747
|
+
automargin: true,
|
|
748
|
+
showgrid: false,
|
|
749
|
+
showticklabels: false,
|
|
750
|
+
},
|
|
751
|
+
}
|
|
752
|
+
: {};
|
|
753
|
+
// The price y-axis keeps the top of the plot when a volume pane
|
|
754
|
+
// sits below it.
|
|
755
|
+
const priceYAxisDomain = hasVolume ? { domain: [0.3, 1] } : {};
|
|
756
|
+
// OHLC / VOHLC variants render as a native candlestick; HLC / VHLC
|
|
757
|
+
// (no open) fall through to the line-derived rendering below.
|
|
758
|
+
if (variant === "OHLC" || variant === "VOHLC") {
|
|
759
|
+
const roles = assignStockRoles(seriesValues, variant);
|
|
760
|
+
const xs = categories.length > 0
|
|
761
|
+
? categories
|
|
762
|
+
: (roles.close ?? []).map((_, i) => i + 1);
|
|
763
|
+
const candleTrace = {
|
|
764
|
+
type: "candlestick",
|
|
765
|
+
x: xs,
|
|
766
|
+
open: (roles.open ?? []).map((v) => v ?? null),
|
|
767
|
+
high: (roles.high ?? []).map((v) => v ?? null),
|
|
768
|
+
low: (roles.low ?? []).map((v) => v ?? null),
|
|
769
|
+
close: (roles.close ?? []).map((v) => v ?? null),
|
|
770
|
+
increasing: { line: { color: theme.themeColors.accent1 } },
|
|
771
|
+
decreasing: { line: { color: theme.themeColors.accent2 } },
|
|
772
|
+
};
|
|
773
|
+
setPlotData(hasVolume ? [candleTrace, buildVolumeTrace(xs)] : [candleTrace]);
|
|
774
|
+
setLayout({
|
|
775
|
+
title: { text: title ?? undefined },
|
|
776
|
+
font: { color: textColor },
|
|
777
|
+
paper_bgcolor: bgColor,
|
|
778
|
+
plot_bgcolor: bgColor,
|
|
779
|
+
showlegend: false,
|
|
780
|
+
xaxis: {
|
|
781
|
+
title: { text: spec.horizontalAxisTitle ?? "" },
|
|
782
|
+
automargin: true,
|
|
783
|
+
showgrid: true,
|
|
784
|
+
gridcolor: gridColor,
|
|
785
|
+
rangeslider: { visible: false },
|
|
786
|
+
},
|
|
787
|
+
yaxis: {
|
|
788
|
+
title: { text: spec.verticalAxisTitle ?? "" },
|
|
789
|
+
automargin: true,
|
|
790
|
+
showgrid: true,
|
|
791
|
+
gridcolor: gridColor,
|
|
792
|
+
...priceYAxisDomain,
|
|
793
|
+
},
|
|
794
|
+
...volumeLayout,
|
|
795
|
+
margin: { l: 50, r: 50, t: 50, b: 50 },
|
|
796
|
+
autosize: true,
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const lineSeries = toLineSeries(seriesValues, variant);
|
|
801
|
+
const stockPalette = [
|
|
802
|
+
theme.themeColors.accent1,
|
|
803
|
+
theme.themeColors.accent2,
|
|
804
|
+
theme.themeColors.accent3,
|
|
805
|
+
theme.themeColors.accent4,
|
|
806
|
+
];
|
|
807
|
+
const xs = categories.length > 0
|
|
808
|
+
? categories
|
|
809
|
+
: (lineSeries[0]?.values ?? []).map((_, i) => i + 1);
|
|
810
|
+
const stockTraces = lineSeries.map((s, i) => ({
|
|
811
|
+
type: "scatter",
|
|
812
|
+
mode: "lines",
|
|
813
|
+
x: xs,
|
|
814
|
+
y: s.values.map((v) => (v ?? null)),
|
|
815
|
+
name: s.name,
|
|
816
|
+
line: { color: stockPalette[i % stockPalette.length] },
|
|
817
|
+
}));
|
|
818
|
+
setPlotData(hasVolume ? [...stockTraces, buildVolumeTrace(xs)] : stockTraces);
|
|
819
|
+
setLayout({
|
|
820
|
+
title: { text: title ?? undefined },
|
|
821
|
+
font: { color: textColor },
|
|
822
|
+
paper_bgcolor: bgColor,
|
|
823
|
+
plot_bgcolor: bgColor,
|
|
824
|
+
showlegend: stockTraces.length > 1,
|
|
825
|
+
xaxis: {
|
|
826
|
+
title: { text: spec.horizontalAxisTitle ?? "" },
|
|
827
|
+
automargin: true,
|
|
828
|
+
showgrid: true,
|
|
829
|
+
gridcolor: gridColor,
|
|
830
|
+
},
|
|
831
|
+
yaxis: {
|
|
832
|
+
title: { text: spec.verticalAxisTitle ?? "" },
|
|
833
|
+
automargin: true,
|
|
834
|
+
showgrid: true,
|
|
835
|
+
gridcolor: gridColor,
|
|
836
|
+
...priceYAxisDomain,
|
|
837
|
+
},
|
|
838
|
+
...volumeLayout,
|
|
839
|
+
margin: { l: 50, r: 50, t: 50, b: 50 },
|
|
840
|
+
autosize: true,
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const isStacked = spec.stackedType && spec.stackedType !== "UNSTACKED";
|
|
845
|
+
const isHorizontalBarChart = spec.chartType === "bar";
|
|
846
|
+
// Get domain data (x-axis)
|
|
847
|
+
const shouldAggregate = shouldAggregateSeries(spec.series);
|
|
848
|
+
const primaryDomainSource = spec?.domains?.[0]?.sources?.[0];
|
|
849
|
+
const rawPrimaryDomainValues = primaryDomainSource
|
|
850
|
+
? sanitizeDomainValues(getDomainValuesFromRange(primaryDomainSource, spec.hiddenDimensionStrategy) ?? [])
|
|
851
|
+
: [];
|
|
852
|
+
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
|
+
});
|
|
864
|
+
const assignCartesianData = (categories, values) => {
|
|
865
|
+
// Convert undefined to null for Plotly (Datum type accepts null but not undefined)
|
|
866
|
+
const plotlyValues = values.map((v) => v ?? null);
|
|
867
|
+
// When categories is empty but we have values, generate index-based x values
|
|
868
|
+
// 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;
|
|
872
|
+
if (isHorizontalBarChart) {
|
|
873
|
+
return {
|
|
874
|
+
x: plotlyValues,
|
|
875
|
+
y: effectiveCategories,
|
|
876
|
+
orientation: "h",
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return { x: effectiveCategories, y: plotlyValues, orientation: undefined };
|
|
880
|
+
};
|
|
881
|
+
// Theme color palette for fallback
|
|
882
|
+
const themeColorPalette = [
|
|
883
|
+
theme.themeColors.accent1,
|
|
884
|
+
theme.themeColors.accent2,
|
|
885
|
+
theme.themeColors.accent3,
|
|
886
|
+
theme.themeColors.accent4,
|
|
887
|
+
theme.themeColors.accent5,
|
|
888
|
+
theme.themeColors.accent6,
|
|
889
|
+
];
|
|
890
|
+
const applyTraceColor = (trace, seriesColor, options) => {
|
|
891
|
+
const target = trace;
|
|
892
|
+
const chartTypeForStyling = options?.effectiveChartType ?? spec.chartType;
|
|
893
|
+
const isBarOrColumn = chartTypeForStyling === "bar" || chartTypeForStyling === "column";
|
|
894
|
+
// Transparent series (Gantt lag offset). Set marker color to
|
|
895
|
+
// `rgba(0,0,0,0)` so the bar layout is preserved but invisible.
|
|
896
|
+
if (options?.noFill) {
|
|
897
|
+
target.marker = {
|
|
898
|
+
...(target.marker ?? {}),
|
|
899
|
+
color: "rgba(0,0,0,0)",
|
|
900
|
+
line: {
|
|
901
|
+
...(target.marker?.line ?? {}),
|
|
902
|
+
color: "rgba(0,0,0,0)",
|
|
903
|
+
width: 0,
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
return trace;
|
|
907
|
+
}
|
|
908
|
+
// Get effective color - gradient fallback wins, then series color,
|
|
909
|
+
// then theme palette. Plotly cannot paint the real gradient, so the
|
|
910
|
+
// median stop stands in for the whole ramp.
|
|
911
|
+
const effectiveColor = options?.gradientColor ??
|
|
912
|
+
seriesColor ??
|
|
913
|
+
(options?.seriesIndex !== undefined
|
|
914
|
+
? themeColorPalette[options.seriesIndex % themeColorPalette.length]
|
|
915
|
+
: undefined);
|
|
916
|
+
// Per-data-point color overrides — Gantt charts use `<c:dPt>` to
|
|
917
|
+
// give every task its own color. Build a per-point color array
|
|
918
|
+
// by category index, falling back to the series color for points
|
|
919
|
+
// without an override.
|
|
920
|
+
if (isBarOrColumn &&
|
|
921
|
+
Array.isArray(options?.dataPointColors) &&
|
|
922
|
+
options?.dataPointColors.length) {
|
|
923
|
+
const overrides = new Map();
|
|
924
|
+
for (const dp of options.dataPointColors) {
|
|
925
|
+
if (typeof dp.idx === "number" && typeof dp.color === "string") {
|
|
926
|
+
overrides.set(dp.idx, dp.color);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
const fallback = effectiveColor ?? "#4285F4";
|
|
930
|
+
const yArray = Array.isArray(target.y)
|
|
931
|
+
? target.y
|
|
932
|
+
: Array.isArray(target.x)
|
|
933
|
+
? target.x
|
|
934
|
+
: [];
|
|
935
|
+
const colors = yArray.map((_, i) => overrides.get(i) ?? fallback);
|
|
936
|
+
target.marker = { ...(target.marker ?? {}), color: colors };
|
|
937
|
+
return trace;
|
|
938
|
+
}
|
|
939
|
+
// Handle invertIfNegative for bar/column charts
|
|
940
|
+
if (isBarOrColumn && options?.invertIfNegative && options?.values) {
|
|
941
|
+
const positiveColor = effectiveColor ?? "#4285F4";
|
|
942
|
+
const negColor = options.negativeColor ?? "#FF0000";
|
|
943
|
+
// Create array of colors based on value sign
|
|
944
|
+
const colors = options.values.map((v) => typeof v === "number" && v < 0 ? negColor : positiveColor);
|
|
945
|
+
target.marker = { ...(target.marker ?? {}), color: colors };
|
|
946
|
+
return trace;
|
|
947
|
+
}
|
|
948
|
+
if (!effectiveColor) {
|
|
949
|
+
return trace;
|
|
950
|
+
}
|
|
951
|
+
if (chartTypeForStyling === "pie") {
|
|
952
|
+
// Don't clobber the distinct per-slice palette the trace was built
|
|
953
|
+
// with (accent1..accent6) with a single repeated color. Only repeat
|
|
954
|
+
// a uniform color when the user explicitly authored a series color;
|
|
955
|
+
// otherwise keep the multi-color palette so each slice stays distinct.
|
|
956
|
+
const existingColors = target.marker?.colors;
|
|
957
|
+
if (!seriesColor &&
|
|
958
|
+
Array.isArray(existingColors) &&
|
|
959
|
+
existingColors.length > 1) {
|
|
960
|
+
return trace;
|
|
961
|
+
}
|
|
962
|
+
const labelCount = Array.isArray(target.labels)
|
|
963
|
+
? target.labels.length
|
|
964
|
+
: Array.isArray(target.y)
|
|
965
|
+
? target.y.length
|
|
966
|
+
: 0;
|
|
967
|
+
target.marker = {
|
|
968
|
+
...(target.marker ?? {}),
|
|
969
|
+
colors: labelCount > 0
|
|
970
|
+
? Array.from({ length: labelCount }, () => effectiveColor)
|
|
971
|
+
: target.marker?.colors,
|
|
972
|
+
color: effectiveColor,
|
|
973
|
+
};
|
|
974
|
+
return trace;
|
|
975
|
+
}
|
|
976
|
+
if (isBarOrColumn) {
|
|
977
|
+
target.marker = { ...(target.marker ?? {}), color: effectiveColor };
|
|
978
|
+
return trace;
|
|
979
|
+
}
|
|
980
|
+
target.marker = { ...(target.marker ?? {}), color: effectiveColor };
|
|
981
|
+
target.line = { ...(target.line ?? {}), color: effectiveColor };
|
|
982
|
+
if (target.fill && target.fill !== "none") {
|
|
983
|
+
// Add transparency to area fills so they don't cover other traces
|
|
984
|
+
target.fillcolor = addAlphaToColor(effectiveColor, 0.5);
|
|
985
|
+
}
|
|
986
|
+
return trace;
|
|
987
|
+
};
|
|
988
|
+
// Track whether any series opts into the secondary value axis; the
|
|
989
|
+
// yaxis2 layout block is added below when true.
|
|
990
|
+
let hasSecondaryAxis = false;
|
|
991
|
+
// Pareto — a histogram whose bins are sorted descending with a
|
|
992
|
+
// cumulative-% line overlaid on a secondary 0-100% axis. Arrives
|
|
993
|
+
// as a plain histogram spec carrying `sourceChartType: "PARETO"`.
|
|
994
|
+
const isPareto = spec.chartType === "histogram" && isParetoSpec(spec);
|
|
995
|
+
// Pie-of-pie / bar-of-pie composite. The pie branch below fills
|
|
996
|
+
// `ofPieExtras` with connector-line shapes and (for bar-of-pie) the
|
|
997
|
+
// hidden secondary axes + stack mode merged into the layout later.
|
|
998
|
+
const isOfPie = spec.chartType === "pie" && isOfPieChartSpec(spec);
|
|
999
|
+
let ofPieHandled = false;
|
|
1000
|
+
let ofPieExtras = null;
|
|
1001
|
+
/**
|
|
1002
|
+
* Attach cartesian-only features to a freshly-built line/bar/area
|
|
1003
|
+
* trace: native error bars, secondary-axis assignment, and trendline
|
|
1004
|
+
* overlay traces (computed by the shared utils/trendline regression).
|
|
1005
|
+
*/
|
|
1006
|
+
const decorateCartesianTrace = (trace, series, values, dataLabel) => {
|
|
1007
|
+
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"`.
|
|
1010
|
+
if (series.useSecondaryAxis) {
|
|
1011
|
+
target.yaxis = "y2";
|
|
1012
|
+
hasSecondaryAxis = true;
|
|
1013
|
+
}
|
|
1014
|
+
// Error bars — native plotly `error_y` / `error_x`. valueType
|
|
1015
|
+
// "custom" reads per-point magnitudes from the spec's plus/minus
|
|
1016
|
+
// ranges, resolved through the same extractor the series values
|
|
1017
|
+
// use.
|
|
1018
|
+
const magnitudes = computeErrorBars(values, resolveCustomErrorBarValues(series.errorBars, (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy)));
|
|
1019
|
+
if (magnitudes) {
|
|
1020
|
+
const direction = series.errorBars?.direction ?? "y";
|
|
1021
|
+
const noEndCap = series.errorBars?.noEndCap ?? false;
|
|
1022
|
+
const symmetric = magnitudes.plus.every((p, i) => p === magnitudes.minus[i]);
|
|
1023
|
+
const errorConfig = {
|
|
1024
|
+
type: "data",
|
|
1025
|
+
symmetric,
|
|
1026
|
+
array: magnitudes.plus,
|
|
1027
|
+
...(symmetric ? {} : { arrayminus: magnitudes.minus }),
|
|
1028
|
+
color: series.color ?? undefined,
|
|
1029
|
+
...(noEndCap ? { width: 0 } : {}),
|
|
1030
|
+
};
|
|
1031
|
+
if (direction === "x") {
|
|
1032
|
+
target.error_x = errorConfig;
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
target.error_y = errorConfig;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// Trendlines — one overlay scatter trace per spec, reusing the
|
|
1039
|
+
// shared regression util.
|
|
1040
|
+
const seriesColor = series.color ?? theme.themeColors.accent1;
|
|
1041
|
+
for (const tl of series.trendlines ?? []) {
|
|
1042
|
+
const result = computeTrendline(values, {
|
|
1043
|
+
type: tl.type,
|
|
1044
|
+
order: tl.order,
|
|
1045
|
+
period: tl.period,
|
|
1046
|
+
forecastForward: tl.forecastForward,
|
|
1047
|
+
forecastBackward: tl.forecastBackward,
|
|
1048
|
+
});
|
|
1049
|
+
if (!result || result.points.length < 2)
|
|
1050
|
+
continue;
|
|
1051
|
+
const tlName = tl.name ??
|
|
1052
|
+
`${dataLabel ? dataLabel + " " : ""}${tl.type ?? "linear"} trend`;
|
|
1053
|
+
// Map the fitted x (category index) back onto the category
|
|
1054
|
+
// 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);
|
|
1056
|
+
const ys = result.points.map((p) => p.y);
|
|
1057
|
+
const overlay = {
|
|
1058
|
+
type: "scatter",
|
|
1059
|
+
mode: "lines",
|
|
1060
|
+
name: tlName,
|
|
1061
|
+
line: {
|
|
1062
|
+
color: tl.color ?? seriesColor,
|
|
1063
|
+
dash: "dash",
|
|
1064
|
+
width: 2,
|
|
1065
|
+
},
|
|
1066
|
+
hoverinfo: "skip",
|
|
1067
|
+
...(series.useSecondaryAxis ? { yaxis: "y2" } : {}),
|
|
1068
|
+
};
|
|
1069
|
+
if (isHorizontalBarChart) {
|
|
1070
|
+
overlay.x = ys;
|
|
1071
|
+
overlay.y = xs;
|
|
1072
|
+
overlay.orientation = "h";
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
overlay.x = xs;
|
|
1076
|
+
overlay.y = ys;
|
|
1077
|
+
}
|
|
1078
|
+
traces.push(overlay);
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
// Create traces for each series
|
|
1082
|
+
let seriesIndex = 0;
|
|
1083
|
+
for (const series of spec.series ?? []) {
|
|
1084
|
+
const currentSeriesIndex = seriesIndex++;
|
|
1085
|
+
const dataLabel = !isNil(series.dataLabel) && isAFormula(series.dataLabel)
|
|
1086
|
+
? coerceCalculatedLabel(await onRequestCalculate(series.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
|
|
1087
|
+
: series.dataLabel ?? "";
|
|
1088
|
+
for (const sheetRange of series.sources) {
|
|
1089
|
+
const seriesData = getSeriesData(spec, sheetRange, {
|
|
1090
|
+
aggregateEnabled: shouldAggregate,
|
|
1091
|
+
aggregateType: series.aggregateType,
|
|
1092
|
+
rawDomainValues: rawPrimaryDomainValues,
|
|
1093
|
+
orderedDomainValues: domainData,
|
|
1094
|
+
});
|
|
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"),
|
|
1233
|
+
name: dataLabel,
|
|
1234
|
+
};
|
|
1235
|
+
applyTraceColor(trace, series.color ?? undefined, {
|
|
1236
|
+
noFill: series.noFill,
|
|
1237
|
+
dataPointColors: series.dataPointColors,
|
|
1238
|
+
seriesIndex: currentSeriesIndex,
|
|
1239
|
+
effectiveChartType,
|
|
1240
|
+
});
|
|
1241
|
+
traces.push(trace);
|
|
1242
|
+
}
|
|
1243
|
+
else if (effectiveChartType === "funnel") {
|
|
1244
|
+
const rawValues = "values" in seriesData
|
|
1245
|
+
? seriesData.values
|
|
1246
|
+
: seriesData;
|
|
1247
|
+
const trace = {
|
|
1248
|
+
type: "funnel",
|
|
1249
|
+
y: domainData,
|
|
1250
|
+
x: rawValues.map((v) => (v ?? null)),
|
|
1251
|
+
name: dataLabel,
|
|
1252
|
+
};
|
|
1253
|
+
applyTraceColor(trace, series.color ?? undefined, {
|
|
1254
|
+
noFill: series.noFill,
|
|
1255
|
+
dataPointColors: series.dataPointColors,
|
|
1256
|
+
seriesIndex: currentSeriesIndex,
|
|
1257
|
+
effectiveChartType,
|
|
1258
|
+
});
|
|
1259
|
+
traces.push(trace);
|
|
1260
|
+
}
|
|
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)) },
|
|
1302
|
+
});
|
|
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,
|
|
1405
|
+
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
|
+
},
|
|
1416
|
+
};
|
|
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
|
+
}
|
|
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,
|
|
1432
|
+
name: dataLabel,
|
|
1433
|
+
stackgroup: isStacked ? "one" : undefined,
|
|
1434
|
+
...seriesTraceStyle,
|
|
1435
|
+
};
|
|
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
|
+
}
|
|
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,
|
|
1460
|
+
};
|
|
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
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
// Authored title formatting (parsed from <c:title><c:txPr> or
|
|
1477
|
+
// <c:title><c:tx><c:rich>) overrides the theme-derived default.
|
|
1478
|
+
const authoredTitleFmt = spec.titleTextFormat;
|
|
1479
|
+
const newLayout = {
|
|
1480
|
+
title: {
|
|
1481
|
+
text: title ?? undefined,
|
|
1482
|
+
font: applyTextFormatToFont({
|
|
1483
|
+
family: "Untitled Sans, apple-system, sans-serif",
|
|
1484
|
+
size: 16,
|
|
1485
|
+
color: textColor,
|
|
1486
|
+
}, authoredTitleFmt),
|
|
1487
|
+
x: 0.02, // Small left margin (0.02 = 2% from left edge)
|
|
1488
|
+
xanchor: "left",
|
|
1489
|
+
},
|
|
1490
|
+
font: {
|
|
1491
|
+
family: "Untitled Sans, apple-system, sans-serif",
|
|
1492
|
+
size: 12,
|
|
1493
|
+
color: textColor,
|
|
1494
|
+
},
|
|
1495
|
+
paper_bgcolor: bgColor,
|
|
1496
|
+
plot_bgcolor: bgColor,
|
|
1497
|
+
xaxis: applyAxisOverridesPlotly({
|
|
1498
|
+
title: {
|
|
1499
|
+
text: spec.horizontalAxisTitle ?? "",
|
|
1500
|
+
font: { color: textColor },
|
|
1501
|
+
},
|
|
1502
|
+
automargin: true,
|
|
1503
|
+
showgrid: true,
|
|
1504
|
+
gridcolor: gridColor,
|
|
1505
|
+
visible: showXAxis(spec?.chartType),
|
|
1506
|
+
type: isHorizontalBarChart ? "linear" : undefined,
|
|
1507
|
+
},
|
|
1508
|
+
// For horizontal bar, OOXML's value axis is the X axis.
|
|
1509
|
+
isHorizontalBarChart
|
|
1510
|
+
? spec.valueAxisOptions
|
|
1511
|
+
: spec.categoryAxisOptions),
|
|
1512
|
+
yaxis: applyAxisOverridesPlotly({
|
|
1513
|
+
title: {
|
|
1514
|
+
text: spec.verticalAxisTitle ?? "",
|
|
1515
|
+
font: { color: textColor },
|
|
1516
|
+
},
|
|
1517
|
+
showgrid: !isHorizontalBarChart,
|
|
1518
|
+
gridcolor: gridColor,
|
|
1519
|
+
automargin: true,
|
|
1520
|
+
type: isHorizontalBarChart ? "category" : undefined,
|
|
1521
|
+
}, isHorizontalBarChart
|
|
1522
|
+
? 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".
|
|
1526
|
+
...(hasSecondaryAxis
|
|
1527
|
+
? {
|
|
1528
|
+
yaxis2: applyAxisOverridesPlotly({
|
|
1529
|
+
title: { font: { color: textColor } },
|
|
1530
|
+
overlaying: "y",
|
|
1531
|
+
side: "right",
|
|
1532
|
+
showgrid: false,
|
|
1533
|
+
automargin: true,
|
|
1534
|
+
// Pareto — the cumulative-% axis is clamped 0-100%.
|
|
1535
|
+
...(isPareto
|
|
1536
|
+
? { range: [0, 100], ticksuffix: "%" }
|
|
1537
|
+
: {}),
|
|
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
|
+
: {}),
|
|
1568
|
+
}
|
|
1569
|
+
: {}),
|
|
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
|
|
1579
|
+
? {
|
|
1580
|
+
shapes: ofPieExtras.shapes,
|
|
1581
|
+
...(ofPieExtras.barmode ? { barmode: ofPieExtras.barmode } : {}),
|
|
1582
|
+
...(ofPieExtras.xaxis2 ? { xaxis2: ofPieExtras.xaxis2 } : {}),
|
|
1583
|
+
...(ofPieExtras.yaxis2 ? { yaxis2: ofPieExtras.yaxis2 } : {}),
|
|
1584
|
+
}
|
|
1585
|
+
: {}),
|
|
1586
|
+
};
|
|
1587
|
+
// Phase 5.1: apply data labels per trace, and set the doughnut hole
|
|
1588
|
+
// when chartType === "doughnut".
|
|
1589
|
+
const dlOpts = spec.dataLabels;
|
|
1590
|
+
const doughnutHole = spec.doughnutHoleSize;
|
|
1591
|
+
const formattedTraces = traces.map((trace) => {
|
|
1592
|
+
let next = applyDataLabelsToTracePlotly(trace, dlOpts, spec.chartType);
|
|
1593
|
+
// Doughnut is a pie trace with `hole` set.
|
|
1594
|
+
if (spec.chartType === "doughnut" && next.type === "pie") {
|
|
1595
|
+
next = {
|
|
1596
|
+
...next,
|
|
1597
|
+
hole: typeof doughnutHole === "number" ? doughnutHole : 0.5,
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
return next;
|
|
1601
|
+
});
|
|
1602
|
+
// Sort traces so area charts (with fill) render first (behind other traces)
|
|
1603
|
+
const sortedTraces = [...formattedTraces].sort((a, b) => {
|
|
1604
|
+
const aHasFill = a.fill && a.fill !== "none";
|
|
1605
|
+
const bHasFill = b.fill && b.fill !== "none";
|
|
1606
|
+
if (aHasFill && !bHasFill)
|
|
1607
|
+
return -1;
|
|
1608
|
+
if (!aHasFill && bHasFill)
|
|
1609
|
+
return 1;
|
|
1610
|
+
return 0;
|
|
1611
|
+
});
|
|
1612
|
+
setPlotData(sortedTraces);
|
|
1613
|
+
setLayout(newLayout);
|
|
1614
|
+
};
|
|
1615
|
+
// Subscribe to datarange
|
|
1616
|
+
useSubscribeToSheet(chart.chartId, chart.spec.dataRange, updateChart);
|
|
1617
|
+
// Update chart on spec changes
|
|
1618
|
+
useIsomorphicLayoutEffect(() => {
|
|
1619
|
+
updateChart();
|
|
1620
|
+
}, [chart.spec, getDomainValuesFromRange, getSeriesValuesFromRange]);
|
|
1621
|
+
// Update theme
|
|
1622
|
+
useIsomorphicLayoutEffect(() => {
|
|
1623
|
+
updateChart();
|
|
1624
|
+
}, [theme, title, isDarkMode]);
|
|
1625
|
+
// Resize chart on width/height changes
|
|
1626
|
+
useIsomorphicLayoutEffect(() => {
|
|
1627
|
+
setLayout((prev) => ({
|
|
1628
|
+
...prev,
|
|
1629
|
+
width: width - 2,
|
|
1630
|
+
height: height - 2,
|
|
1631
|
+
}));
|
|
1632
|
+
// Of-pie composites derive their connector-line shapes and
|
|
1633
|
+
// secondary-plot domains from the container's pixel aspect ratio —
|
|
1634
|
+
// rebuild them for the new size.
|
|
1635
|
+
if (spec.chartType === "pie" && isOfPieChartSpec(spec)) {
|
|
1636
|
+
updateChart();
|
|
1637
|
+
}
|
|
1638
|
+
}, [width, height]);
|
|
1639
|
+
// Chrome bug - redraw chart when visibility changes
|
|
1640
|
+
useOnVisibilityChange((state) => {
|
|
1641
|
+
if (state === "visible") {
|
|
1642
|
+
updateChart();
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
const config = {
|
|
1646
|
+
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.
|
|
1651
|
+
displayModeBar: false,
|
|
1652
|
+
displaylogo: false,
|
|
1653
|
+
staticPlot: true,
|
|
1654
|
+
};
|
|
1655
|
+
// Excel's "Rounded Corners" chart frame (`<c:roundedCorners val="1"/>`
|
|
1656
|
+
// at the chartSpace root). Default per OOXML §21.2.2.183 is square,
|
|
1657
|
+
// so only opt in when the workbook explicitly set the flag.
|
|
1658
|
+
const roundedCornersStyle = spec.roundedCorners
|
|
1659
|
+
? { borderRadius: 8, overflow: "hidden" }
|
|
1660
|
+
: undefined;
|
|
1661
|
+
return (React.createElement("div", { className: "flex flex-1", style: roundedCornersStyle },
|
|
1662
|
+
isSelected ? (React.createElement(DropdownMenu, null,
|
|
1663
|
+
React.createElement(DropdownMenuTrigger, { asChild: true },
|
|
1664
|
+
React.createElement(IconButton, { className: "absolute right-1 top-1 z-10" },
|
|
1665
|
+
React.createElement(DotsVerticalIcon, null))),
|
|
1666
|
+
React.createElement(DropdownMenuPortal, null,
|
|
1667
|
+
React.createElement(DropdownMenuContent, { align: "start", onCloseAutoFocus: (event) => {
|
|
1668
|
+
event.preventDefault();
|
|
1669
|
+
if (shouldFocusSheetRef.current) {
|
|
1670
|
+
focusSheet?.();
|
|
1671
|
+
}
|
|
1672
|
+
}, onInteractOutside: () => {
|
|
1673
|
+
shouldFocusSheetRef.current = false;
|
|
1674
|
+
} },
|
|
1675
|
+
readonly ? null : (React.createElement(DropdownMenuItem, { onClick: () => onRequestEdit?.(chart) }, "Edit chart")),
|
|
1676
|
+
readonly ? null : (React.createElement(DropdownMenuItem, { onClick: () => {
|
|
1677
|
+
shouldFocusSheetRef.current = true;
|
|
1678
|
+
onDeleteChart?.(chart.chartId);
|
|
1679
|
+
} }, "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%" } }))));
|
|
1682
|
+
};
|