@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,2343 @@
|
|
|
1
|
+
import { ChevronRightIcon, DotsVerticalIcon } from "@rowsncolumns/icons";
|
|
2
|
+
import { getStringifiedColor, useFocusSheet } from "@rowsncolumns/spreadsheet";
|
|
3
|
+
import { useSubscribeToMultipleRanges } from "@rowsncolumns/spreadsheet-state";
|
|
4
|
+
import { createDownloadableFile } from "@rowsncolumns/toolkit";
|
|
5
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, DropdownRightSlot, IconButton, useIsomorphicLayoutEffect, useOnVisibilityChange, } from "@rowsncolumns/ui";
|
|
6
|
+
import { debug, isAFormula, isNil } from "@rowsncolumns/utils";
|
|
7
|
+
import { BarChart, BoxplotChart, CandlestickChart, CustomChart, FunnelChart, LineChart, PieChart, RadarChart as EchartsRadarChart, ScatterChart, SunburstChart, TreemapChart as EchartsTreemapChart, } from "echarts/charts";
|
|
8
|
+
import { DatasetComponent, GridComponent, LegendComponent, RadarComponent, TitleComponent, TooltipComponent, } from "echarts/components";
|
|
9
|
+
import * as echarts from "echarts/core";
|
|
10
|
+
import { CanvasRenderer } from "echarts/renderers";
|
|
11
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { aggregateSeriesByDomain, getDomainValuesForAxis, sanitizeDomainValues, shouldAggregateSeries, } from "../utils/aggregation";
|
|
13
|
+
import { gapWidthToBarCategoryGap, overlapToBarGap, } from "../utils/bar-layout";
|
|
14
|
+
import { computeBoxplotSummary } from "../utils/boxplot";
|
|
15
|
+
import { createBubbleSizeScale, prepareBubbleSeriesData, } from "../utils/bubble";
|
|
16
|
+
import { coerceCalculatedLabel, getChartDataRanges, } from "../utils/chart-spec-utils";
|
|
17
|
+
import { getEchartsGlState, loadEchartsGl, } from "../utils/echarts-gl-loader";
|
|
18
|
+
import { computeErrorBars, resolveCustomErrorBarValues, } from "../utils/error-bars";
|
|
19
|
+
import { gradientToEcharts } from "../utils/gradient";
|
|
20
|
+
import { buildHierarchyTree, extractHierarchyRows, getHierarchyHeaderTitle, } from "../utils/hierarchy";
|
|
21
|
+
import { binHistogram, computePareto, isParetoSpec, } from "../utils/histogram";
|
|
22
|
+
import { clampSecondPieSize, isOfPieChartSpec, otherSliceHalfAngle, resolveOfPieType, splitOfPie, } from "../utils/of-pie";
|
|
23
|
+
import { applyAxisOverrides, applyLegendOverrides, buildSeriesLabelOption, } from "../utils/overrides";
|
|
24
|
+
import { buildRadarIndicators, buildRadarSeriesData } from "../utils/radar";
|
|
25
|
+
import { buildRegionMapData, buildSequentialColorRamp, isRegionMapSpec, resolveGeoScope, warnOnceForUnmatchedRegions, } from "../utils/region-map";
|
|
26
|
+
import { loadRegionMapGeo } from "../utils/region-map-loader";
|
|
27
|
+
import { toCandlestick, toLineSeries, toVolumeSeries, } from "../utils/stock";
|
|
28
|
+
import { buildBar3DData, buildLine3DSeries, buildSurfaceData, getValueExtent, resolve3DRenderKind, SURFACE_COLOR_RAMP, } from "../utils/three-d";
|
|
29
|
+
import { computeTrendline } from "../utils/trendline";
|
|
30
|
+
import { computeWaterfall } from "../utils/waterfall";
|
|
31
|
+
import { isWebGLAvailable } from "../utils/webgl";
|
|
32
|
+
import { chartTypeToEchartsType, getEchartsSeriesStyle, mapMarkerSymbolToEcharts, showXAxis, } from "./helpers";
|
|
33
|
+
// Excel error values that should be treated as null in charts
|
|
34
|
+
const EXCEL_ERROR_VALUES = new Set([
|
|
35
|
+
"#N/A",
|
|
36
|
+
"#REF!",
|
|
37
|
+
"#VALUE!",
|
|
38
|
+
"#DIV/0!",
|
|
39
|
+
"#NAME?",
|
|
40
|
+
"#NULL!",
|
|
41
|
+
"#NUM!",
|
|
42
|
+
"#ERROR!",
|
|
43
|
+
"#GETTING_DATA",
|
|
44
|
+
]);
|
|
45
|
+
/**
|
|
46
|
+
* Check if a value is an Excel error string
|
|
47
|
+
*/
|
|
48
|
+
const isErrorValue = (value) => {
|
|
49
|
+
if (typeof value !== "string")
|
|
50
|
+
return false;
|
|
51
|
+
return EXCEL_ERROR_VALUES.has(value.toUpperCase());
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Sanitize a value for chart display - convert errors to undefined
|
|
55
|
+
*/
|
|
56
|
+
const sanitizeChartValue = (value) => {
|
|
57
|
+
if (value === null || value === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (isErrorValue(value))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof value === "number" && !isNaN(value))
|
|
62
|
+
return value;
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
const num = parseFloat(value);
|
|
65
|
+
return isNaN(num) ? undefined : num;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
const BASIC_CHART_TYPES = new Set([
|
|
70
|
+
"bar",
|
|
71
|
+
"column",
|
|
72
|
+
"line",
|
|
73
|
+
"pie",
|
|
74
|
+
"area",
|
|
75
|
+
"doughnut",
|
|
76
|
+
"radar",
|
|
77
|
+
"stock",
|
|
78
|
+
"histogram",
|
|
79
|
+
"boxplot",
|
|
80
|
+
"waterfall",
|
|
81
|
+
"funnel",
|
|
82
|
+
"combo",
|
|
83
|
+
"regionMap",
|
|
84
|
+
]);
|
|
85
|
+
const BUBBLE_CHART_TYPES = new Set([
|
|
86
|
+
"bubble",
|
|
87
|
+
"scatter",
|
|
88
|
+
]);
|
|
89
|
+
const HIERARCHY_CHART_TYPES = new Set([
|
|
90
|
+
"treemap",
|
|
91
|
+
"sunburst",
|
|
92
|
+
]);
|
|
93
|
+
const isBasicChartSpec = (spec) => BASIC_CHART_TYPES.has(spec.chartType);
|
|
94
|
+
const isBubbleChartSpec = (spec) => BUBBLE_CHART_TYPES.has(spec.chartType);
|
|
95
|
+
const isHierarchyChartSpec = (spec) => HIERARCHY_CHART_TYPES.has(spec.chartType);
|
|
96
|
+
const HIERARCHY_LEVEL_STYLES = [
|
|
97
|
+
{
|
|
98
|
+
itemStyle: {
|
|
99
|
+
gapWidth: 1,
|
|
100
|
+
borderColorSaturation: 0.6,
|
|
101
|
+
},
|
|
102
|
+
upperLabel: { show: false },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
colorSaturation: [0.35, 0.65],
|
|
106
|
+
itemStyle: {
|
|
107
|
+
gapWidth: 1,
|
|
108
|
+
borderColorSaturation: 0.6,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
colorSaturation: [0.45, 0.75],
|
|
113
|
+
itemStyle: {
|
|
114
|
+
gapWidth: 1,
|
|
115
|
+
borderColorSaturation: 0.6,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
const getHierarchyDepth = (nodes, depth = 1) => {
|
|
120
|
+
let maxDepth = depth;
|
|
121
|
+
nodes.forEach((node) => {
|
|
122
|
+
if (node.children && node.children.length > 0) {
|
|
123
|
+
maxDepth = Math.max(maxDepth, getHierarchyDepth(node.children, depth + 1));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return maxDepth;
|
|
127
|
+
};
|
|
128
|
+
const flattenChartDataValues = (chartData, extractor, hiddenStrategy, skipFirstRow = false) => {
|
|
129
|
+
if (!chartData || chartData.length === 0)
|
|
130
|
+
return [];
|
|
131
|
+
const values = [];
|
|
132
|
+
chartData.forEach((entry) => {
|
|
133
|
+
// Inline values (`<c:strLit>` / `<c:numLit>` embedded in the chart
|
|
134
|
+
// XML) take precedence over cell-range sources — by spec only one
|
|
135
|
+
// is populated, but if both happen to exist the inline value is
|
|
136
|
+
// the authored "ground truth".
|
|
137
|
+
const inline = entry
|
|
138
|
+
.inlineValues;
|
|
139
|
+
if (inline && inline.length) {
|
|
140
|
+
const trimmed = skipFirstRow ? inline.slice(1) : inline;
|
|
141
|
+
values.push(...trimmed);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
entry.sources.forEach((source) => {
|
|
145
|
+
const extracted = extractor(source, hiddenStrategy) ?? [];
|
|
146
|
+
const trimmed = skipFirstRow && extracted.length > 0 ? extracted.slice(1) : extracted;
|
|
147
|
+
values.push(...trimmed);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return values;
|
|
151
|
+
};
|
|
152
|
+
const buildHierarchyLevels = (maxDepth) => {
|
|
153
|
+
if (maxDepth <= 0)
|
|
154
|
+
return undefined;
|
|
155
|
+
const levels = [];
|
|
156
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
157
|
+
const baseStyle = HIERARCHY_LEVEL_STYLES[Math.min(i, HIERARCHY_LEVEL_STYLES.length - 1)];
|
|
158
|
+
levels.push({
|
|
159
|
+
...baseStyle,
|
|
160
|
+
itemStyle: {
|
|
161
|
+
...baseStyle.itemStyle,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return levels;
|
|
166
|
+
};
|
|
167
|
+
const defaultLegendConfig = {
|
|
168
|
+
padding: 10,
|
|
169
|
+
itemWidth: 10,
|
|
170
|
+
itemHeight: 4,
|
|
171
|
+
top: 20,
|
|
172
|
+
};
|
|
173
|
+
export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, onDeleteChart, onRequestEdit, onRequestCalculate, isSelected, }) => {
|
|
174
|
+
const spec = chart.spec;
|
|
175
|
+
const explicitChartTitle = chart.spec.title ?? undefined;
|
|
176
|
+
const hierarchyTitleRef = useRef(explicitChartTitle);
|
|
177
|
+
const title = explicitChartTitle ?? hierarchyTitleRef.current;
|
|
178
|
+
const el = useRef(null);
|
|
179
|
+
const chartRef = useRef(undefined);
|
|
180
|
+
const focusSheet = useFocusSheet();
|
|
181
|
+
const shouldFocusSheetRef = useRef(false);
|
|
182
|
+
const previousChartType = useRef(undefined);
|
|
183
|
+
const previousSeriesCount = useRef(undefined);
|
|
184
|
+
// 3-D pipeline state. echarts-gl is lazy-loaded the first time a 3-D
|
|
185
|
+
// spec renders; the tick re-runs updateChart when the module lands so
|
|
186
|
+
// the chart upgrades from the immediate 2-D fallback to true 3-D.
|
|
187
|
+
const renderedIn3D = useRef(false);
|
|
188
|
+
const glUpgradeRequested = useRef(false);
|
|
189
|
+
const [glUpgradeTick, setGlUpgradeTick] = useState(0);
|
|
190
|
+
// Region-map (choropleth) pipeline state. The map option (geo +
|
|
191
|
+
// visualMap) doesn't merge back into a cartesian one, so the instance
|
|
192
|
+
// is re-initialized when entering/leaving the region-map rendering.
|
|
193
|
+
const renderedAsRegionMap = useRef(false);
|
|
194
|
+
// Initialize line chart
|
|
195
|
+
const initializeChart = () => {
|
|
196
|
+
if (el.current && !chartRef.current) {
|
|
197
|
+
chartRef.current = echarts.init(el.current);
|
|
198
|
+
chartRef.current.setOption({
|
|
199
|
+
title: {
|
|
200
|
+
left: "top",
|
|
201
|
+
padding: 10,
|
|
202
|
+
textStyle: {
|
|
203
|
+
fontSize: 16,
|
|
204
|
+
fontWeight: "normal",
|
|
205
|
+
fontFamily: "Untitled Sans, apple-system, sans-serif",
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
tooltip: {
|
|
209
|
+
textStyle: {
|
|
210
|
+
fontSize: 12,
|
|
211
|
+
},
|
|
212
|
+
trigger: "axis",
|
|
213
|
+
},
|
|
214
|
+
legend: defaultLegendConfig,
|
|
215
|
+
grid: {
|
|
216
|
+
left: "3%",
|
|
217
|
+
right: "4%",
|
|
218
|
+
bottom: "3%",
|
|
219
|
+
containLabel: true,
|
|
220
|
+
},
|
|
221
|
+
xAxis: [
|
|
222
|
+
{
|
|
223
|
+
type: "category",
|
|
224
|
+
name: spec.horizontalAxisTitle ?? "", // Set your x-axis title here
|
|
225
|
+
nameLocation: "middle",
|
|
226
|
+
nameGap: 30,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
yAxis: [
|
|
230
|
+
{
|
|
231
|
+
type: "value",
|
|
232
|
+
name: spec.verticalAxisTitle ?? "", // Set your y-axis title here
|
|
233
|
+
nameLocation: "middle",
|
|
234
|
+
nameGap: 50,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
series: [],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const getSeriesData = useCallback((spec, sheetRange, options) => {
|
|
242
|
+
const rawSeriesValues = getSeriesValuesFromRange(sheetRange, spec.hiddenDimensionStrategy);
|
|
243
|
+
// Sanitize values - convert error strings like #N/A to null
|
|
244
|
+
const seriesValues = rawSeriesValues.map(sanitizeChartValue);
|
|
245
|
+
const { aggregateEnabled, aggregateType, rawDomainValues, orderedDomainValues, } = options;
|
|
246
|
+
const canAggregate = aggregateEnabled &&
|
|
247
|
+
!!aggregateType &&
|
|
248
|
+
rawDomainValues.length > 0 &&
|
|
249
|
+
orderedDomainValues.length > 0 &&
|
|
250
|
+
rawDomainValues.length === seriesValues.length;
|
|
251
|
+
if (canAggregate) {
|
|
252
|
+
const aggregatedValues = aggregateSeriesByDomain(rawDomainValues, seriesValues, aggregateType, orderedDomainValues);
|
|
253
|
+
if (spec.chartType === "pie") {
|
|
254
|
+
return orderedDomainValues.map((name, idx) => ({
|
|
255
|
+
value: aggregatedValues[idx] ?? 0,
|
|
256
|
+
name,
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
return aggregatedValues;
|
|
260
|
+
}
|
|
261
|
+
const numericValues = seriesValues;
|
|
262
|
+
if (spec.chartType === "pie") {
|
|
263
|
+
const domainSheetRange = spec.domains[0]?.sources[0];
|
|
264
|
+
if (!domainSheetRange) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
const domainValues = getDomainValuesFromRange(domainSheetRange, spec.hiddenDimensionStrategy);
|
|
268
|
+
return domainValues.map((name, idx) => ({
|
|
269
|
+
value: numericValues[idx],
|
|
270
|
+
name,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
return numericValues;
|
|
274
|
+
}, [getSeriesValuesFromRange, getDomainValuesFromRange]);
|
|
275
|
+
// Resize a chart
|
|
276
|
+
const resizeChart = () => {
|
|
277
|
+
chartRef.current?.resize();
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Triggers a chart update
|
|
281
|
+
*/
|
|
282
|
+
const updateChart = async () => {
|
|
283
|
+
debug("Updating chart", chart);
|
|
284
|
+
// ---------------------------- 3-D gating ----------------------------
|
|
285
|
+
// 3-D / surface payloads degrade to a 2-D fallback `chartType` with the
|
|
286
|
+
// authored class preserved as `sourceChartType` (BAR_3D, LINE_3D,
|
|
287
|
+
// PIE_3D, AREA_3D, SURFACE, SURFACE_3D). When WebGL is available and
|
|
288
|
+
// the lazily loaded echarts-gl module is ready, the dedicated 3-D
|
|
289
|
+
// branch below renders a true 3-D plot; in every other case (no WebGL,
|
|
290
|
+
// SSR, import failure, PIE_3D) the 2-D fallback renders untouched.
|
|
291
|
+
// The 3-D and region-map gates key off disjoint sourceChartType
|
|
292
|
+
// tokens, so a spec can never satisfy both.
|
|
293
|
+
const threeDRenderKind = resolve3DRenderKind(spec.sourceChartType);
|
|
294
|
+
const canRender3D = threeDRenderKind !== null && isWebGLAvailable();
|
|
295
|
+
const willRender3D = canRender3D && getEchartsGlState() === "ready";
|
|
296
|
+
if (canRender3D && !willRender3D && getEchartsGlState() !== "failed") {
|
|
297
|
+
// Kick off the echarts-gl load in the background (once). The 2-D
|
|
298
|
+
// fallback renders immediately; the tick re-runs updateChart to
|
|
299
|
+
// upgrade to 3-D when the module resolves. A failed load latches
|
|
300
|
+
// and the chart simply stays 2-D.
|
|
301
|
+
if (!glUpgradeRequested.current) {
|
|
302
|
+
glUpgradeRequested.current = true;
|
|
303
|
+
loadEchartsGl().then((loaded) => {
|
|
304
|
+
if (loaded) {
|
|
305
|
+
setGlUpgradeTick((tick) => tick + 1);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (renderedIn3D.current && !willRender3D) {
|
|
311
|
+
// Leaving 3-D (spec edit) — the grid3D option doesn't merge back
|
|
312
|
+
// into a cartesian one, so re-initialize from a clean slate and
|
|
313
|
+
// let the normal 2-D flow below do a full structural render.
|
|
314
|
+
chartRef.current?.dispose();
|
|
315
|
+
chartRef.current = undefined;
|
|
316
|
+
renderedIn3D.current = false;
|
|
317
|
+
previousChartType.current = undefined;
|
|
318
|
+
previousSeriesCount.current = undefined;
|
|
319
|
+
initializeChart();
|
|
320
|
+
applyThemeOptions();
|
|
321
|
+
}
|
|
322
|
+
// ------------------------- Region map -------------------------
|
|
323
|
+
// Filled map / choropleth: `chartType: "regionMap"`, or a payload
|
|
324
|
+
// degraded to another basic `chartType` that preserves the authored
|
|
325
|
+
// class as `sourceChartType: "REGION_MAP"`. Category labels are the
|
|
326
|
+
// region names and the first series carries the values. The geo
|
|
327
|
+
// assets (echarts map module + world GeoJSON) are lazy-loaded on
|
|
328
|
+
// first use, so non-map charts never pay for them.
|
|
329
|
+
const isRegionMap = isRegionMapSpec(spec);
|
|
330
|
+
if (renderedAsRegionMap.current && !isRegionMap) {
|
|
331
|
+
// Leaving the region map (spec edit) — the geo/visualMap option
|
|
332
|
+
// doesn't merge back into a cartesian one, so re-initialize from
|
|
333
|
+
// a clean slate and let the normal flow below do a full
|
|
334
|
+
// structural render.
|
|
335
|
+
chartRef.current?.dispose();
|
|
336
|
+
chartRef.current = undefined;
|
|
337
|
+
renderedAsRegionMap.current = false;
|
|
338
|
+
previousChartType.current = undefined;
|
|
339
|
+
previousSeriesCount.current = undefined;
|
|
340
|
+
initializeChart();
|
|
341
|
+
applyThemeOptions();
|
|
342
|
+
}
|
|
343
|
+
if (isRegionMap) {
|
|
344
|
+
const regionSpec = spec;
|
|
345
|
+
const geoScope = resolveGeoScope(regionSpec.geoScope);
|
|
346
|
+
const geo = await loadRegionMapGeo(geoScope);
|
|
347
|
+
// Entering the region map from a cartesian render: the previous
|
|
348
|
+
// grid/axis option doesn't merge into the map one, so start from
|
|
349
|
+
// a bare instance (theme options are re-applied after setOption).
|
|
350
|
+
if (!renderedAsRegionMap.current && chartRef.current) {
|
|
351
|
+
chartRef.current.dispose();
|
|
352
|
+
chartRef.current = undefined;
|
|
353
|
+
}
|
|
354
|
+
if (el.current && !chartRef.current) {
|
|
355
|
+
chartRef.current = echarts.init(el.current);
|
|
356
|
+
}
|
|
357
|
+
if (!geo) {
|
|
358
|
+
// Map asset failed to load (or unknown scope) — the loader
|
|
359
|
+
// already warned once; render an empty chart, never crash.
|
|
360
|
+
chartRef.current?.setOption({ series: [] }, { replaceMerge: ["series", "visualMap"] });
|
|
361
|
+
renderedAsRegionMap.current = true;
|
|
362
|
+
applyThemeOptions();
|
|
363
|
+
previousChartType.current = spec.chartType;
|
|
364
|
+
previousSeriesCount.current = 0;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const labels = flattenChartDataValues(regionSpec.domains, getDomainValuesFromRange, regionSpec.hiddenDimensionStrategy, false);
|
|
368
|
+
const seriesEntry = regionSpec.series?.[0];
|
|
369
|
+
const values = seriesEntry
|
|
370
|
+
? flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, regionSpec.hiddenDimensionStrategy)
|
|
371
|
+
: [];
|
|
372
|
+
const { data, unmatched, min, max } = buildRegionMapData(labels, values, geo.regionNames);
|
|
373
|
+
warnOnceForUnmatchedRegions(geoScope, unmatched);
|
|
374
|
+
const textColor = getStringifiedColor({ theme: 1, tint: isDarkMode ? 1 : 0 }, theme);
|
|
375
|
+
// Sequential single-hue value ramp derived from the theme accent;
|
|
376
|
+
// regions without data keep a neutral fill so "no data" reads
|
|
377
|
+
// differently from "small value".
|
|
378
|
+
const ramp = buildSequentialColorRamp(theme.themeColors.accent1, !!isDarkMode);
|
|
379
|
+
const noDataColor = isDarkMode
|
|
380
|
+
? "rgba(255,255,255,0.08)"
|
|
381
|
+
: "rgba(0,0,0,0.06)";
|
|
382
|
+
const borderColor = isDarkMode ? "rgba(255,255,255,0.3)" : "#FFFFFF";
|
|
383
|
+
const regionSeriesLabel = buildSeriesLabelOption(spec.dataLabels);
|
|
384
|
+
const seriesName = seriesEntry?.dataLabel && !isAFormula(seriesEntry.dataLabel)
|
|
385
|
+
? seriesEntry.dataLabel
|
|
386
|
+
: "";
|
|
387
|
+
chartRef.current?.setOption({
|
|
388
|
+
// The visualMap color scale is the value legend; the series
|
|
389
|
+
// legend stays hidden unless an authored override turns it on.
|
|
390
|
+
legend: applyLegendOverrides({ ...defaultLegendConfig, show: false }, spec.legend),
|
|
391
|
+
tooltip: {
|
|
392
|
+
textStyle: { fontSize: 12 },
|
|
393
|
+
trigger: "item",
|
|
394
|
+
formatter: (params) => {
|
|
395
|
+
const value = Array.isArray(params?.value)
|
|
396
|
+
? params.value[params.value.length - 1]
|
|
397
|
+
: params?.value;
|
|
398
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
399
|
+
? `${params?.name}: ${value}`
|
|
400
|
+
: `${params?.name ?? ""}`;
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
visualMap: [
|
|
404
|
+
{
|
|
405
|
+
type: "continuous",
|
|
406
|
+
min,
|
|
407
|
+
max,
|
|
408
|
+
left: "left",
|
|
409
|
+
bottom: 10,
|
|
410
|
+
inRange: { color: ramp },
|
|
411
|
+
textStyle: { color: textColor, fontSize: 12 },
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
series: [
|
|
415
|
+
{
|
|
416
|
+
type: "map",
|
|
417
|
+
map: geoScope,
|
|
418
|
+
name: seriesName,
|
|
419
|
+
data,
|
|
420
|
+
roam: false,
|
|
421
|
+
// Region names label the shapes only when the authored
|
|
422
|
+
// dataLabels override asks for them.
|
|
423
|
+
label: { show: false, ...(regionSeriesLabel ?? {}) },
|
|
424
|
+
emphasis: { label: { show: false } },
|
|
425
|
+
select: { disabled: true },
|
|
426
|
+
itemStyle: {
|
|
427
|
+
areaColor: noDataColor,
|
|
428
|
+
borderColor,
|
|
429
|
+
borderWidth: 0.5,
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
}, { replaceMerge: ["series", "visualMap", "legend", "tooltip"] });
|
|
434
|
+
// Re-apply theme-derived title / palette / tooltip colors — lost
|
|
435
|
+
// when the instance was re-initialized on entry.
|
|
436
|
+
renderedAsRegionMap.current = true;
|
|
437
|
+
applyThemeOptions();
|
|
438
|
+
previousChartType.current = spec.chartType;
|
|
439
|
+
previousSeriesCount.current = 1;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const chartDomains = [];
|
|
443
|
+
const chartSeries = [];
|
|
444
|
+
const legend = { ...defaultLegendConfig, data: [] };
|
|
445
|
+
const chartType = chartTypeToEchartsType(spec?.chartType);
|
|
446
|
+
const position = chart.position;
|
|
447
|
+
const showAxis = showXAxis(spec?.chartType);
|
|
448
|
+
const isBubble = isBubbleChartSpec(spec);
|
|
449
|
+
const isHierarchy = isHierarchyChartSpec(spec);
|
|
450
|
+
if (isBubble) {
|
|
451
|
+
hierarchyTitleRef.current = explicitChartTitle;
|
|
452
|
+
const skipHeaders = spec.useFirstRowAsHeaders !== false;
|
|
453
|
+
const labelValues = flattenChartDataValues(spec.bubbleLabels, getDomainValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders);
|
|
454
|
+
// Per-series X / size — scatter (always) and bubble (often) carry
|
|
455
|
+
// a separate xVal/bubbleSize per series in OOXML. Earlier code
|
|
456
|
+
// flattened them into a single shared array which broke when
|
|
457
|
+
// series had different ranges.
|
|
458
|
+
const perSeriesX = (spec.domains ?? []).map((dom) => flattenChartDataValues([dom], getSeriesValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders));
|
|
459
|
+
const perSeriesSize = (spec.bubbleSizes ?? []).map((sz) => flattenChartDataValues([sz], getSeriesValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders));
|
|
460
|
+
const seriesInputs = [];
|
|
461
|
+
for (let i = 0; i < spec.series.length; i++) {
|
|
462
|
+
const seriesEntry = spec.series[i];
|
|
463
|
+
let resolvedLabel = seriesEntry.dataLabel ?? "";
|
|
464
|
+
if (resolvedLabel && isAFormula(resolvedLabel)) {
|
|
465
|
+
resolvedLabel = coerceCalculatedLabel(await onRequestCalculate(resolvedLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex));
|
|
466
|
+
}
|
|
467
|
+
const xValues = perSeriesX[i] ?? perSeriesX[0];
|
|
468
|
+
const sizeValues = perSeriesSize[i] ?? perSeriesSize[0];
|
|
469
|
+
seriesInputs.push({
|
|
470
|
+
name: resolvedLabel,
|
|
471
|
+
values: flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders),
|
|
472
|
+
...(xValues ? { xValues } : {}),
|
|
473
|
+
...(sizeValues ? { sizeValues } : {}),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const sharedXValues = perSeriesX[0] ?? [];
|
|
477
|
+
const sharedSizeValues = perSeriesSize[0] ?? [];
|
|
478
|
+
const { categories, series: preparedSeries, sizeValues, } = prepareBubbleSeriesData({
|
|
479
|
+
xValues: sharedXValues,
|
|
480
|
+
labelValues,
|
|
481
|
+
sizeValues: sharedSizeValues,
|
|
482
|
+
series: seriesInputs,
|
|
483
|
+
});
|
|
484
|
+
const sizeScale = createBubbleSizeScale(sizeValues);
|
|
485
|
+
const xAxisConfig = categories.length > 0
|
|
486
|
+
? {
|
|
487
|
+
type: "category",
|
|
488
|
+
data: categories,
|
|
489
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
490
|
+
nameLocation: "middle",
|
|
491
|
+
nameGap: 30,
|
|
492
|
+
}
|
|
493
|
+
: {
|
|
494
|
+
type: "value",
|
|
495
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
496
|
+
nameLocation: "middle",
|
|
497
|
+
nameGap: 30,
|
|
498
|
+
};
|
|
499
|
+
const tooltipFormatter = (params) => {
|
|
500
|
+
const [rawX, y, size] = (params.value ?? []);
|
|
501
|
+
const label = categories.length > 0
|
|
502
|
+
? params.data?.xLabel ?? categories[Math.round(rawX)] ?? rawX
|
|
503
|
+
: rawX;
|
|
504
|
+
const bubbleName = params.name ? `${params.name}<br/>` : "";
|
|
505
|
+
const seriesName = params.seriesName ? `${params.seriesName}<br/>` : "";
|
|
506
|
+
return `${seriesName}${bubbleName}X: ${label ?? ""}<br/>Y: ${y ?? ""}<br/>Size: ${size ?? ""}`;
|
|
507
|
+
};
|
|
508
|
+
// Spec-level overrides — axis min/max/visibility/textFormat, legend
|
|
509
|
+
// position/visibility, dataLabel toggles. Same helpers as the
|
|
510
|
+
// non-bubble layout below.
|
|
511
|
+
const bubbleSeriesLabel = buildSeriesLabelOption(spec.dataLabels);
|
|
512
|
+
chartRef.current?.setOption({
|
|
513
|
+
xAxis: [
|
|
514
|
+
applyAxisOverrides({ ...xAxisConfig }, spec.categoryAxisOptions),
|
|
515
|
+
],
|
|
516
|
+
yAxis: [
|
|
517
|
+
applyAxisOverrides({
|
|
518
|
+
type: "value",
|
|
519
|
+
name: spec.verticalAxisTitle ?? "",
|
|
520
|
+
nameLocation: "middle",
|
|
521
|
+
nameGap: 45,
|
|
522
|
+
}, spec.valueAxisOptions),
|
|
523
|
+
],
|
|
524
|
+
legend: applyLegendOverrides({
|
|
525
|
+
...legend,
|
|
526
|
+
show: preparedSeries.length > 1,
|
|
527
|
+
data: preparedSeries.map((entry) => entry.name),
|
|
528
|
+
}, spec.legend),
|
|
529
|
+
grid: {
|
|
530
|
+
left: "3%",
|
|
531
|
+
right: "4%",
|
|
532
|
+
bottom: "3%",
|
|
533
|
+
containLabel: true,
|
|
534
|
+
},
|
|
535
|
+
tooltip: {
|
|
536
|
+
trigger: "item",
|
|
537
|
+
formatter: tooltipFormatter,
|
|
538
|
+
},
|
|
539
|
+
// Scatter vs bubble — scatter uses small fixed markers
|
|
540
|
+
// (optionally connected with a line per scatterStyle); bubble
|
|
541
|
+
// applies a size scale on the 3rd dimension. Pre-fix scatter
|
|
542
|
+
// routed through the bubble code, producing Y-scaled giant
|
|
543
|
+
// circles instead of Excel's small dots.
|
|
544
|
+
series: preparedSeries.map((entry, index) => {
|
|
545
|
+
const isScatter = spec.chartType === "scatter";
|
|
546
|
+
const scatterStyle = spec.scatterStyle ??
|
|
547
|
+
(isScatter ? "lineMarker" : undefined);
|
|
548
|
+
const seriesEntry = spec.series?.[index];
|
|
549
|
+
const seriesColor = seriesEntry?.color;
|
|
550
|
+
const perMarker = seriesEntry?.marker;
|
|
551
|
+
if (isScatter) {
|
|
552
|
+
const symbol = perMarker?.symbol;
|
|
553
|
+
const noMarkers = symbol === "none";
|
|
554
|
+
const wantsLine = scatterStyle === "line" ||
|
|
555
|
+
scatterStyle === "lineMarker" ||
|
|
556
|
+
scatterStyle === "smooth" ||
|
|
557
|
+
scatterStyle === "smoothMarker";
|
|
558
|
+
const wantsMarkers = !noMarkers &&
|
|
559
|
+
(scatterStyle === "marker" ||
|
|
560
|
+
scatterStyle === "lineMarker" ||
|
|
561
|
+
scatterStyle === "smoothMarker" ||
|
|
562
|
+
!scatterStyle);
|
|
563
|
+
const smooth = scatterStyle === "smooth" || scatterStyle === "smoothMarker";
|
|
564
|
+
return wantsLine
|
|
565
|
+
? {
|
|
566
|
+
type: "line",
|
|
567
|
+
name: entry.name,
|
|
568
|
+
data: entry.points,
|
|
569
|
+
showSymbol: wantsMarkers,
|
|
570
|
+
smooth,
|
|
571
|
+
symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
|
|
572
|
+
symbolSize: perMarker?.size ?? 6,
|
|
573
|
+
...(seriesColor
|
|
574
|
+
? {
|
|
575
|
+
itemStyle: { color: seriesColor },
|
|
576
|
+
lineStyle: { color: seriesColor },
|
|
577
|
+
}
|
|
578
|
+
: {}),
|
|
579
|
+
}
|
|
580
|
+
: {
|
|
581
|
+
type: "scatter",
|
|
582
|
+
name: entry.name,
|
|
583
|
+
data: entry.points,
|
|
584
|
+
symbol: mapMarkerSymbolToEcharts(symbol) ?? "circle",
|
|
585
|
+
symbolSize: perMarker?.size ?? 6,
|
|
586
|
+
...(seriesColor
|
|
587
|
+
? { itemStyle: { color: seriesColor } }
|
|
588
|
+
: {}),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
type: chartType,
|
|
593
|
+
name: entry.name,
|
|
594
|
+
data: entry.points,
|
|
595
|
+
symbolSize: (data) => sizeScale(data?.[2] ?? 0),
|
|
596
|
+
label: {
|
|
597
|
+
show: true,
|
|
598
|
+
formatter: (params) => params.name ?? "",
|
|
599
|
+
},
|
|
600
|
+
...(seriesColor ? { itemStyle: { color: seriesColor } } : {}),
|
|
601
|
+
};
|
|
602
|
+
}).map((s) => bubbleSeriesLabel
|
|
603
|
+
? { ...s, label: { ...(s.label ?? {}), ...bubbleSeriesLabel } }
|
|
604
|
+
: s),
|
|
605
|
+
}, {
|
|
606
|
+
replaceMerge: [
|
|
607
|
+
"series",
|
|
608
|
+
"xAxis",
|
|
609
|
+
"yAxis",
|
|
610
|
+
"legend",
|
|
611
|
+
"grid",
|
|
612
|
+
"tooltip",
|
|
613
|
+
],
|
|
614
|
+
});
|
|
615
|
+
previousChartType.current = spec.chartType;
|
|
616
|
+
previousSeriesCount.current = preparedSeries.length;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (isHierarchy) {
|
|
620
|
+
const hierarchyRows = extractHierarchyRows(spec, {
|
|
621
|
+
getDomainValuesFromRange,
|
|
622
|
+
getSeriesValuesFromRange,
|
|
623
|
+
hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
|
|
624
|
+
});
|
|
625
|
+
const headerTitle = getHierarchyHeaderTitle(spec, {
|
|
626
|
+
getDomainValuesFromRange,
|
|
627
|
+
getSeriesValuesFromRange,
|
|
628
|
+
hiddenDimensionStrategy: spec.hiddenDimensionStrategy,
|
|
629
|
+
});
|
|
630
|
+
hierarchyTitleRef.current = explicitChartTitle ?? headerTitle;
|
|
631
|
+
const hierarchyData = buildHierarchyTree(hierarchyRows);
|
|
632
|
+
const rootName = hierarchyData.length === 1 ? hierarchyData[0]?.name ?? "" : "";
|
|
633
|
+
const seriesDisplayName = explicitChartTitle ?? headerTitle ?? rootName ?? "";
|
|
634
|
+
const hierarchyDepth = getHierarchyDepth(hierarchyData);
|
|
635
|
+
const levels = buildHierarchyLevels(hierarchyDepth);
|
|
636
|
+
const tooltipFormatter = (params) => {
|
|
637
|
+
const value = Array.isArray(params.value)
|
|
638
|
+
? params.value[params.value.length - 1]
|
|
639
|
+
: params.value ?? "";
|
|
640
|
+
return `${params.name}: ${value}`;
|
|
641
|
+
};
|
|
642
|
+
// Spec-level overrides — treemap/sunburst default to no legend,
|
|
643
|
+
// but an authored legend (visibility/position) still wins.
|
|
644
|
+
const hierarchySeriesLabel = buildSeriesLabelOption(spec.dataLabels);
|
|
645
|
+
chartRef.current?.setOption({
|
|
646
|
+
xAxis: [
|
|
647
|
+
{
|
|
648
|
+
type: "category",
|
|
649
|
+
show: false,
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
yAxis: [
|
|
653
|
+
{
|
|
654
|
+
type: "value",
|
|
655
|
+
show: false,
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
legend: applyLegendOverrides({ ...defaultLegendConfig, show: false }, spec.legend),
|
|
659
|
+
grid: {
|
|
660
|
+
left: 0,
|
|
661
|
+
right: 0,
|
|
662
|
+
top: 0,
|
|
663
|
+
bottom: 0,
|
|
664
|
+
},
|
|
665
|
+
tooltip: {
|
|
666
|
+
trigger: "item",
|
|
667
|
+
formatter: tooltipFormatter,
|
|
668
|
+
},
|
|
669
|
+
series: [
|
|
670
|
+
{
|
|
671
|
+
type: chartType,
|
|
672
|
+
name: seriesDisplayName,
|
|
673
|
+
rootName: rootName || seriesDisplayName || "",
|
|
674
|
+
data: hierarchyData,
|
|
675
|
+
label: {
|
|
676
|
+
show: true,
|
|
677
|
+
formatter: "{b}",
|
|
678
|
+
...(hierarchySeriesLabel ?? {}),
|
|
679
|
+
},
|
|
680
|
+
upperLabel: spec.chartType === "treemap"
|
|
681
|
+
? {
|
|
682
|
+
show: true,
|
|
683
|
+
padding: [0, 0, 0, 4],
|
|
684
|
+
}
|
|
685
|
+
: undefined,
|
|
686
|
+
levels,
|
|
687
|
+
colorMappingBy: "index",
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
}, {
|
|
691
|
+
replaceMerge: [
|
|
692
|
+
"series",
|
|
693
|
+
"xAxis",
|
|
694
|
+
"yAxis",
|
|
695
|
+
"legend",
|
|
696
|
+
"grid",
|
|
697
|
+
"tooltip",
|
|
698
|
+
],
|
|
699
|
+
});
|
|
700
|
+
previousChartType.current = spec.chartType;
|
|
701
|
+
previousSeriesCount.current = 1;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
hierarchyTitleRef.current = explicitChartTitle;
|
|
706
|
+
}
|
|
707
|
+
if (!isBasicChartSpec(spec)) {
|
|
708
|
+
debug("Unsupported chart type", spec.chartType);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
// ----------------------------- 3-D -----------------------------
|
|
712
|
+
// True 3-D rendering for degraded Excel 3-D / surface payloads,
|
|
713
|
+
// keyed off `sourceChartType` (see the gating block above):
|
|
714
|
+
// - BAR_3D -> bar3D (x = category, y = series row, z = value)
|
|
715
|
+
// - LINE_3D -> line3D (one polyline per series at its depth row)
|
|
716
|
+
// - AREA_3D -> line3D (echarts-gl has no area3D)
|
|
717
|
+
// - SURFACE / SURFACE_3D -> surface (values grid as z-heights)
|
|
718
|
+
// - PIE_3D never reaches here (echarts-gl has no pie3D; the 2-D pie
|
|
719
|
+
// fallback renders instead — matching what Excel-degraded payloads
|
|
720
|
+
// did before this pipeline existed).
|
|
721
|
+
if (willRender3D && threeDRenderKind) {
|
|
722
|
+
const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? ""));
|
|
723
|
+
const threeDSeriesInputs = [];
|
|
724
|
+
for (const seriesEntry of spec.series ?? []) {
|
|
725
|
+
const dataLabel = !isNil(seriesEntry.dataLabel) && isAFormula(seriesEntry.dataLabel)
|
|
726
|
+
? coerceCalculatedLabel(await onRequestCalculate(seriesEntry.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
|
|
727
|
+
: seriesEntry.dataLabel ?? "";
|
|
728
|
+
const values = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map(sanitizeChartValue);
|
|
729
|
+
threeDSeriesInputs.push({ name: dataLabel, values });
|
|
730
|
+
}
|
|
731
|
+
const seriesNames = threeDSeriesInputs.map((entry, index) => entry.name || `Series ${index + 1}`);
|
|
732
|
+
const threeDPalette = [
|
|
733
|
+
theme.themeColors.accent1,
|
|
734
|
+
theme.themeColors.accent2,
|
|
735
|
+
theme.themeColors.accent3,
|
|
736
|
+
theme.themeColors.accent4,
|
|
737
|
+
theme.themeColors.accent5,
|
|
738
|
+
theme.themeColors.accent6,
|
|
739
|
+
];
|
|
740
|
+
const seriesColorAt = (index) => spec.series?.[index]?.color ??
|
|
741
|
+
threeDPalette[index % threeDPalette.length];
|
|
742
|
+
// Entering 3-D from a 2-D render: the cartesian option doesn't
|
|
743
|
+
// merge into a grid3D one, so re-initialize the echarts instance
|
|
744
|
+
// from scratch (theme options are re-applied after setOption).
|
|
745
|
+
if (!renderedIn3D.current && chartRef.current) {
|
|
746
|
+
chartRef.current.dispose();
|
|
747
|
+
chartRef.current = undefined;
|
|
748
|
+
}
|
|
749
|
+
if (el.current && !chartRef.current) {
|
|
750
|
+
chartRef.current = echarts.init(el.current);
|
|
751
|
+
}
|
|
752
|
+
// Value axes label integer positions with the category / series
|
|
753
|
+
// name they map to; fractional ticks stay blank.
|
|
754
|
+
const categoryAxisLabel = (value) => Number.isInteger(value) ? categories[value] ?? "" : "";
|
|
755
|
+
const seriesAxisLabel = (value) => Number.isInteger(value) ? seriesNames[value] ?? "" : "";
|
|
756
|
+
const threeDTooltipFormatter = (params) => {
|
|
757
|
+
const value = (params?.value ?? []);
|
|
758
|
+
const categoryLabel = categories[Math.round(value[0] ?? 0)] ?? String(value[0] ?? "");
|
|
759
|
+
const seriesLabel = seriesNames[Math.round(value[1] ?? 0)] ?? params?.seriesName ?? "";
|
|
760
|
+
return `${seriesLabel ? `${seriesLabel}<br/>` : ""}${categoryLabel}: ${value[2] ?? ""}`;
|
|
761
|
+
};
|
|
762
|
+
let threeDSeries;
|
|
763
|
+
let visualMap;
|
|
764
|
+
let xAxis3D;
|
|
765
|
+
let yAxis3D;
|
|
766
|
+
if (threeDRenderKind === "bar3D") {
|
|
767
|
+
xAxis3D = { type: "category", data: categories };
|
|
768
|
+
yAxis3D = { type: "category", data: seriesNames };
|
|
769
|
+
threeDSeries = [
|
|
770
|
+
{
|
|
771
|
+
type: "bar3D",
|
|
772
|
+
shading: "lambert",
|
|
773
|
+
data: buildBar3DData(threeDSeriesInputs).map(([x, y, z]) => ({
|
|
774
|
+
value: [x, y, z],
|
|
775
|
+
itemStyle: { color: seriesColorAt(y) },
|
|
776
|
+
})),
|
|
777
|
+
},
|
|
778
|
+
];
|
|
779
|
+
}
|
|
780
|
+
else if (threeDRenderKind === "line3D") {
|
|
781
|
+
xAxis3D = {
|
|
782
|
+
type: "value",
|
|
783
|
+
axisLabel: { formatter: categoryAxisLabel },
|
|
784
|
+
min: 0,
|
|
785
|
+
max: Math.max(categories.length - 1, 1),
|
|
786
|
+
};
|
|
787
|
+
yAxis3D = {
|
|
788
|
+
type: "value",
|
|
789
|
+
axisLabel: { formatter: seriesAxisLabel },
|
|
790
|
+
min: 0,
|
|
791
|
+
max: Math.max(seriesNames.length - 1, 1),
|
|
792
|
+
};
|
|
793
|
+
threeDSeries = buildLine3DSeries(threeDSeriesInputs).map((entry, index) => ({
|
|
794
|
+
type: "line3D",
|
|
795
|
+
name: seriesNames[index],
|
|
796
|
+
data: entry.data,
|
|
797
|
+
lineStyle: { width: 3, color: seriesColorAt(index) },
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
const surfacePoints = buildSurfaceData(threeDSeriesInputs);
|
|
802
|
+
const [zMin, zMax] = getValueExtent(surfacePoints);
|
|
803
|
+
xAxis3D = {
|
|
804
|
+
type: "value",
|
|
805
|
+
axisLabel: { formatter: categoryAxisLabel },
|
|
806
|
+
min: 0,
|
|
807
|
+
max: Math.max(categories.length - 1, 1),
|
|
808
|
+
};
|
|
809
|
+
yAxis3D = {
|
|
810
|
+
type: "value",
|
|
811
|
+
axisLabel: { formatter: seriesAxisLabel },
|
|
812
|
+
min: 0,
|
|
813
|
+
max: Math.max(seriesNames.length - 1, 1),
|
|
814
|
+
};
|
|
815
|
+
threeDSeries = [
|
|
816
|
+
{
|
|
817
|
+
type: "surface",
|
|
818
|
+
data: surfacePoints,
|
|
819
|
+
wireframe: { show: true },
|
|
820
|
+
shading: "color",
|
|
821
|
+
},
|
|
822
|
+
];
|
|
823
|
+
visualMap = {
|
|
824
|
+
show: false,
|
|
825
|
+
dimension: 2,
|
|
826
|
+
min: zMin,
|
|
827
|
+
max: zMax,
|
|
828
|
+
seriesIndex: 0,
|
|
829
|
+
inRange: { color: [...SURFACE_COLOR_RAMP] },
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
chartRef.current?.setOption({
|
|
833
|
+
tooltip: {
|
|
834
|
+
textStyle: { fontSize: 12 },
|
|
835
|
+
formatter: threeDTooltipFormatter,
|
|
836
|
+
},
|
|
837
|
+
legend: threeDRenderKind === "line3D"
|
|
838
|
+
? applyLegendOverrides({
|
|
839
|
+
...defaultLegendConfig,
|
|
840
|
+
show: seriesNames.length > 1,
|
|
841
|
+
data: seriesNames,
|
|
842
|
+
}, spec.legend)
|
|
843
|
+
: { show: false },
|
|
844
|
+
xAxis3D: { ...xAxis3D, name: spec.horizontalAxisTitle ?? "" },
|
|
845
|
+
yAxis3D: { ...yAxis3D, name: "" },
|
|
846
|
+
zAxis3D: { type: "value", name: spec.verticalAxisTitle ?? "" },
|
|
847
|
+
grid3D: {
|
|
848
|
+
boxDepth: Math.max(20, Math.min(100, seriesNames.length * 20)),
|
|
849
|
+
viewControl: { autoRotate: false },
|
|
850
|
+
},
|
|
851
|
+
...(visualMap ? { visualMap: [visualMap] } : {}),
|
|
852
|
+
series: threeDSeries,
|
|
853
|
+
}, {
|
|
854
|
+
replaceMerge: [
|
|
855
|
+
"series",
|
|
856
|
+
"xAxis3D",
|
|
857
|
+
"yAxis3D",
|
|
858
|
+
"zAxis3D",
|
|
859
|
+
"visualMap",
|
|
860
|
+
"legend",
|
|
861
|
+
"tooltip",
|
|
862
|
+
],
|
|
863
|
+
});
|
|
864
|
+
// Re-apply theme-derived title / palette / tooltip colors — lost
|
|
865
|
+
// when the instance was re-initialized on entry into 3-D.
|
|
866
|
+
renderedIn3D.current = true;
|
|
867
|
+
applyThemeOptions();
|
|
868
|
+
previousChartType.current = spec.chartType;
|
|
869
|
+
previousSeriesCount.current = threeDSeries.length;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// ---------------------------- Radar ----------------------------
|
|
873
|
+
// Radar is polar: a top-level `radar.indicator` list (one axis per
|
|
874
|
+
// category) plus one `radar`-type series carrying every data series'
|
|
875
|
+
// value vector. radarStyle controls markers / filled area.
|
|
876
|
+
if (spec.chartType === "radar") {
|
|
877
|
+
const skipHeaders = false;
|
|
878
|
+
const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, skipHeaders);
|
|
879
|
+
const radarSeriesInputs = [];
|
|
880
|
+
for (const seriesEntry of spec.series ?? []) {
|
|
881
|
+
const label = seriesEntry.dataLabel && !isAFormula(seriesEntry.dataLabel)
|
|
882
|
+
? seriesEntry.dataLabel
|
|
883
|
+
: "";
|
|
884
|
+
const values = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)));
|
|
885
|
+
radarSeriesInputs.push({ name: label, values });
|
|
886
|
+
}
|
|
887
|
+
const indicators = buildRadarIndicators(categories, radarSeriesInputs);
|
|
888
|
+
const radarData = buildRadarSeriesData(radarSeriesInputs, indicators.length);
|
|
889
|
+
const radarStyle = spec.radarStyle ?? "standard";
|
|
890
|
+
const radarColors = radarSeriesInputs.map((_, i) => spec.series?.[i]?.color);
|
|
891
|
+
// Spec-level overrides — radar is polar (no cartesian axes), so
|
|
892
|
+
// only legend + dataLabel overrides apply here.
|
|
893
|
+
const radarSeriesLabel = buildSeriesLabelOption(spec.dataLabels);
|
|
894
|
+
chartRef.current?.setOption({
|
|
895
|
+
radar: {
|
|
896
|
+
indicator: indicators.length
|
|
897
|
+
? indicators
|
|
898
|
+
: [{ name: "", max: 1 }],
|
|
899
|
+
},
|
|
900
|
+
legend: applyLegendOverrides({
|
|
901
|
+
...legend,
|
|
902
|
+
show: radarData.length > 1,
|
|
903
|
+
data: radarData.map((d) => d.name),
|
|
904
|
+
}, spec.legend),
|
|
905
|
+
tooltip: { trigger: "item" },
|
|
906
|
+
series: [
|
|
907
|
+
{
|
|
908
|
+
type: "radar",
|
|
909
|
+
symbol: radarStyle === "standard" ? "none" : "circle",
|
|
910
|
+
...(radarSeriesLabel ? { label: radarSeriesLabel } : {}),
|
|
911
|
+
data: radarData.map((d, i) => ({
|
|
912
|
+
value: d.value,
|
|
913
|
+
name: d.name,
|
|
914
|
+
...(radarStyle === "filled" ? { areaStyle: {} } : {}),
|
|
915
|
+
...(radarColors[i]
|
|
916
|
+
? {
|
|
917
|
+
itemStyle: { color: radarColors[i] },
|
|
918
|
+
lineStyle: { color: radarColors[i] },
|
|
919
|
+
}
|
|
920
|
+
: {}),
|
|
921
|
+
})),
|
|
922
|
+
},
|
|
923
|
+
],
|
|
924
|
+
}, {
|
|
925
|
+
replaceMerge: [
|
|
926
|
+
"series",
|
|
927
|
+
"xAxis",
|
|
928
|
+
"yAxis",
|
|
929
|
+
"radar",
|
|
930
|
+
"legend",
|
|
931
|
+
"grid",
|
|
932
|
+
"tooltip",
|
|
933
|
+
],
|
|
934
|
+
});
|
|
935
|
+
previousChartType.current = spec.chartType;
|
|
936
|
+
previousSeriesCount.current = radarData.length;
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
// ---------------------------- Stock ----------------------------
|
|
940
|
+
// Line-derived stock: fan the price-role series (open/high/low/close)
|
|
941
|
+
// out into separate line series over the category (date) domain. The
|
|
942
|
+
// V-variants additionally get a volume column sub-pane below the
|
|
943
|
+
// price plot.
|
|
944
|
+
if (spec.chartType === "stock") {
|
|
945
|
+
const variant = (spec.stockVariant ?? "HLC");
|
|
946
|
+
const categories = flattenChartDataValues(spec.domains, getDomainValuesFromRange, spec.hiddenDimensionStrategy, false).map((v) => String(v ?? ""));
|
|
947
|
+
const seriesValues = (spec.series ?? []).map((seriesEntry) => flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v))));
|
|
948
|
+
// Spec-level overrides — stock is cartesian, so axis min/max/
|
|
949
|
+
// visibility apply alongside legend + dataLabel toggles. They
|
|
950
|
+
// target the price plot's axes: the category-axis overrides land
|
|
951
|
+
// on the shared base x-axis (so both panes stay in sync when a
|
|
952
|
+
// volume pane exists), the value-axis overrides on the price
|
|
953
|
+
// pane's y-axis only — the volume pane keeps its own hidden-label
|
|
954
|
+
// value axis.
|
|
955
|
+
const stockSeriesLabel = buildSeriesLabelOption(spec.dataLabels);
|
|
956
|
+
// V-variants (VHLC / VOHLC) carry a volume series that renders as
|
|
957
|
+
// a column sub-pane under the price plot: two grids stacked
|
|
958
|
+
// vertically (price ~70% of the plot, volume below on its own
|
|
959
|
+
// value axis) with the category axes linked through axisPointer.
|
|
960
|
+
// Up days color like the candlestick body, down days like its
|
|
961
|
+
// shadow, so the panes read as one chart.
|
|
962
|
+
const upColor = theme.themeColors.accent1;
|
|
963
|
+
const downColor = theme.themeColors.accent2;
|
|
964
|
+
const volumePoints = toVolumeSeries(seriesValues, variant);
|
|
965
|
+
const hasVolume = (volumePoints?.length ?? 0) > 0;
|
|
966
|
+
const stockGrid = hasVolume
|
|
967
|
+
? [
|
|
968
|
+
{ left: "10%", right: "4%", top: "8%", height: "56%" },
|
|
969
|
+
{ left: "10%", right: "4%", top: "74%", height: "18%" },
|
|
970
|
+
]
|
|
971
|
+
: [{ left: "3%", right: "4%", bottom: "3%", containLabel: true }];
|
|
972
|
+
const baseStockXAxis = applyAxisOverrides({
|
|
973
|
+
type: "category",
|
|
974
|
+
data: categories,
|
|
975
|
+
show: true,
|
|
976
|
+
axisLine: { show: true },
|
|
977
|
+
axisTick: { show: true },
|
|
978
|
+
splitLine: { show: false },
|
|
979
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
980
|
+
nameLocation: "middle",
|
|
981
|
+
nameGap: 30,
|
|
982
|
+
}, spec.categoryAxisOptions);
|
|
983
|
+
const stockXAxis = hasVolume
|
|
984
|
+
? [
|
|
985
|
+
// Price pane keeps its axis line but the shared category
|
|
986
|
+
// labels + title live on the volume pane below. Spread after
|
|
987
|
+
// the overridden base so the pane's labels stay hidden even
|
|
988
|
+
// when the overrides carry an axisLabel text style.
|
|
989
|
+
{
|
|
990
|
+
...baseStockXAxis,
|
|
991
|
+
gridIndex: 0,
|
|
992
|
+
axisLabel: { show: false },
|
|
993
|
+
axisTick: { show: false },
|
|
994
|
+
name: "",
|
|
995
|
+
},
|
|
996
|
+
{ ...baseStockXAxis, gridIndex: 1 },
|
|
997
|
+
]
|
|
998
|
+
: [baseStockXAxis];
|
|
999
|
+
const volumeYAxis = {
|
|
1000
|
+
type: "value",
|
|
1001
|
+
gridIndex: 1,
|
|
1002
|
+
scale: true,
|
|
1003
|
+
splitNumber: 2,
|
|
1004
|
+
axisLabel: { show: false },
|
|
1005
|
+
axisLine: { show: false },
|
|
1006
|
+
axisTick: { show: false },
|
|
1007
|
+
splitLine: { show: false },
|
|
1008
|
+
};
|
|
1009
|
+
const volumeSeries = hasVolume
|
|
1010
|
+
? [
|
|
1011
|
+
{
|
|
1012
|
+
type: "bar",
|
|
1013
|
+
name: "Volume",
|
|
1014
|
+
xAxisIndex: 1,
|
|
1015
|
+
yAxisIndex: 1,
|
|
1016
|
+
data: (volumePoints ?? []).map((p) => ({
|
|
1017
|
+
value: p.value,
|
|
1018
|
+
itemStyle: { color: p.up ? upColor : downColor },
|
|
1019
|
+
})),
|
|
1020
|
+
},
|
|
1021
|
+
]
|
|
1022
|
+
: [];
|
|
1023
|
+
// Link the panes' category axes so hovering either pane raises the
|
|
1024
|
+
// axis pointer (and tooltip) across both. Cleared when no volume.
|
|
1025
|
+
const stockAxisPointer = {
|
|
1026
|
+
link: hasVolume ? [{ xAxisIndex: "all" }] : [],
|
|
1027
|
+
};
|
|
1028
|
+
// OHLC / VOHLC variants carry an `open`, so they render as a
|
|
1029
|
+
// candlestick (Excel's open-high-low-close stock chart). HLC / VHLC
|
|
1030
|
+
// (no open) fall through to the line-derived rendering below.
|
|
1031
|
+
if (variant === "OHLC" || variant === "VOHLC") {
|
|
1032
|
+
const candleData = toCandlestick(seriesValues, variant);
|
|
1033
|
+
chartRef.current?.setOption({
|
|
1034
|
+
xAxis: stockXAxis,
|
|
1035
|
+
yAxis: [
|
|
1036
|
+
applyAxisOverrides({
|
|
1037
|
+
type: "value",
|
|
1038
|
+
show: true,
|
|
1039
|
+
scale: true,
|
|
1040
|
+
name: spec.verticalAxisTitle ?? "",
|
|
1041
|
+
nameLocation: "middle",
|
|
1042
|
+
nameGap: 50,
|
|
1043
|
+
gridIndex: 0,
|
|
1044
|
+
}, spec.valueAxisOptions),
|
|
1045
|
+
...(hasVolume ? [volumeYAxis] : []),
|
|
1046
|
+
],
|
|
1047
|
+
legend: applyLegendOverrides({ ...legend, show: false }, spec.legend),
|
|
1048
|
+
grid: stockGrid,
|
|
1049
|
+
tooltip: { trigger: "axis" },
|
|
1050
|
+
axisPointer: stockAxisPointer,
|
|
1051
|
+
series: [
|
|
1052
|
+
{
|
|
1053
|
+
type: "candlestick",
|
|
1054
|
+
name: spec.title || "Stock",
|
|
1055
|
+
data: candleData,
|
|
1056
|
+
itemStyle: {
|
|
1057
|
+
color: upColor,
|
|
1058
|
+
color0: downColor,
|
|
1059
|
+
borderColor: upColor,
|
|
1060
|
+
borderColor0: downColor,
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
...volumeSeries,
|
|
1064
|
+
],
|
|
1065
|
+
}, {
|
|
1066
|
+
replaceMerge: [
|
|
1067
|
+
"series",
|
|
1068
|
+
"xAxis",
|
|
1069
|
+
"yAxis",
|
|
1070
|
+
"legend",
|
|
1071
|
+
"grid",
|
|
1072
|
+
"tooltip",
|
|
1073
|
+
],
|
|
1074
|
+
});
|
|
1075
|
+
previousChartType.current = spec.chartType;
|
|
1076
|
+
previousSeriesCount.current = 1 + volumeSeries.length;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const lineSeries = toLineSeries(seriesValues, variant);
|
|
1080
|
+
const stockColors = [
|
|
1081
|
+
theme.themeColors.accent1,
|
|
1082
|
+
theme.themeColors.accent2,
|
|
1083
|
+
theme.themeColors.accent3,
|
|
1084
|
+
theme.themeColors.accent4,
|
|
1085
|
+
];
|
|
1086
|
+
chartRef.current?.setOption({
|
|
1087
|
+
xAxis: stockXAxis,
|
|
1088
|
+
yAxis: [
|
|
1089
|
+
applyAxisOverrides({
|
|
1090
|
+
type: "value",
|
|
1091
|
+
show: true,
|
|
1092
|
+
name: spec.verticalAxisTitle ?? "",
|
|
1093
|
+
nameLocation: "middle",
|
|
1094
|
+
nameGap: 50,
|
|
1095
|
+
gridIndex: 0,
|
|
1096
|
+
}, spec.valueAxisOptions),
|
|
1097
|
+
...(hasVolume ? [volumeYAxis] : []),
|
|
1098
|
+
],
|
|
1099
|
+
legend: applyLegendOverrides({
|
|
1100
|
+
...legend,
|
|
1101
|
+
show: lineSeries.length > 1,
|
|
1102
|
+
data: lineSeries.map((s) => s.name),
|
|
1103
|
+
}, spec.legend),
|
|
1104
|
+
grid: stockGrid,
|
|
1105
|
+
tooltip: { trigger: "axis" },
|
|
1106
|
+
axisPointer: stockAxisPointer,
|
|
1107
|
+
series: [
|
|
1108
|
+
...lineSeries.map((s, i) => ({
|
|
1109
|
+
type: "line",
|
|
1110
|
+
name: s.name,
|
|
1111
|
+
data: s.values,
|
|
1112
|
+
showSymbol: false,
|
|
1113
|
+
itemStyle: { color: stockColors[i % stockColors.length] },
|
|
1114
|
+
lineStyle: { color: stockColors[i % stockColors.length] },
|
|
1115
|
+
...(stockSeriesLabel ? { label: stockSeriesLabel } : {}),
|
|
1116
|
+
})),
|
|
1117
|
+
...volumeSeries,
|
|
1118
|
+
],
|
|
1119
|
+
}, {
|
|
1120
|
+
replaceMerge: [
|
|
1121
|
+
"series",
|
|
1122
|
+
"xAxis",
|
|
1123
|
+
"yAxis",
|
|
1124
|
+
"legend",
|
|
1125
|
+
"grid",
|
|
1126
|
+
"tooltip",
|
|
1127
|
+
],
|
|
1128
|
+
});
|
|
1129
|
+
previousChartType.current = spec.chartType;
|
|
1130
|
+
previousSeriesCount.current = lineSeries.length + volumeSeries.length;
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const isStacked = spec.stackedType && spec.stackedType !== "UNSTACKED";
|
|
1134
|
+
const isCircularChart = spec.chartType === "pie" || spec.chartType === "doughnut";
|
|
1135
|
+
// Pie-of-pie / bar-of-pie composite. The of-pie branch below fills
|
|
1136
|
+
// `ofPieLayout` so the grid/axis assembly at the bottom can add the
|
|
1137
|
+
// secondary-bar grid and the 0-1 value axes the connector series
|
|
1138
|
+
// needs.
|
|
1139
|
+
const isOfPie = isCircularChart && isOfPieChartSpec(spec);
|
|
1140
|
+
let ofPieLayout = null;
|
|
1141
|
+
const isHorizontalBarChart = spec.chartType === "bar";
|
|
1142
|
+
const shouldAggregate = shouldAggregateSeries(spec.series);
|
|
1143
|
+
const primaryDomainSource = spec?.domains?.[0]?.sources?.[0];
|
|
1144
|
+
const rawPrimaryDomainValues = primaryDomainSource
|
|
1145
|
+
? sanitizeDomainValues(getDomainValuesFromRange(primaryDomainSource, spec.hiddenDimensionStrategy) ?? [])
|
|
1146
|
+
: [];
|
|
1147
|
+
const aggregateDomainValues = getDomainValuesForAxis(rawPrimaryDomainValues, shouldAggregate);
|
|
1148
|
+
spec.domains?.forEach((domain, domainIndex) => {
|
|
1149
|
+
domain.sources.forEach((sheetRange, sourceIndex) => {
|
|
1150
|
+
let axisData = [];
|
|
1151
|
+
if (domainIndex === 0 && sourceIndex === 0) {
|
|
1152
|
+
axisData = aggregateDomainValues;
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
const rawValues = sanitizeDomainValues(getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? []);
|
|
1156
|
+
axisData = getDomainValuesForAxis(rawValues, shouldAggregate);
|
|
1157
|
+
}
|
|
1158
|
+
const axisTitle = domainIndex === 0 && sourceIndex === 0 && !isCircularChart
|
|
1159
|
+
? isHorizontalBarChart
|
|
1160
|
+
? spec.verticalAxisTitle ?? ""
|
|
1161
|
+
: spec.horizontalAxisTitle ?? ""
|
|
1162
|
+
: undefined;
|
|
1163
|
+
chartDomains.push({
|
|
1164
|
+
type: "category",
|
|
1165
|
+
data: axisData,
|
|
1166
|
+
show: showAxis && !isCircularChart,
|
|
1167
|
+
axisLine: { show: !isCircularChart },
|
|
1168
|
+
axisTick: { show: !isCircularChart },
|
|
1169
|
+
splitLine: { show: !isCircularChart },
|
|
1170
|
+
name: axisTitle,
|
|
1171
|
+
nameLocation: "middle",
|
|
1172
|
+
nameGap: 30,
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
if (chartDomains.length === 0 && !isCircularChart) {
|
|
1177
|
+
chartDomains.push({
|
|
1178
|
+
type: "category",
|
|
1179
|
+
show: showAxis,
|
|
1180
|
+
axisLine: { show: true },
|
|
1181
|
+
axisTick: { show: true },
|
|
1182
|
+
splitLine: { show: true },
|
|
1183
|
+
gridIndex: 0,
|
|
1184
|
+
name: isHorizontalBarChart
|
|
1185
|
+
? spec.verticalAxisTitle ?? ""
|
|
1186
|
+
: spec.horizontalAxisTitle ?? "",
|
|
1187
|
+
nameLocation: "middle",
|
|
1188
|
+
nameGap: 30,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
// Trendline + error-bar overlays are extra series appended after the
|
|
1192
|
+
// base series. The redraw/series-count accounting below derives from
|
|
1193
|
+
// the final `chartSeries.length`, so these just push onto the array.
|
|
1194
|
+
/**
|
|
1195
|
+
* Append trendline and error-bar overlay series for one data series.
|
|
1196
|
+
* `data` is the resolved numeric value array (may be number[] or
|
|
1197
|
+
* objects with a `value` field for pie — overlays are skipped for
|
|
1198
|
+
* non-numeric shapes).
|
|
1199
|
+
*/
|
|
1200
|
+
const pushSeriesOverlays = (series, data, dataLabel) => {
|
|
1201
|
+
const numericValues = Array.isArray(data)
|
|
1202
|
+
? data.map((v) => typeof v === "number"
|
|
1203
|
+
? v
|
|
1204
|
+
: v && typeof v === "object" && "value" in v
|
|
1205
|
+
? v.value
|
|
1206
|
+
: Number(v))
|
|
1207
|
+
: [];
|
|
1208
|
+
if (numericValues.length === 0)
|
|
1209
|
+
return;
|
|
1210
|
+
const seriesColor = series.color ?? theme.themeColors.accent1;
|
|
1211
|
+
const useSecondary = series.useSecondaryAxis ? { yAxisIndex: 1 } : {};
|
|
1212
|
+
// Trendlines — one overlay line series per spec.
|
|
1213
|
+
for (const tl of series.trendlines ?? []) {
|
|
1214
|
+
const result = computeTrendline(numericValues, {
|
|
1215
|
+
type: tl.type,
|
|
1216
|
+
order: tl.order,
|
|
1217
|
+
period: tl.period,
|
|
1218
|
+
forecastForward: tl.forecastForward,
|
|
1219
|
+
forecastBackward: tl.forecastBackward,
|
|
1220
|
+
});
|
|
1221
|
+
if (!result || result.points.length < 2)
|
|
1222
|
+
continue;
|
|
1223
|
+
const tlName = tl.name ??
|
|
1224
|
+
`${dataLabel ? dataLabel + " " : ""}${tl.type ?? "linear"} trend`;
|
|
1225
|
+
chartSeries.push({
|
|
1226
|
+
type: "line",
|
|
1227
|
+
name: tlName,
|
|
1228
|
+
// Index x lets the trendline overlay the category axis even
|
|
1229
|
+
// when forecasting extends beyond the data range.
|
|
1230
|
+
data: result.points.map((p) => [p.x, p.y]),
|
|
1231
|
+
showSymbol: false,
|
|
1232
|
+
smooth: tl.type === "poly",
|
|
1233
|
+
lineStyle: {
|
|
1234
|
+
color: tl.color ?? seriesColor,
|
|
1235
|
+
type: "dashed",
|
|
1236
|
+
width: 2,
|
|
1237
|
+
},
|
|
1238
|
+
itemStyle: { color: tl.color ?? seriesColor },
|
|
1239
|
+
tooltip: { show: false },
|
|
1240
|
+
z: 5,
|
|
1241
|
+
...useSecondary,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
// Error bars — drawn as a custom series of vertical/horizontal
|
|
1245
|
+
// whiskers. valueType "custom" reads per-point magnitudes from the
|
|
1246
|
+
// spec's plus/minus ranges, resolved through the same extractor
|
|
1247
|
+
// the series values use.
|
|
1248
|
+
const magnitudes = computeErrorBars(numericValues, resolveCustomErrorBarValues(series.errorBars, (range) => getSeriesValuesFromRange(range, spec.hiddenDimensionStrategy)));
|
|
1249
|
+
if (magnitudes) {
|
|
1250
|
+
const direction = series.errorBars?.direction ?? "y";
|
|
1251
|
+
const noEndCap = series.errorBars?.noEndCap ?? false;
|
|
1252
|
+
const errorData = numericValues.map((v, i) => [
|
|
1253
|
+
i,
|
|
1254
|
+
typeof v === "number" ? v : 0,
|
|
1255
|
+
magnitudes.plus[i] ?? 0,
|
|
1256
|
+
magnitudes.minus[i] ?? 0,
|
|
1257
|
+
]);
|
|
1258
|
+
chartSeries.push({
|
|
1259
|
+
type: "custom",
|
|
1260
|
+
name: `${dataLabel ? dataLabel + " " : ""}error`,
|
|
1261
|
+
data: errorData,
|
|
1262
|
+
z: 6,
|
|
1263
|
+
tooltip: { show: false },
|
|
1264
|
+
...useSecondary,
|
|
1265
|
+
renderItem: (_params, api) => {
|
|
1266
|
+
const value = api.value(1);
|
|
1267
|
+
const plus = api.value(2);
|
|
1268
|
+
const minus = api.value(3);
|
|
1269
|
+
const capHalf = 5;
|
|
1270
|
+
if (direction === "x") {
|
|
1271
|
+
const center = api.coord([value, api.value(0)]);
|
|
1272
|
+
const hi = api.coord([value + plus, api.value(0)]);
|
|
1273
|
+
const lo = api.coord([value - minus, api.value(0)]);
|
|
1274
|
+
const children = [
|
|
1275
|
+
{
|
|
1276
|
+
type: "line",
|
|
1277
|
+
shape: { x1: lo[0], y1: center[1], x2: hi[0], y2: center[1] },
|
|
1278
|
+
style: { stroke: seriesColor, lineWidth: 1 },
|
|
1279
|
+
},
|
|
1280
|
+
];
|
|
1281
|
+
if (!noEndCap) {
|
|
1282
|
+
children.push({
|
|
1283
|
+
type: "line",
|
|
1284
|
+
shape: {
|
|
1285
|
+
x1: hi[0],
|
|
1286
|
+
y1: center[1] - capHalf,
|
|
1287
|
+
x2: hi[0],
|
|
1288
|
+
y2: center[1] + capHalf,
|
|
1289
|
+
},
|
|
1290
|
+
style: { stroke: seriesColor, lineWidth: 1 },
|
|
1291
|
+
}, {
|
|
1292
|
+
type: "line",
|
|
1293
|
+
shape: {
|
|
1294
|
+
x1: lo[0],
|
|
1295
|
+
y1: center[1] - capHalf,
|
|
1296
|
+
x2: lo[0],
|
|
1297
|
+
y2: center[1] + capHalf,
|
|
1298
|
+
},
|
|
1299
|
+
style: { stroke: seriesColor, lineWidth: 1 },
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
return { type: "group", children };
|
|
1303
|
+
}
|
|
1304
|
+
const center = api.coord([api.value(0), value]);
|
|
1305
|
+
const hi = api.coord([api.value(0), value + plus]);
|
|
1306
|
+
const lo = api.coord([api.value(0), value - minus]);
|
|
1307
|
+
const children = [
|
|
1308
|
+
{
|
|
1309
|
+
type: "line",
|
|
1310
|
+
shape: { x1: center[0], y1: lo[1], x2: center[0], y2: hi[1] },
|
|
1311
|
+
style: { stroke: seriesColor, lineWidth: 1 },
|
|
1312
|
+
},
|
|
1313
|
+
];
|
|
1314
|
+
if (!noEndCap) {
|
|
1315
|
+
children.push({
|
|
1316
|
+
type: "line",
|
|
1317
|
+
shape: {
|
|
1318
|
+
x1: center[0] - capHalf,
|
|
1319
|
+
y1: hi[1],
|
|
1320
|
+
x2: center[0] + capHalf,
|
|
1321
|
+
y2: hi[1],
|
|
1322
|
+
},
|
|
1323
|
+
style: { stroke: seriesColor, lineWidth: 1 },
|
|
1324
|
+
}, {
|
|
1325
|
+
type: "line",
|
|
1326
|
+
shape: {
|
|
1327
|
+
x1: center[0] - capHalf,
|
|
1328
|
+
y1: lo[1],
|
|
1329
|
+
x2: center[0] + capHalf,
|
|
1330
|
+
y2: lo[1],
|
|
1331
|
+
},
|
|
1332
|
+
style: { stroke: seriesColor, lineWidth: 1 },
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
return { type: "group", children };
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
// Excel `<c:overlap>` / `<c:gapWidth>` → echarts barGap /
|
|
1341
|
+
// barCategoryGap (see utils/bar-layout.ts for the exact mapping).
|
|
1342
|
+
// The category-gap conversion needs the number of side-by-side bars
|
|
1343
|
+
// per category slot: 1 when stacked (all series share the slot),
|
|
1344
|
+
// otherwise the count of rendered bar/column series.
|
|
1345
|
+
const sideBySideBarCount = isStacked
|
|
1346
|
+
? 1
|
|
1347
|
+
: (spec.series ?? []).reduce((count, s) => {
|
|
1348
|
+
const seriesType = s.seriesChartType ?? spec.chartType;
|
|
1349
|
+
return seriesType === "bar" || seriesType === "column"
|
|
1350
|
+
? count + Math.max(1, s.sources?.length ?? 0)
|
|
1351
|
+
: count;
|
|
1352
|
+
}, 0);
|
|
1353
|
+
const specBarGap = overlapToBarGap(spec.overlap);
|
|
1354
|
+
const specBarCategoryGap = gapWidthToBarCategoryGap(spec.gapWidth, {
|
|
1355
|
+
seriesCount: sideBySideBarCount,
|
|
1356
|
+
overlap: isStacked ? 0 : spec.overlap,
|
|
1357
|
+
});
|
|
1358
|
+
for (const series of spec.series ?? []) {
|
|
1359
|
+
const dataLabel = !isNil(series.dataLabel) && isAFormula(series.dataLabel)
|
|
1360
|
+
? coerceCalculatedLabel(await onRequestCalculate(series.dataLabel, position.sheetId ?? sheetId, position.overlayPosition.anchorCell.rowIndex, position.overlayPosition.anchorCell.columnIndex))
|
|
1361
|
+
: series.dataLabel ?? "";
|
|
1362
|
+
// Label
|
|
1363
|
+
if (dataLabel) {
|
|
1364
|
+
legend.data?.push(dataLabel);
|
|
1365
|
+
}
|
|
1366
|
+
for (const sheetRange of series.sources) {
|
|
1367
|
+
const data = getSeriesData(spec, sheetRange, {
|
|
1368
|
+
aggregateEnabled: shouldAggregate,
|
|
1369
|
+
aggregateType: series.aggregateType,
|
|
1370
|
+
rawDomainValues: rawPrimaryDomainValues,
|
|
1371
|
+
orderedDomainValues: aggregateDomainValues,
|
|
1372
|
+
});
|
|
1373
|
+
// Support per-series chart type for combo charts
|
|
1374
|
+
const effectiveChartType = series.seriesChartType ?? spec.chartType;
|
|
1375
|
+
const seriesEchartsType = chartTypeToEchartsType(effectiveChartType);
|
|
1376
|
+
const seriesStyleAttrs = getEchartsSeriesStyle(effectiveChartType);
|
|
1377
|
+
const seriesEntry = {
|
|
1378
|
+
type: seriesEchartsType,
|
|
1379
|
+
name: dataLabel,
|
|
1380
|
+
data,
|
|
1381
|
+
stack: isStacked ? "dataLabel" : undefined,
|
|
1382
|
+
smooth: spec.smooth ?? undefined,
|
|
1383
|
+
radius: spec.radius ?? (isCircularChart ? [0, "75%"] : undefined),
|
|
1384
|
+
...seriesStyleAttrs,
|
|
1385
|
+
};
|
|
1386
|
+
// Plot this series against a secondary value axis. The 2nd
|
|
1387
|
+
// yAxis element is appended below (when any series opts in);
|
|
1388
|
+
// here we point the series at index 1.
|
|
1389
|
+
if (series.useSecondaryAxis) {
|
|
1390
|
+
seriesEntry.yAxisIndex = 1;
|
|
1391
|
+
}
|
|
1392
|
+
// Handle invertIfNegative for bar/column charts
|
|
1393
|
+
const isBarOrColumn = effectiveChartType === "bar" || effectiveChartType === "column";
|
|
1394
|
+
// Bar layout (overlap/gapWidth) — echarts reads barGap /
|
|
1395
|
+
// barCategoryGap from bar series (the last bar series wins for
|
|
1396
|
+
// the whole coordinate system), so apply them to every
|
|
1397
|
+
// bar/column series. Set before the gradient/noFill branches so
|
|
1398
|
+
// every push path carries them.
|
|
1399
|
+
if (isBarOrColumn) {
|
|
1400
|
+
if (specBarGap !== undefined) {
|
|
1401
|
+
seriesEntry.barGap = specBarGap;
|
|
1402
|
+
}
|
|
1403
|
+
if (specBarCategoryGap !== undefined) {
|
|
1404
|
+
seriesEntry.barCategoryGap = specBarCategoryGap;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const seriesAny = series;
|
|
1408
|
+
// Gradient fill — overrides solid color when present. echarts
|
|
1409
|
+
// paints it natively via a LinearGradient on itemStyle.color
|
|
1410
|
+
// (and areaStyle for area series / lineStyle for lines).
|
|
1411
|
+
const gradientArgs = gradientToEcharts(series.gradient);
|
|
1412
|
+
if (gradientArgs && !seriesAny.noFill) {
|
|
1413
|
+
const gradient = new echarts.graphic.LinearGradient(gradientArgs.x, gradientArgs.y, gradientArgs.x2, gradientArgs.y2, gradientArgs.colorStops);
|
|
1414
|
+
seriesEntry.itemStyle = {
|
|
1415
|
+
...(seriesEntry.itemStyle ?? {}),
|
|
1416
|
+
color: gradient,
|
|
1417
|
+
};
|
|
1418
|
+
if (effectiveChartType === "area") {
|
|
1419
|
+
seriesEntry.areaStyle = {
|
|
1420
|
+
...(seriesEntry.areaStyle ?? {}),
|
|
1421
|
+
color: gradient,
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
chartSeries.push(seriesEntry);
|
|
1425
|
+
pushSeriesOverlays(series, data, dataLabel);
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
if (seriesAny.noFill) {
|
|
1429
|
+
// Transparent series (Gantt lag offset, etc.) — fill it
|
|
1430
|
+
// with `transparent` so layout is preserved but nothing
|
|
1431
|
+
// paints. Legend entries are also suppressed via `name: ""`
|
|
1432
|
+
// since the series carries no semantic information.
|
|
1433
|
+
seriesEntry.itemStyle = {
|
|
1434
|
+
...(seriesEntry.itemStyle ?? {}),
|
|
1435
|
+
color: "transparent",
|
|
1436
|
+
borderColor: "transparent",
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
else if (isBarOrColumn &&
|
|
1440
|
+
Array.isArray(seriesAny.dataPointColors) &&
|
|
1441
|
+
seriesAny.dataPointColors.length) {
|
|
1442
|
+
// Per-data-point color overrides — Gantt-style charts colour
|
|
1443
|
+
// each task differently via `<c:dPt>`. Echarts accepts an
|
|
1444
|
+
// `itemStyle.color` function that receives the data index;
|
|
1445
|
+
// we look up the override or fall back to the series color.
|
|
1446
|
+
const overrides = new Map();
|
|
1447
|
+
for (const dp of seriesAny.dataPointColors) {
|
|
1448
|
+
if (typeof dp.idx === "number" && typeof dp.color === "string") {
|
|
1449
|
+
overrides.set(dp.idx, dp.color);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const fallback = series.color ?? theme.themeColors.accent1;
|
|
1453
|
+
seriesEntry.itemStyle = {
|
|
1454
|
+
...(seriesEntry.itemStyle ?? {}),
|
|
1455
|
+
color: (params) => overrides.get(params.dataIndex) ?? fallback,
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
else if (isBarOrColumn && series.invertIfNegative) {
|
|
1459
|
+
const positiveColor = series.color ?? theme.themeColors.accent1;
|
|
1460
|
+
const negativeColor = series.negativeColor ?? "#FF0000";
|
|
1461
|
+
// Use a color function that returns different colors based on value
|
|
1462
|
+
seriesEntry.itemStyle = {
|
|
1463
|
+
...(seriesEntry.itemStyle ?? {}),
|
|
1464
|
+
color: (params) => {
|
|
1465
|
+
const value = Array.isArray(params.data)
|
|
1466
|
+
? params.data[1]
|
|
1467
|
+
: params.data?.value ?? params.data;
|
|
1468
|
+
return typeof value === "number" && value < 0
|
|
1469
|
+
? negativeColor
|
|
1470
|
+
: positiveColor;
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
else if (series.color) {
|
|
1475
|
+
seriesEntry.itemStyle = {
|
|
1476
|
+
...(seriesEntry.itemStyle ?? {}),
|
|
1477
|
+
color: series.color,
|
|
1478
|
+
};
|
|
1479
|
+
seriesEntry.lineStyle = {
|
|
1480
|
+
...(seriesEntry.lineStyle ?? {}),
|
|
1481
|
+
color: series.color,
|
|
1482
|
+
};
|
|
1483
|
+
if (effectiveChartType === "area") {
|
|
1484
|
+
seriesEntry.areaStyle = {
|
|
1485
|
+
...(seriesEntry.areaStyle ?? {}),
|
|
1486
|
+
color: series.color,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
chartSeries.push(seriesEntry);
|
|
1491
|
+
pushSeriesOverlays(series, data, dataLabel);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (isOfPie) {
|
|
1495
|
+
// Pie-of-pie / bar-of-pie — Excel splits the minor slices into a
|
|
1496
|
+
// secondary pie or stacked bar beside the main pie, replaces them
|
|
1497
|
+
// with an aggregated "Other" slice, and joins the two plots with
|
|
1498
|
+
// connector lines. The split rule comes from splitType/splitPos
|
|
1499
|
+
// (default: last 3 categories) and the secondary plot's size from
|
|
1500
|
+
// secondPieSize (percent of the main pie, default 75).
|
|
1501
|
+
const labels = rawPrimaryDomainValues.map((v) => String(v ?? ""));
|
|
1502
|
+
const seriesEntry = spec.series?.[0];
|
|
1503
|
+
const rawValues = seriesEntry
|
|
1504
|
+
? flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)))
|
|
1505
|
+
: [];
|
|
1506
|
+
const { main, secondary, otherTotal, total } = splitOfPie(rawValues, labels, {
|
|
1507
|
+
splitType: spec.splitType,
|
|
1508
|
+
splitPos: spec.splitPos,
|
|
1509
|
+
});
|
|
1510
|
+
const ofPieType = resolveOfPieType(spec.ofPieType);
|
|
1511
|
+
const secondScale = clampSecondPieSize(spec.secondPieSize) / 100;
|
|
1512
|
+
const halfAngle = otherSliceHalfAngle(otherTotal, total);
|
|
1513
|
+
const hasSecondary = secondary.length > 0;
|
|
1514
|
+
// Layout, as fractions of the chart container. The main pie sits
|
|
1515
|
+
// left-of-center; the secondary plot on the right. Pie radii are
|
|
1516
|
+
// echarts-style percentages of min(width, height) / 2.
|
|
1517
|
+
const MAIN_CX = 0.3;
|
|
1518
|
+
const CY = 0.5;
|
|
1519
|
+
const MAIN_RADIUS_PCT = 55;
|
|
1520
|
+
const SECONDARY_CX = 0.78;
|
|
1521
|
+
const secondaryRadiusPct = Math.min(80, MAIN_RADIUS_PCT * secondScale);
|
|
1522
|
+
const BAR_LEFT = 0.68;
|
|
1523
|
+
const BAR_RIGHT = 0.88;
|
|
1524
|
+
// Bar-of-pie: the stacked bar's height tracks secondPieSize the
|
|
1525
|
+
// same way the secondary pie's diameter does.
|
|
1526
|
+
const barHalfFrac = Math.min(0.45, Math.max(0.08, 0.28 * secondScale));
|
|
1527
|
+
ofPieLayout = {
|
|
1528
|
+
ofPieType,
|
|
1529
|
+
hasSecondary,
|
|
1530
|
+
barGrid: ofPieType === "bar" && hasSecondary
|
|
1531
|
+
? {
|
|
1532
|
+
left: `${BAR_LEFT * 100}%`,
|
|
1533
|
+
right: `${(1 - BAR_RIGHT) * 100}%`,
|
|
1534
|
+
top: `${(CY - barHalfFrac) * 100}%`,
|
|
1535
|
+
bottom: `${(1 - CY - barHalfFrac) * 100}%`,
|
|
1536
|
+
}
|
|
1537
|
+
: undefined,
|
|
1538
|
+
};
|
|
1539
|
+
chartDomains.length = 0;
|
|
1540
|
+
chartSeries.length = 0;
|
|
1541
|
+
// Palette continues from the main plot into the secondary one so
|
|
1542
|
+
// slices keep distinct colors across both plots (Excel behavior).
|
|
1543
|
+
const ofPiePalette = [
|
|
1544
|
+
theme.themeColors.accent1,
|
|
1545
|
+
theme.themeColors.accent2,
|
|
1546
|
+
theme.themeColors.accent3,
|
|
1547
|
+
theme.themeColors.accent4,
|
|
1548
|
+
theme.themeColors.accent5,
|
|
1549
|
+
theme.themeColors.accent6,
|
|
1550
|
+
];
|
|
1551
|
+
const paletteAt = (index) => ofPiePalette[index % ofPiePalette.length];
|
|
1552
|
+
chartSeries.push({
|
|
1553
|
+
type: "pie",
|
|
1554
|
+
name: seriesEntry?.dataLabel ?? "",
|
|
1555
|
+
center: [`${MAIN_CX * 100}%`, `${CY * 100}%`],
|
|
1556
|
+
radius: [0, `${MAIN_RADIUS_PCT}%`],
|
|
1557
|
+
// Rotate the pie so the "Other" slice's angular midpoint faces
|
|
1558
|
+
// the secondary plot (Excel's layout). Slices sweep clockwise
|
|
1559
|
+
// and Other is last, ending the sweep at startAngle; putting
|
|
1560
|
+
// the midpoint at 0° (3 o'clock) needs startAngle 360 - 180f.
|
|
1561
|
+
startAngle: 360 - (halfAngle * 180) / Math.PI,
|
|
1562
|
+
data: main.map((s, i) => ({
|
|
1563
|
+
name: s.label,
|
|
1564
|
+
value: s.value,
|
|
1565
|
+
itemStyle: { color: paletteAt(i) },
|
|
1566
|
+
})),
|
|
1567
|
+
});
|
|
1568
|
+
if (hasSecondary) {
|
|
1569
|
+
if (ofPieType === "pie") {
|
|
1570
|
+
chartSeries.push({
|
|
1571
|
+
type: "pie",
|
|
1572
|
+
name: seriesEntry?.dataLabel ?? "",
|
|
1573
|
+
center: [`${SECONDARY_CX * 100}%`, `${CY * 100}%`],
|
|
1574
|
+
radius: [0, `${secondaryRadiusPct}%`],
|
|
1575
|
+
data: secondary.map((s, i) => ({
|
|
1576
|
+
name: s.label,
|
|
1577
|
+
value: s.value,
|
|
1578
|
+
itemStyle: { color: paletteAt(main.length + i) },
|
|
1579
|
+
})),
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
else {
|
|
1583
|
+
// Bar-of-pie — a single-category stacked bar on the second
|
|
1584
|
+
// grid, one series per point so each segment keeps its own
|
|
1585
|
+
// color, legend entry and tooltip.
|
|
1586
|
+
secondary.forEach((s, i) => {
|
|
1587
|
+
chartSeries.push({
|
|
1588
|
+
type: "bar",
|
|
1589
|
+
name: s.label,
|
|
1590
|
+
stack: "rnc-of-pie-secondary",
|
|
1591
|
+
xAxisIndex: 1,
|
|
1592
|
+
yAxisIndex: 1,
|
|
1593
|
+
barWidth: "75%",
|
|
1594
|
+
data: [s.value],
|
|
1595
|
+
itemStyle: { color: paletteAt(main.length + i) },
|
|
1596
|
+
tooltip: { formatter: "{a}: {c}" },
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
// Connector lines from the Other slice's edges to the secondary
|
|
1601
|
+
// plot, drawn by a custom series over hidden 0-1 value axes so
|
|
1602
|
+
// they re-render on resize. Geometry mirrors the fractions used
|
|
1603
|
+
// for the pie centers/radii above.
|
|
1604
|
+
const connectorColor = isDarkMode
|
|
1605
|
+
? "rgba(255,255,255,0.45)"
|
|
1606
|
+
: "#999999";
|
|
1607
|
+
chartSeries.push({
|
|
1608
|
+
type: "custom",
|
|
1609
|
+
name: "",
|
|
1610
|
+
silent: true,
|
|
1611
|
+
animation: false,
|
|
1612
|
+
tooltip: { show: false },
|
|
1613
|
+
z: 1,
|
|
1614
|
+
data: [[0, 0]],
|
|
1615
|
+
renderItem: (params) => {
|
|
1616
|
+
const cs = params?.coordSys ?? {};
|
|
1617
|
+
const width = cs.width ?? 0;
|
|
1618
|
+
const height = cs.height ?? 0;
|
|
1619
|
+
if (!(width > 0) || !(height > 0)) {
|
|
1620
|
+
return { type: "group", children: [] };
|
|
1621
|
+
}
|
|
1622
|
+
const x0 = cs.x ?? 0;
|
|
1623
|
+
const y0 = cs.y ?? 0;
|
|
1624
|
+
const r = ((MAIN_RADIUS_PCT / 100) * Math.min(width, height)) / 2;
|
|
1625
|
+
const cx = x0 + MAIN_CX * width;
|
|
1626
|
+
const cy = y0 + CY * height;
|
|
1627
|
+
// Boundary points of the Other slice on the main circle —
|
|
1628
|
+
// ±halfAngle around 3 o'clock (screen y grows downwards).
|
|
1629
|
+
const px = cx + r * Math.cos(halfAngle);
|
|
1630
|
+
const pyTop = cy - r * Math.sin(halfAngle);
|
|
1631
|
+
const pyBottom = cy + r * Math.sin(halfAngle);
|
|
1632
|
+
let tx;
|
|
1633
|
+
let tyTop;
|
|
1634
|
+
let tyBottom;
|
|
1635
|
+
if (ofPieType === "pie") {
|
|
1636
|
+
const r2 = ((secondaryRadiusPct / 100) * Math.min(width, height)) / 2;
|
|
1637
|
+
tx = x0 + SECONDARY_CX * width;
|
|
1638
|
+
tyTop = cy - r2;
|
|
1639
|
+
tyBottom = cy + r2;
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
tx = x0 + BAR_LEFT * width;
|
|
1643
|
+
tyTop = y0 + (CY - barHalfFrac) * height;
|
|
1644
|
+
tyBottom = y0 + (CY + barHalfFrac) * height;
|
|
1645
|
+
}
|
|
1646
|
+
const style = { stroke: connectorColor, lineWidth: 1 };
|
|
1647
|
+
return {
|
|
1648
|
+
type: "group",
|
|
1649
|
+
children: [
|
|
1650
|
+
{
|
|
1651
|
+
type: "line",
|
|
1652
|
+
shape: { x1: px, y1: pyTop, x2: tx, y2: tyTop },
|
|
1653
|
+
style,
|
|
1654
|
+
},
|
|
1655
|
+
{
|
|
1656
|
+
type: "line",
|
|
1657
|
+
shape: { x1: px, y1: pyBottom, x2: tx, y2: tyBottom },
|
|
1658
|
+
style,
|
|
1659
|
+
},
|
|
1660
|
+
],
|
|
1661
|
+
};
|
|
1662
|
+
},
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
// Legend lists every category (main + secondary); the Other slice
|
|
1666
|
+
// only exists in the main pie.
|
|
1667
|
+
legend.data = [
|
|
1668
|
+
...main.map((s) => s.label),
|
|
1669
|
+
...secondary.map((s) => s.label),
|
|
1670
|
+
];
|
|
1671
|
+
}
|
|
1672
|
+
else if (spec.chartType === "waterfall") {
|
|
1673
|
+
const primaryDomainSource = spec?.domains?.[0]?.sources?.[0];
|
|
1674
|
+
const rawDomainValues = primaryDomainSource
|
|
1675
|
+
? sanitizeDomainValues(getDomainValuesFromRange(primaryDomainSource, spec.hiddenDimensionStrategy) ?? [])
|
|
1676
|
+
: [];
|
|
1677
|
+
const labels = rawDomainValues.map((v) => String(v ?? ""));
|
|
1678
|
+
const seriesEntry = spec.series?.[0];
|
|
1679
|
+
const rawValues = seriesEntry
|
|
1680
|
+
? flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)))
|
|
1681
|
+
: [];
|
|
1682
|
+
const totalIndices = new Set(spec.totalIndices ?? []);
|
|
1683
|
+
const steps = computeWaterfall(rawValues, labels, totalIndices);
|
|
1684
|
+
chartDomains.length = 0;
|
|
1685
|
+
chartDomains.push({
|
|
1686
|
+
type: "category",
|
|
1687
|
+
data: steps.map((s) => s.label),
|
|
1688
|
+
show: showAxis,
|
|
1689
|
+
axisLine: { show: true },
|
|
1690
|
+
axisTick: { show: true },
|
|
1691
|
+
splitLine: { show: false },
|
|
1692
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
1693
|
+
nameLocation: "middle",
|
|
1694
|
+
nameGap: 30,
|
|
1695
|
+
});
|
|
1696
|
+
chartSeries.length = 0;
|
|
1697
|
+
chartSeries.push({
|
|
1698
|
+
type: "bar",
|
|
1699
|
+
stack: "waterfall",
|
|
1700
|
+
itemStyle: { color: "transparent" },
|
|
1701
|
+
emphasis: { itemStyle: { color: "transparent" } },
|
|
1702
|
+
data: steps.map((s) => s.placeholder),
|
|
1703
|
+
silent: true,
|
|
1704
|
+
});
|
|
1705
|
+
const accent = seriesEntry?.color ?? theme.themeColors.accent1;
|
|
1706
|
+
const decreaseColor = seriesEntry?.negativeColor ?? theme.themeColors.accent2 ?? accent;
|
|
1707
|
+
const totalColor = theme.themeColors.accent3 ?? accent;
|
|
1708
|
+
// Excel-style connector lines: a horizontal segment at the running
|
|
1709
|
+
// total after bar i, spanning across to bar i+1. Within the bars
|
|
1710
|
+
// the segment overlaps their top/bottom edges, so only the gap
|
|
1711
|
+
// between bars is visually distinct.
|
|
1712
|
+
const connectors = steps.slice(0, -1).map((s, i) => [
|
|
1713
|
+
{ coord: [i, s.cumulative] },
|
|
1714
|
+
{ coord: [i + 1, s.cumulative] },
|
|
1715
|
+
]);
|
|
1716
|
+
chartSeries.push({
|
|
1717
|
+
type: "bar",
|
|
1718
|
+
stack: "waterfall",
|
|
1719
|
+
name: seriesEntry?.dataLabel ?? "",
|
|
1720
|
+
data: steps.map((s, i) => ({
|
|
1721
|
+
value: s.value,
|
|
1722
|
+
itemStyle: {
|
|
1723
|
+
color: s.isTotal
|
|
1724
|
+
? totalColor
|
|
1725
|
+
: rawValues[i] < 0
|
|
1726
|
+
? decreaseColor
|
|
1727
|
+
: accent,
|
|
1728
|
+
},
|
|
1729
|
+
})),
|
|
1730
|
+
markLine: {
|
|
1731
|
+
silent: true,
|
|
1732
|
+
symbol: "none",
|
|
1733
|
+
animation: false,
|
|
1734
|
+
label: { show: false },
|
|
1735
|
+
lineStyle: {
|
|
1736
|
+
color: isDarkMode ? "rgba(255,255,255,0.45)" : "#999999",
|
|
1737
|
+
width: 1,
|
|
1738
|
+
type: "solid",
|
|
1739
|
+
},
|
|
1740
|
+
data: connectors,
|
|
1741
|
+
},
|
|
1742
|
+
});
|
|
1743
|
+
legend.data = [];
|
|
1744
|
+
}
|
|
1745
|
+
else if (spec.chartType === "funnel") {
|
|
1746
|
+
const primaryDomainSource = spec?.domains?.[0]?.sources?.[0];
|
|
1747
|
+
const rawDomainValues = primaryDomainSource
|
|
1748
|
+
? sanitizeDomainValues(getDomainValuesFromRange(primaryDomainSource, spec.hiddenDimensionStrategy) ?? [])
|
|
1749
|
+
: [];
|
|
1750
|
+
const labels = rawDomainValues.map((v) => String(v ?? ""));
|
|
1751
|
+
const seriesEntry = spec.series?.[0];
|
|
1752
|
+
const rawValues = seriesEntry
|
|
1753
|
+
? flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : 0))
|
|
1754
|
+
: [];
|
|
1755
|
+
chartDomains.length = 0;
|
|
1756
|
+
chartSeries.length = 0;
|
|
1757
|
+
chartSeries.push({
|
|
1758
|
+
type: "funnel",
|
|
1759
|
+
name: seriesEntry?.dataLabel ?? "",
|
|
1760
|
+
data: rawValues.map((value, i) => ({
|
|
1761
|
+
name: labels[i] ?? String(i),
|
|
1762
|
+
value,
|
|
1763
|
+
})),
|
|
1764
|
+
label: { show: true, position: "inside" },
|
|
1765
|
+
});
|
|
1766
|
+
legend.data = [];
|
|
1767
|
+
}
|
|
1768
|
+
else if (spec.chartType === "boxplot") {
|
|
1769
|
+
const quartileMethod = spec.quartileMethod;
|
|
1770
|
+
const showOutliers = spec.showOutliers;
|
|
1771
|
+
const showMeanMarker = spec.showMeanMarker === true;
|
|
1772
|
+
const boxStats = [];
|
|
1773
|
+
const boxLabels = [];
|
|
1774
|
+
// [categoryIndex, value] pairs for the outlier / mean overlay
|
|
1775
|
+
// scatter series (only populated when the options are on).
|
|
1776
|
+
const outlierPoints = [];
|
|
1777
|
+
const meanPoints = [];
|
|
1778
|
+
for (const seriesEntry of spec.series ?? []) {
|
|
1779
|
+
const label = seriesEntry.dataLabel && !isAFormula(seriesEntry.dataLabel)
|
|
1780
|
+
? seriesEntry.dataLabel
|
|
1781
|
+
: "";
|
|
1782
|
+
const rawValues = flattenChartDataValues([seriesEntry], getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)));
|
|
1783
|
+
const summary = computeBoxplotSummary(rawValues, { quartileMethod });
|
|
1784
|
+
if (summary) {
|
|
1785
|
+
const boxIndex = boxStats.length;
|
|
1786
|
+
// `showOutliers === true` clips the whiskers to the most
|
|
1787
|
+
// extreme data points inside the 1.5×IQR fences and plots
|
|
1788
|
+
// the points beyond them (Excel's "show outlier points").
|
|
1789
|
+
// Otherwise the whiskers span the full min/max — today's
|
|
1790
|
+
// rendering.
|
|
1791
|
+
const lo = showOutliers === true ? summary.lowerWhisker : summary.min;
|
|
1792
|
+
const hi = showOutliers === true ? summary.upperWhisker : summary.max;
|
|
1793
|
+
boxStats.push([lo, summary.q1, summary.median, summary.q3, hi]);
|
|
1794
|
+
boxLabels.push(label);
|
|
1795
|
+
if (showOutliers === true) {
|
|
1796
|
+
for (const outlier of summary.outliers) {
|
|
1797
|
+
outlierPoints.push([boxIndex, outlier]);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
if (showMeanMarker) {
|
|
1801
|
+
meanPoints.push([boxIndex, summary.mean]);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
chartDomains.length = 0;
|
|
1806
|
+
chartDomains.push({
|
|
1807
|
+
type: "category",
|
|
1808
|
+
data: boxLabels,
|
|
1809
|
+
show: showAxis,
|
|
1810
|
+
axisLine: { show: true },
|
|
1811
|
+
axisTick: { show: true },
|
|
1812
|
+
splitLine: { show: false },
|
|
1813
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
1814
|
+
nameLocation: "middle",
|
|
1815
|
+
nameGap: 30,
|
|
1816
|
+
});
|
|
1817
|
+
chartSeries.length = 0;
|
|
1818
|
+
const boxSeries = {
|
|
1819
|
+
type: "boxplot",
|
|
1820
|
+
name: "",
|
|
1821
|
+
data: boxStats,
|
|
1822
|
+
};
|
|
1823
|
+
const boxColor = spec.series?.[0]?.color;
|
|
1824
|
+
if (boxColor) {
|
|
1825
|
+
boxSeries.itemStyle = { color: boxColor, borderColor: boxColor };
|
|
1826
|
+
}
|
|
1827
|
+
chartSeries.push(boxSeries);
|
|
1828
|
+
if (outlierPoints.length > 0) {
|
|
1829
|
+
chartSeries.push({
|
|
1830
|
+
type: "scatter",
|
|
1831
|
+
name: "",
|
|
1832
|
+
data: outlierPoints,
|
|
1833
|
+
symbolSize: 6,
|
|
1834
|
+
...(boxColor ? { itemStyle: { color: boxColor } } : {}),
|
|
1835
|
+
tooltip: { show: false },
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
if (meanPoints.length > 0) {
|
|
1839
|
+
chartSeries.push({
|
|
1840
|
+
type: "scatter",
|
|
1841
|
+
name: "",
|
|
1842
|
+
data: meanPoints,
|
|
1843
|
+
symbol: "diamond",
|
|
1844
|
+
symbolSize: 9,
|
|
1845
|
+
...(boxColor ? { itemStyle: { color: boxColor } } : {}),
|
|
1846
|
+
tooltip: { show: false },
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
legend.data = [];
|
|
1850
|
+
}
|
|
1851
|
+
else if (spec.chartType === "histogram") {
|
|
1852
|
+
const rawValues = flattenChartDataValues(spec.series, getSeriesValuesFromRange, spec.hiddenDimensionStrategy).map((v) => (typeof v === "number" ? v : Number(v)));
|
|
1853
|
+
let { labels, counts } = binHistogram(rawValues, spec.binCount);
|
|
1854
|
+
// Pareto — a histogram whose bins are sorted descending with a
|
|
1855
|
+
// cumulative-% line overlaid on a secondary 0-100% axis. The
|
|
1856
|
+
// authored type arrives as `sourceChartType: "PARETO"` on an
|
|
1857
|
+
// otherwise plain histogram spec.
|
|
1858
|
+
const isPareto = isParetoSpec(spec);
|
|
1859
|
+
let cumulativePercents = [];
|
|
1860
|
+
if (isPareto) {
|
|
1861
|
+
const pareto = computePareto({ labels, counts });
|
|
1862
|
+
labels = pareto.labels;
|
|
1863
|
+
counts = pareto.counts;
|
|
1864
|
+
cumulativePercents = pareto.cumulativePercents;
|
|
1865
|
+
}
|
|
1866
|
+
chartDomains.length = 0;
|
|
1867
|
+
chartDomains.push({
|
|
1868
|
+
type: "category",
|
|
1869
|
+
data: labels,
|
|
1870
|
+
show: showAxis,
|
|
1871
|
+
axisLine: { show: true },
|
|
1872
|
+
axisTick: { show: true },
|
|
1873
|
+
splitLine: { show: false },
|
|
1874
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
1875
|
+
nameLocation: "middle",
|
|
1876
|
+
nameGap: 30,
|
|
1877
|
+
});
|
|
1878
|
+
chartSeries.length = 0;
|
|
1879
|
+
const histogramSeries = {
|
|
1880
|
+
type: "bar",
|
|
1881
|
+
name: spec.series?.[0]?.dataLabel ?? "",
|
|
1882
|
+
data: counts,
|
|
1883
|
+
barCategoryGap: "1%",
|
|
1884
|
+
};
|
|
1885
|
+
const histogramColor = spec.series?.[0]?.color;
|
|
1886
|
+
if (histogramColor) {
|
|
1887
|
+
histogramSeries.itemStyle = { color: histogramColor };
|
|
1888
|
+
}
|
|
1889
|
+
chartSeries.push(histogramSeries);
|
|
1890
|
+
if (isPareto) {
|
|
1891
|
+
// Cumulative-% overlay on the secondary axis (yAxisIndex 1);
|
|
1892
|
+
// the matching 0-100% axis element is appended with the other
|
|
1893
|
+
// value axes below.
|
|
1894
|
+
chartSeries.push({
|
|
1895
|
+
type: "line",
|
|
1896
|
+
name: "Cumulative %",
|
|
1897
|
+
data: cumulativePercents,
|
|
1898
|
+
yAxisIndex: 1,
|
|
1899
|
+
symbol: "circle",
|
|
1900
|
+
symbolSize: 6,
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
legend.data = [];
|
|
1904
|
+
}
|
|
1905
|
+
// Only redraw chart
|
|
1906
|
+
// if chart type has changed
|
|
1907
|
+
// if series count has changed. Use the rendered series count
|
|
1908
|
+
// (`chartSeries.length`) rather than `spec.series.length` because
|
|
1909
|
+
// trendline / error-bar overlays add extra series; comparing the
|
|
1910
|
+
// raw spec count would miss those and skip the structural redraw.
|
|
1911
|
+
const renderedSeriesCount = chartSeries.length;
|
|
1912
|
+
const replaceMerge = previousChartType.current !== spec.chartType ||
|
|
1913
|
+
previousSeriesCount.current !== renderedSeriesCount
|
|
1914
|
+
? ["series", "xAxis", "yAxis", "legend", "grid", "tooltip"]
|
|
1915
|
+
: [];
|
|
1916
|
+
// Of-pie uses a full-container grid (for the connector overlay's
|
|
1917
|
+
// hidden 0-1 axes) plus, for bar-of-pie, a second grid hosting the
|
|
1918
|
+
// stacked secondary bar.
|
|
1919
|
+
const gridConfig = isOfPie
|
|
1920
|
+
? [
|
|
1921
|
+
{ left: 0, right: 0, top: 0, bottom: 0 },
|
|
1922
|
+
...(ofPieLayout?.barGrid ? [ofPieLayout.barGrid] : []),
|
|
1923
|
+
]
|
|
1924
|
+
: isCircularChart
|
|
1925
|
+
? {
|
|
1926
|
+
left: 0,
|
|
1927
|
+
right: 0,
|
|
1928
|
+
top: 0,
|
|
1929
|
+
bottom: 0,
|
|
1930
|
+
}
|
|
1931
|
+
: {
|
|
1932
|
+
left: "3%",
|
|
1933
|
+
right: "4%",
|
|
1934
|
+
bottom: "3%",
|
|
1935
|
+
containLabel: true,
|
|
1936
|
+
};
|
|
1937
|
+
const valueAxisBase = {
|
|
1938
|
+
type: "value",
|
|
1939
|
+
show: !isCircularChart,
|
|
1940
|
+
axisLine: { show: !isCircularChart },
|
|
1941
|
+
axisTick: { show: !isCircularChart },
|
|
1942
|
+
splitLine: { show: !isCircularChart },
|
|
1943
|
+
nameLocation: "middle",
|
|
1944
|
+
nameGap: 50,
|
|
1945
|
+
};
|
|
1946
|
+
// Secondary (right-hand) value axis. Series with useSecondaryAxis
|
|
1947
|
+
// were pointed at yAxisIndex 1 in the loop above; the matching axis
|
|
1948
|
+
// element is appended here. Pareto histograms always carry one for
|
|
1949
|
+
// the cumulative-% line, clamped to 0-100%.
|
|
1950
|
+
const isPareto = spec.chartType === "histogram" && isParetoSpec(spec);
|
|
1951
|
+
const hasSecondaryAxis = !isCircularChart &&
|
|
1952
|
+
!isHorizontalBarChart &&
|
|
1953
|
+
((spec.series ?? []).some((s) => s.useSecondaryAxis) || isPareto);
|
|
1954
|
+
const tooltipConfig = {
|
|
1955
|
+
textStyle: {
|
|
1956
|
+
fontSize: 12,
|
|
1957
|
+
},
|
|
1958
|
+
trigger: isCircularChart ? "item" : "axis",
|
|
1959
|
+
formatter: isCircularChart ? "{b}: {c} ({d}%)" : undefined,
|
|
1960
|
+
};
|
|
1961
|
+
// Phase 5.1: apply legend / axis / dataLabels / doughnut overrides.
|
|
1962
|
+
const finalLegend = applyLegendOverrides(legend, spec.legend);
|
|
1963
|
+
const seriesLabelOption = buildSeriesLabelOption(spec.dataLabels);
|
|
1964
|
+
// For doughnut, surface the inner-radius hole on each pie series.
|
|
1965
|
+
// Excel persists `holeSize` as a 0-90 percentage; we normalize to a
|
|
1966
|
+
// 0-1 fraction (doughnutHoleSize). Default to 0.5 when unset.
|
|
1967
|
+
const isDoughnut = spec.chartType === "doughnut";
|
|
1968
|
+
const doughnutHoleSize = spec.doughnutHoleSize;
|
|
1969
|
+
// PERCENT_STACKED — echarts has no built-in `barnorm: 'percent'`,
|
|
1970
|
+
// so we normalize per category here: each cell becomes
|
|
1971
|
+
// (value / category_total) * 100, while the original value is
|
|
1972
|
+
// tucked into a sibling field so the label formatter can still
|
|
1973
|
+
// surface the dollar amount. Touches only bar/column series in
|
|
1974
|
+
// the percent-stacked spec; other charts pass through unchanged.
|
|
1975
|
+
if (spec.stackedType === "PERCENT_STACKED" &&
|
|
1976
|
+
(spec.chartType === "bar" || spec.chartType === "column")) {
|
|
1977
|
+
const barSeries = chartSeries.filter((s) => s?.type === "bar");
|
|
1978
|
+
// Length of the value axis = max length across stacked series.
|
|
1979
|
+
const len = barSeries.reduce((m, s) => Math.max(m, Array.isArray(s.data) ? s.data.length : 0), 0);
|
|
1980
|
+
const categoryTotals = new Array(len).fill(0);
|
|
1981
|
+
for (const s of barSeries) {
|
|
1982
|
+
if (!Array.isArray(s.data))
|
|
1983
|
+
continue;
|
|
1984
|
+
for (let i = 0; i < s.data.length; i++) {
|
|
1985
|
+
const raw = s.data[i];
|
|
1986
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
1987
|
+
if (Number.isFinite(n))
|
|
1988
|
+
categoryTotals[i] += n;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
for (const s of barSeries) {
|
|
1992
|
+
if (!Array.isArray(s.data))
|
|
1993
|
+
continue;
|
|
1994
|
+
s.data = s.data.map((raw, i) => {
|
|
1995
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
1996
|
+
const total = categoryTotals[i];
|
|
1997
|
+
if (!Number.isFinite(n) || total === 0) {
|
|
1998
|
+
return { value: 0, originalValue: raw };
|
|
1999
|
+
}
|
|
2000
|
+
return { value: (n / total) * 100, originalValue: n };
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
const seriesWithLabels = chartSeries.map((s) => {
|
|
2005
|
+
const next = { ...s };
|
|
2006
|
+
if (seriesLabelOption) {
|
|
2007
|
+
next.label = { ...(s?.label ?? {}), ...seriesLabelOption };
|
|
2008
|
+
}
|
|
2009
|
+
if (isDoughnut && next.type === "pie") {
|
|
2010
|
+
const inner = typeof doughnutHoleSize === "number"
|
|
2011
|
+
? Math.round(Math.max(0, Math.min(0.9, doughnutHoleSize)) * 100)
|
|
2012
|
+
: 50;
|
|
2013
|
+
next.radius = [`${inner}%`, "75%"];
|
|
2014
|
+
}
|
|
2015
|
+
return next;
|
|
2016
|
+
});
|
|
2017
|
+
// Update charts
|
|
2018
|
+
chartRef.current?.setOption({
|
|
2019
|
+
xAxis: (() => {
|
|
2020
|
+
if (isOfPie) {
|
|
2021
|
+
// Hidden 0-1 value axis on the full-container grid for the
|
|
2022
|
+
// connector overlay, plus a hidden single-category axis on
|
|
2023
|
+
// the secondary grid for bar-of-pie.
|
|
2024
|
+
const axes = [
|
|
2025
|
+
{
|
|
2026
|
+
type: "value",
|
|
2027
|
+
min: 0,
|
|
2028
|
+
max: 1,
|
|
2029
|
+
show: false,
|
|
2030
|
+
axisLine: { show: false },
|
|
2031
|
+
axisTick: { show: false },
|
|
2032
|
+
splitLine: { show: false },
|
|
2033
|
+
},
|
|
2034
|
+
];
|
|
2035
|
+
if (ofPieLayout?.barGrid) {
|
|
2036
|
+
axes.push({
|
|
2037
|
+
type: "category",
|
|
2038
|
+
data: [""],
|
|
2039
|
+
gridIndex: 1,
|
|
2040
|
+
show: false,
|
|
2041
|
+
axisLine: { show: false },
|
|
2042
|
+
axisTick: { show: false },
|
|
2043
|
+
splitLine: { show: false },
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
return axes;
|
|
2047
|
+
}
|
|
2048
|
+
if (isCircularChart) {
|
|
2049
|
+
return [
|
|
2050
|
+
{
|
|
2051
|
+
type: "category",
|
|
2052
|
+
show: false,
|
|
2053
|
+
axisLine: { show: false },
|
|
2054
|
+
axisTick: { show: false },
|
|
2055
|
+
splitLine: { show: false },
|
|
2056
|
+
},
|
|
2057
|
+
];
|
|
2058
|
+
}
|
|
2059
|
+
if (isHorizontalBarChart) {
|
|
2060
|
+
return [
|
|
2061
|
+
applyAxisOverrides({
|
|
2062
|
+
...valueAxisBase,
|
|
2063
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
2064
|
+
}, spec.valueAxisOptions),
|
|
2065
|
+
];
|
|
2066
|
+
}
|
|
2067
|
+
const base = chartDomains.length > 0
|
|
2068
|
+
? chartDomains
|
|
2069
|
+
: [
|
|
2070
|
+
{
|
|
2071
|
+
type: "category",
|
|
2072
|
+
show: showAxis,
|
|
2073
|
+
axisLine: { show: true },
|
|
2074
|
+
axisTick: { show: true },
|
|
2075
|
+
splitLine: { show: true },
|
|
2076
|
+
name: spec.horizontalAxisTitle ?? "",
|
|
2077
|
+
nameLocation: "middle",
|
|
2078
|
+
nameGap: 30,
|
|
2079
|
+
},
|
|
2080
|
+
];
|
|
2081
|
+
return base.map((axis) => applyAxisOverrides(axis, spec.categoryAxisOptions));
|
|
2082
|
+
})(),
|
|
2083
|
+
yAxis: (() => {
|
|
2084
|
+
if (isOfPie) {
|
|
2085
|
+
const axes = [
|
|
2086
|
+
{
|
|
2087
|
+
...valueAxisBase,
|
|
2088
|
+
min: 0,
|
|
2089
|
+
max: 1,
|
|
2090
|
+
show: false,
|
|
2091
|
+
axisLine: { show: false },
|
|
2092
|
+
axisTick: { show: false },
|
|
2093
|
+
splitLine: { show: false },
|
|
2094
|
+
name: undefined,
|
|
2095
|
+
},
|
|
2096
|
+
];
|
|
2097
|
+
if (ofPieLayout?.barGrid) {
|
|
2098
|
+
axes.push({
|
|
2099
|
+
type: "value",
|
|
2100
|
+
gridIndex: 1,
|
|
2101
|
+
show: false,
|
|
2102
|
+
axisLine: { show: false },
|
|
2103
|
+
axisTick: { show: false },
|
|
2104
|
+
splitLine: { show: false },
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
return axes;
|
|
2108
|
+
}
|
|
2109
|
+
if (isCircularChart) {
|
|
2110
|
+
return [
|
|
2111
|
+
{
|
|
2112
|
+
...valueAxisBase,
|
|
2113
|
+
show: false,
|
|
2114
|
+
axisLine: { show: false },
|
|
2115
|
+
axisTick: { show: false },
|
|
2116
|
+
splitLine: { show: false },
|
|
2117
|
+
name: undefined,
|
|
2118
|
+
},
|
|
2119
|
+
];
|
|
2120
|
+
}
|
|
2121
|
+
if (isHorizontalBarChart) {
|
|
2122
|
+
const base = chartDomains.length > 0
|
|
2123
|
+
? chartDomains
|
|
2124
|
+
: [
|
|
2125
|
+
{
|
|
2126
|
+
type: "category",
|
|
2127
|
+
show: showAxis,
|
|
2128
|
+
axisLine: { show: true },
|
|
2129
|
+
axisTick: { show: true },
|
|
2130
|
+
splitLine: { show: true },
|
|
2131
|
+
name: spec.verticalAxisTitle ?? "",
|
|
2132
|
+
nameLocation: "middle",
|
|
2133
|
+
nameGap: 30,
|
|
2134
|
+
},
|
|
2135
|
+
];
|
|
2136
|
+
return base.map((axis) => applyAxisOverrides(axis, spec.categoryAxisOptions));
|
|
2137
|
+
}
|
|
2138
|
+
const axes = [
|
|
2139
|
+
applyAxisOverrides({ ...valueAxisBase, name: spec.verticalAxisTitle ?? "" }, spec.valueAxisOptions),
|
|
2140
|
+
];
|
|
2141
|
+
if (hasSecondaryAxis) {
|
|
2142
|
+
axes.push(applyAxisOverrides({
|
|
2143
|
+
...valueAxisBase,
|
|
2144
|
+
name: "",
|
|
2145
|
+
splitLine: { show: false },
|
|
2146
|
+
// Pareto — the cumulative-% axis is clamped 0-100%.
|
|
2147
|
+
...(isPareto
|
|
2148
|
+
? {
|
|
2149
|
+
min: 0,
|
|
2150
|
+
max: 100,
|
|
2151
|
+
axisLabel: { formatter: "{value}%" },
|
|
2152
|
+
}
|
|
2153
|
+
: {}),
|
|
2154
|
+
}, spec.secondaryValueAxisOptions));
|
|
2155
|
+
}
|
|
2156
|
+
return axes;
|
|
2157
|
+
})(),
|
|
2158
|
+
series: seriesWithLabels,
|
|
2159
|
+
legend: finalLegend,
|
|
2160
|
+
grid: gridConfig,
|
|
2161
|
+
tooltip: tooltipConfig,
|
|
2162
|
+
}, { replaceMerge, lazyUpdate: true });
|
|
2163
|
+
previousChartType.current = spec.chartType;
|
|
2164
|
+
previousSeriesCount.current = renderedSeriesCount;
|
|
2165
|
+
};
|
|
2166
|
+
useIsomorphicLayoutEffect(() => {
|
|
2167
|
+
echarts.use([
|
|
2168
|
+
TitleComponent,
|
|
2169
|
+
TooltipComponent,
|
|
2170
|
+
GridComponent,
|
|
2171
|
+
LegendComponent,
|
|
2172
|
+
RadarComponent,
|
|
2173
|
+
BarChart,
|
|
2174
|
+
BoxplotChart,
|
|
2175
|
+
CandlestickChart,
|
|
2176
|
+
CustomChart,
|
|
2177
|
+
FunnelChart,
|
|
2178
|
+
LineChart,
|
|
2179
|
+
PieChart,
|
|
2180
|
+
EchartsRadarChart,
|
|
2181
|
+
ScatterChart,
|
|
2182
|
+
EchartsTreemapChart,
|
|
2183
|
+
SunburstChart,
|
|
2184
|
+
CanvasRenderer,
|
|
2185
|
+
DatasetComponent,
|
|
2186
|
+
]);
|
|
2187
|
+
initializeChart();
|
|
2188
|
+
}, []);
|
|
2189
|
+
// Subscribe to all chart data sources (domains, series, etc.)
|
|
2190
|
+
const chartDataRanges = useMemo(() => getChartDataRanges(chart.spec), [chart.spec]);
|
|
2191
|
+
useSubscribeToMultipleRanges(chart.chartId, chartDataRanges, updateChart);
|
|
2192
|
+
// Theme-derived chart properties (title/legend/tooltip colors, series
|
|
2193
|
+
// palette). Shared by the theme effect below and the 3-D / region-map
|
|
2194
|
+
// pipelines, which re-initialize the echarts instance when switching
|
|
2195
|
+
// between the cartesian 2-D option and the grid3D / map one and must
|
|
2196
|
+
// re-apply these.
|
|
2197
|
+
const applyThemeOptions = () => {
|
|
2198
|
+
if (chartRef.current) {
|
|
2199
|
+
const textColor = getStringifiedColor({
|
|
2200
|
+
theme: 1,
|
|
2201
|
+
tint: isDarkMode ? 1 : 0,
|
|
2202
|
+
}, theme);
|
|
2203
|
+
const bgColor = getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme);
|
|
2204
|
+
const showAxis = showXAxis(chart.spec.chartType);
|
|
2205
|
+
// Authored chart-level text formats override the theme-derived
|
|
2206
|
+
// defaults. Title, legend, and data-label colors flow through
|
|
2207
|
+
// here; per-axis label colors land on each axis via
|
|
2208
|
+
// `applyAxisOverrides` later in the layout build.
|
|
2209
|
+
const authoredTitleFmt = chart.spec.titleTextFormat;
|
|
2210
|
+
const authoredLegendFmt = chart.spec.legend?.textFormat;
|
|
2211
|
+
const authoredDataLabelFmt = chart.spec.dataLabels?.textFormat;
|
|
2212
|
+
const options = {
|
|
2213
|
+
title: {
|
|
2214
|
+
text: title ?? undefined,
|
|
2215
|
+
textStyle: {
|
|
2216
|
+
color: authoredTitleFmt?.color ?? textColor,
|
|
2217
|
+
...(authoredTitleFmt?.fontFamily
|
|
2218
|
+
? { fontFamily: authoredTitleFmt.fontFamily }
|
|
2219
|
+
: {}),
|
|
2220
|
+
...(typeof authoredTitleFmt?.fontSize === "number"
|
|
2221
|
+
? { fontSize: authoredTitleFmt.fontSize }
|
|
2222
|
+
: {}),
|
|
2223
|
+
...(authoredTitleFmt?.bold ? { fontWeight: "bold" } : {}),
|
|
2224
|
+
...(authoredTitleFmt?.italic ? { fontStyle: "italic" } : {}),
|
|
2225
|
+
},
|
|
2226
|
+
},
|
|
2227
|
+
legend: {
|
|
2228
|
+
textStyle: {
|
|
2229
|
+
color: authoredLegendFmt?.color ?? textColor,
|
|
2230
|
+
...(authoredLegendFmt?.fontFamily
|
|
2231
|
+
? { fontFamily: authoredLegendFmt.fontFamily }
|
|
2232
|
+
: {}),
|
|
2233
|
+
...(typeof authoredLegendFmt?.fontSize === "number"
|
|
2234
|
+
? { fontSize: authoredLegendFmt.fontSize }
|
|
2235
|
+
: {}),
|
|
2236
|
+
},
|
|
2237
|
+
},
|
|
2238
|
+
axisLabel: {
|
|
2239
|
+
color: authoredDataLabelFmt?.color ?? textColor,
|
|
2240
|
+
},
|
|
2241
|
+
tooltip: {
|
|
2242
|
+
backgroundColor: bgColor,
|
|
2243
|
+
textStyle: {
|
|
2244
|
+
color: textColor,
|
|
2245
|
+
},
|
|
2246
|
+
},
|
|
2247
|
+
color: [
|
|
2248
|
+
theme.themeColors.accent1,
|
|
2249
|
+
theme.themeColors.accent2,
|
|
2250
|
+
theme.themeColors.accent3,
|
|
2251
|
+
theme.themeColors.accent4,
|
|
2252
|
+
theme.themeColors.accent5,
|
|
2253
|
+
theme.themeColors.accent6,
|
|
2254
|
+
],
|
|
2255
|
+
};
|
|
2256
|
+
// Skip when rendering 3-D or a region map: merging a cartesian
|
|
2257
|
+
// yAxis into a grid3D / map option would create a stray empty
|
|
2258
|
+
// 2-D axis over the WebGL scene / choropleth.
|
|
2259
|
+
if (showAxis &&
|
|
2260
|
+
!renderedIn3D.current &&
|
|
2261
|
+
!renderedAsRegionMap.current) {
|
|
2262
|
+
options.yAxis = {
|
|
2263
|
+
splitLine: {
|
|
2264
|
+
lineStyle: {
|
|
2265
|
+
color: isDarkMode ? "rgba(255,255,255,0.3)" : "#CCCCCC",
|
|
2266
|
+
},
|
|
2267
|
+
},
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
chartRef.current.setOption(options);
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
// Update chart properties
|
|
2274
|
+
useIsomorphicLayoutEffect(() => {
|
|
2275
|
+
applyThemeOptions();
|
|
2276
|
+
}, [theme, title, isDarkMode, chart.spec.chartType]);
|
|
2277
|
+
// Draw chart. `glUpgradeTick` re-runs the draw when the lazily loaded
|
|
2278
|
+
// echarts-gl module resolves, upgrading a 3-D spec's immediate 2-D
|
|
2279
|
+
// fallback to the true 3-D rendering.
|
|
2280
|
+
useIsomorphicLayoutEffect(() => {
|
|
2281
|
+
updateChart();
|
|
2282
|
+
}, [
|
|
2283
|
+
chart.spec,
|
|
2284
|
+
getDomainValuesFromRange,
|
|
2285
|
+
getSeriesValuesFromRange,
|
|
2286
|
+
glUpgradeTick,
|
|
2287
|
+
]);
|
|
2288
|
+
// Resize chart
|
|
2289
|
+
useIsomorphicLayoutEffect(() => {
|
|
2290
|
+
resizeChart();
|
|
2291
|
+
}, [
|
|
2292
|
+
chart.position.overlayPosition.widthPixels,
|
|
2293
|
+
chart.position.overlayPosition.heightPixels,
|
|
2294
|
+
]);
|
|
2295
|
+
// Chrome bug
|
|
2296
|
+
useOnVisibilityChange((state) => state === "visible" ? resizeChart() : null);
|
|
2297
|
+
useIsomorphicLayoutEffect(() => {
|
|
2298
|
+
chartRef.current?.resize({ silent: true });
|
|
2299
|
+
}, [width, height]);
|
|
2300
|
+
const onDownloadChart = () => {
|
|
2301
|
+
if (chartRef.current) {
|
|
2302
|
+
const url = chartRef.current.getDataURL({
|
|
2303
|
+
type: "png",
|
|
2304
|
+
backgroundColor: getStringifiedColor({ theme: 0, tint: isDarkMode ? -1 : 1 }, theme),
|
|
2305
|
+
});
|
|
2306
|
+
createDownloadableFile(chart.spec.title ?? "Chart", url);
|
|
2307
|
+
}
|
|
2308
|
+
};
|
|
2309
|
+
// Excel's "Rounded Corners" chart style (`<c:roundedCorners val="1"/>`
|
|
2310
|
+
// at the chartSpace root). Default per OOXML §21.2.2.183 is square,
|
|
2311
|
+
// so only opt in when the workbook explicitly set the flag — otherwise
|
|
2312
|
+
// every imported chart would silently gain a radius.
|
|
2313
|
+
const roundedCornersStyle = spec.roundedCorners
|
|
2314
|
+
? { borderRadius: 8, overflow: "hidden" }
|
|
2315
|
+
: undefined;
|
|
2316
|
+
return (React.createElement("div", { className: "flex flex-1", style: roundedCornersStyle },
|
|
2317
|
+
isSelected ? (React.createElement(DropdownMenu, null,
|
|
2318
|
+
React.createElement(DropdownMenuTrigger, { asChild: true },
|
|
2319
|
+
React.createElement(IconButton, { className: "absolute right-1 top-1 z-10" },
|
|
2320
|
+
React.createElement(DotsVerticalIcon, null))),
|
|
2321
|
+
React.createElement(DropdownMenuPortal, null,
|
|
2322
|
+
React.createElement(DropdownMenuContent, { align: "start", onCloseAutoFocus: (event) => {
|
|
2323
|
+
event.preventDefault();
|
|
2324
|
+
if (shouldFocusSheetRef.current) {
|
|
2325
|
+
focusSheet?.();
|
|
2326
|
+
}
|
|
2327
|
+
}, onInteractOutside: () => {
|
|
2328
|
+
shouldFocusSheetRef.current = false;
|
|
2329
|
+
} },
|
|
2330
|
+
readonly ? null : (React.createElement(DropdownMenuItem, { onClick: () => onRequestEdit?.(chart) }, "Edit chart")),
|
|
2331
|
+
readonly ? null : (React.createElement(DropdownMenuItem, { onClick: () => {
|
|
2332
|
+
shouldFocusSheetRef.current = true;
|
|
2333
|
+
onDeleteChart?.(chart.chartId);
|
|
2334
|
+
} }, "Delete chart")),
|
|
2335
|
+
React.createElement(DropdownMenuSub, null,
|
|
2336
|
+
React.createElement(DropdownMenuSubTrigger, null,
|
|
2337
|
+
"Download chart",
|
|
2338
|
+
React.createElement(DropdownRightSlot, null,
|
|
2339
|
+
React.createElement(ChevronRightIcon, null))),
|
|
2340
|
+
React.createElement(DropdownMenuSubContent, null,
|
|
2341
|
+
React.createElement(DropdownMenuItem, { onClick: () => onDownloadChart?.() }, "PNG Image (.png)"))))))) : null,
|
|
2342
|
+
React.createElement("div", { className: "flex-1", ref: el })));
|
|
2343
|
+
};
|