import type { Theme } from '@mui/material' import type { EChartsOption } from 'echarts' import * as echarts from 'echarts' import type { CallbackDataParams } from 'echarts/types/dist/shared' import { buildGridConfig, buildLegendConfig, buildSeriesLabelConfig, createTooltipFormatter, createTooltipPositioner, niceNum, } from '../../widgets/utils/chart-config' import type { OptionFactory, OptionFactoryContext } from '../echart' import { mergeOptions, resolveThemeColor } from '../utils' import type { WidgetSeries } from '../types' import type { PieEChartsOption, PieOptionFactoryInput, PieOptionsInput, PieWidgetData, } from './types' const DEFAULT_RADIUS: readonly [string, string] = ['58%', '74%'] /** * Builds the **structural** ECharts option for a pie / donut widget. * Mirrors v1's pie look-and-feel: themed legend (`buildLegendConfig`), * themed tooltip via `createTooltipPositioner` / `createTooltipFormatter`, * `qualitative.bold` color palette, slice borders against the paper * background, hover-emphasis disabled, and a centered rich-text label. * * Layered like Bar / Histogram: * - **Theme-aware bits** live here on a series template (slice border, * center-label rich style sizes, emphasis-disabled). * - **Reactive bits** (tooltip value formatter, center-label text, * selection-driven dim) are re-emitted by * {@link createPieOptionFactory} at fusion time so RelativeData's * percent override flows through. */ export function pieOptions({ theme, formatter, labelFormatter, }: PieOptionsInput): PieEChartsOption { return { legend: { ...buildLegendConfig({ hasLegend: true, labelFormatter }), // Pie reads better with a centered legend (the donut is radially // symmetric — left-aligning the legend below it visually unbalances // the chart). Override `buildLegendConfig`'s `type: 'scroll'` with // `'plain'` so long category lists wrap to multiple rows instead // of clipping the last visible item against the chart container. // The wider `itemGap` gives chips breathing room in the typical // 3–6 category case. left: 'center', type: 'scroll', itemGap: 16, }, tooltip: { // Pie uses item-trigger (no axis); the rest of the styling mirrors // bar/histogram so the dashboard reads consistently — dark // grey[900] bg, white caption-font text, padded, with the shared // overflow-aware positioner. trigger: 'item', backgroundColor: theme.palette.grey[900], borderWidth: 0, padding: [parseInt(theme.spacing(1)), parseInt(theme.spacing(1))], textStyle: { color: theme.palette.common.white, fontSize: 11, fontFamily: theme.typography.caption.fontFamily, }, position: createTooltipPositioner(theme), formatter: buildPieTooltipFormatter(formatter, labelFormatter), }, // V1 used `qualitative.bold` only (no `secondary.main` prefix) because // pie slices are categorical — each slice deserves a distinct palette // entry from the start, not a primary highlight. color: Object.values( (theme.palette as { qualitative?: { bold?: Record } }) .qualitative?.bold ?? {}, ), // Pie template. The merger spreads this into each per-data series it // emits — borderColor / borderWidth / emphasis-disabled / label rich // sizes all survive through. series: [ { type: 'pie', colorBy: 'data', avoidLabelOverlap: true, selectedOffset: 0, emphasis: { disabled: true }, itemStyle: { borderColor: theme.palette.background.paper, borderWidth: 1, }, label: { show: true, position: 'center', rich: { b: { fontSize: 16, fontWeight: 'normal', lineHeight: 20 }, c: { fontSize: 28, fontWeight: 'bold', lineHeight: 27 }, }, }, }, ], } } /** * Returns the pie widget's {@link OptionFactory} — one closure that owns * BOTH phases of option construction: * * - **Structural phase** (`option == null`) — builds the theme-aware * structural option via {@link pieOptions}, optionally merging the * consumer-supplied `optionsOverride` on top. Called once by Provider * to seed `rawOptions` in the store. * - **Merge phase** (`option != null`) — fuses post-pipeline `state.data` * (`PieWidgetData`) into the option via the dataset API. Single-series * → donut (one dataset, `series[i].encode = { itemName: 'name', value: 'value' }`). * Multi-series → horizontal-bar fallback (mirrors v1 pie). Reactive * formatters from `ctx` drive the tooltip + center label at fusion * time so RelativeData's percent formatter flows through without a * structural rebuild. */ export function createPieOptionFactory( options: PieOptionFactoryInput, ): OptionFactory { const { theme, formatter, labelFormatter, optionsOverride, series } = options const radius = options.radius ?? DEFAULT_RADIUS const selection = options.selection const selectionSet = selection && selection.length > 0 ? new Set(selection) : null return (option, data, ctx) => { if (option == null) { const structural = pieOptions({ theme, formatter, labelFormatter }) return optionsOverride ? (mergeOptions( structural as unknown as Record, optionsOverride as Partial>, ) as EChartsOption) : structural } const seriesArr = Array.isArray(data) ? (data as PieWidgetData) : [] if (seriesArr.length === 0) { return { ...option, dataset: [], series: [] } } if (seriesArr.length > 1) { return buildMultiSeriesBarFusion( option, seriesArr, theme, series, ctx, selectionSet, ) } return buildSingleSeriesPieFusion( option, seriesArr, radius, series, ctx, selectionSet, ) } } /** * Single-series donut fusion. Spreads the structural pie series template * into per-data series with center/radius/encoding, wires the reactive * center-label / tooltip formatters and the per-data palette-aware * selection-dim callback. */ function buildSingleSeriesPieFusion( option: EChartsOption, seriesArr: PieWidgetData, radius: readonly [string, string], series: readonly WidgetSeries[] | undefined, ctx: OptionFactoryContext | undefined, selectionSet: Set | null, ): EChartsOption { const seriesTemplates = Array.isArray(option.series) ? option.series : [] const broadcastTemplate = seriesTemplates[0] ?? {} const baseTooltip = typeof option.tooltip === 'object' && !Array.isArray(option.tooltip) ? option.tooltip : {} const baseLegend = typeof option.legend === 'object' && !Array.isArray(option.legend) ? option.legend : {} const formatter = ctx?.formatter const labelFormatter = ctx?.labelFormatter // Pie wants `colorBy: 'data'` — each slice draws from the option's // `color` palette by data index. But installing an `itemStyle.color` // callback disables ECharts' automatic per-data cycling, and // `params.color` collapses to the series color (so every slice goes // the same color). We re-implement the per-data palette resolution // here so the multicolor look survives selection-driven dimming. const palette = Array.isArray(option.color) ? (option.color as readonly (string | undefined)[]) : [] const resolvePaletteColor = (params: CallbackDataParams): string => { const swatch = palette.length > 0 ? palette[params.dataIndex % palette.length] : undefined return swatch ?? (params.color as string) } // Always emit `itemStyle.color` (passthrough when nothing is selected), // not conditionally — same anti-stale-callback rationale as bar / // histogram. The slice border + width come from the structural // template's `itemStyle` and survive via the spread below. const colorFn = (params: CallbackDataParams): string => { const base = resolvePaletteColor(params) if (!selectionSet) return base const datum = params.value as { name?: string | number } | undefined const name = datum?.name ?? params.name return name != null && selectionSet.has(name) ? base : echarts.color.modifyAlpha(base, 0.15) } // Center label formatter — lives in the merger because it reads // reactive `formatter` / `labelFormatter` from ctx. The rich tags // `{c|…}` and `{b|…}` reference the rich styles baked into the // structural label template. const labelTextFormatter = (params: CallbackDataParams): string => { const { name } = params const encodeIndex = params.encode?.value?.[0] if (encodeIndex === undefined) return '' const value = (Object.values(params.data ?? {}) as unknown[]).at( encodeIndex, ) // `value` here is the resolved dataset cell — for pie data it's a // `string | number` primitive. Anything else is a misuse we won't // dress up with `String(...)` (would render "[object Object]"). const formattedValue = typeof value === 'number' ? formatter ? formatter(value) : String(value) : typeof value === 'string' ? value : '' const formattedName = labelFormatter ? String(labelFormatter(name ?? '')) : String(name ?? '') return `{c|${formattedValue}}\n\n{b|${formattedName}}` } return { ...option, dataset: seriesArr.map((s) => ({ source: s as readonly object[] })), series: seriesArr.map((_, i) => { const template = (seriesTemplates[i] as object | undefined) ?? (broadcastTemplate as object) const templateObj = typeof template === 'object' ? template : {} const templateLabel = (templateObj as { label?: object }).label ?? {} const templateItemStyle = (templateObj as { itemStyle?: object }).itemStyle ?? {} return { ...templateObj, type: 'pie' as const, datasetIndex: i, name: series?.[i]?.name ?? `Series ${i + 1}`, radius: [...radius], // Lift the donut up so the bottom-anchored legend has the // vertical real estate to wrap to multiple rows for long // category lists — at exactly `50%` the bottom slices crowd // the chips and `type: 'plain'` legends can't expand upward // without overlapping the donut. center: ['50%', '38%'] as [string, string], encode: { itemName: 'name', value: 'value' }, label: { ...templateLabel, formatter: labelTextFormatter, }, itemStyle: { ...templateItemStyle, color: colorFn, }, } }), // Legend always shows for pie (mirrors v1) — slice names drive the // entries, so even a single donut benefits from a category list. legend: { ...baseLegend, show: true }, tooltip: { ...baseTooltip, formatter: buildPieTooltipFormatter(formatter, labelFormatter), }, } } /** * Multi-series horizontal-bar fusion. Mirrors v1 pie's "pie data with * >1 series collapses into a horizontal bar chart" behavior — side-by- * side donuts don't read well when you're comparing the same categories * across cohorts, so swap to a value-on-x / category-on-y bar layout. * * Pie data shape is identical to bar's (`{ name, value }[][]`), so the * same dataset feeds either layout without transformation. */ function buildMultiSeriesBarFusion( option: EChartsOption, seriesArr: PieWidgetData, theme: Theme, series: readonly WidgetSeries[] | undefined, ctx: OptionFactoryContext | undefined, selectionSet: Set | null, ): EChartsOption { const baseTooltip = typeof option.tooltip === 'object' && !Array.isArray(option.tooltip) ? option.tooltip : {} const baseLegend = typeof option.legend === 'object' && !Array.isArray(option.legend) ? option.legend : {} const baseGrid = typeof option.grid === 'object' && !Array.isArray(option.grid) ? option.grid : {} const formatter = ctx?.formatter const labelFormatter = ctx?.labelFormatter const { niceMinVal, niceMaxVal } = computeNiceBounds(seriesArr) // Per-series palette — multi-series bars take one color per series // (not per-data, unlike donut slices). Same anti-stale-callback rule: // always emit the callback so a transition to/from a selection // replaces the previous closure cleanly. const palette = Array.isArray(option.color) ? (option.color as readonly (string | undefined)[]) : [] const barColorFn = (params: CallbackDataParams): string => { const seriesIdx = params.seriesIndex ?? 0 const seriesSwatch = palette.length > 0 ? palette[seriesIdx % palette.length] : undefined const base = (seriesSwatch ?? params.color) as string if (!selectionSet) return base const datum = params.value as { name?: string | number } | undefined const name = datum?.name ?? params.name return name != null && selectionSet.has(name) ? base : echarts.color.modifyAlpha(base, 0.15) } return { ...option, dataset: seriesArr.map((s) => ({ source: s as readonly object[] })), grid: { ...baseGrid, ...buildGridConfig(true, theme), right: parseInt(theme.spacing(4)), containLabel: true, }, // Drop pie-specific structural keys that shouldn't render in the // bar fallback. ECharts ignores undefined keys, so this is a clean // override. series: seriesArr.map((_, i) => { const overrideColor = resolveThemeColor(theme, series?.[i]?.color) return { datasetIndex: i, type: 'bar' as const, name: series?.[i]?.name ?? `Series ${i + 1}`, barMaxWidth: 100, emphasis: { focus: 'series' }, ...buildSeriesLabelConfig(formatter, 'x'), itemStyle: { color: barColorFn }, ...(overrideColor ? { color: overrideColor } : {}), } }), xAxis: { type: 'value', // Closures over the pre-computed nice bounds — ECharts calls them // once per render to resolve the axis extents. min: () => niceMinVal, max: () => niceMaxVal, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: true, lineStyle: { color: theme.palette.black?.[4] ?? theme.palette.divider }, }, // Value labels render BELOW the axis line (default placement), // NOT inside the plot. Bar/histogram use `inside: true` + // `verticalAlign: 'bottom'` for their *vertical* y-axis where the // axis is the left wall — labels inside read like grid annotations. // A horizontal bar chart's value axis is the x-axis, and `inside` // there pushes the max label *behind* the bar (it visually // disappears once a bar reaches the right edge). Use the default // below-the-axis placement so the rounded extents stay readable. axisLabel: { fontSize: theme.typography.overlineDelicate?.fontSize, fontFamily: theme.typography.overlineDelicate?.fontFamily, margin: parseInt(theme.spacing(1)), showMaxLabel: true, showMinLabel: true, formatter: (value: number) => { if (value !== niceMaxVal && value !== niceMinVal) return '' if (value === 0) return '' return formatter ? formatter(value) : String(value) }, }, }, yAxis: { type: 'category', axisLine: { show: false }, axisTick: { show: false }, axisLabel: { padding: [parseInt(theme.spacing(0.5)), 0, 0, 0], ...(labelFormatter && { formatter: (value: string | number) => String(labelFormatter(value)), }), }, }, legend: { ...baseLegend, show: true }, tooltip: { ...baseTooltip, trigger: 'axis', formatter: buildHorizontalBarTooltipFormatter(formatter, labelFormatter), }, } as EChartsOption } /** * Tooltip formatter for the horizontal-bar fallback. Reads the value by * `encode.x` dimension index (v1 parity) so it stays robust to * downstream changes that rename dataset columns. */ function buildHorizontalBarTooltipFormatter( formatter: ((value: number) => string) | undefined, labelFormatter: ((value: string | number) => string | number) | undefined, ) { return createTooltipFormatter((item) => { const encodeIndex = item.encode?.x?.at(0) const dimName = encodeIndex !== undefined ? item.dimensionNames?.[encodeIndex] : undefined const row = item.value && typeof item.value === 'object' && !Array.isArray(item.value) ? (item.value as Record) : undefined const raw = dimName && row ? row[dimName] : undefined const formattedValue = typeof raw === 'number' && formatter ? formatter(raw) : (raw ?? '') const marker = typeof item.marker === 'string' ? item.marker : '' const seriesName = item.seriesName ? `${item.seriesName}: ` : '' const name = labelFormatter ? String(labelFormatter(item.name ?? '')) : String(item.name ?? '') return { name, seriesName, marker, value: formattedValue } }) } /** * Min/max bounds over every datum's `.value` across all series. Used by * the horizontal-bar fallback's x-axis label formatter so we only render * labels at the rounded extents (matches v1 + bar / histogram). */ function computeNiceBounds(seriesArr: PieWidgetData): { niceMinVal: number niceMaxVal: number } { let min = 0 let max = -Infinity for (const series of seriesArr) { for (const d of series) { if (typeof d?.value !== 'number' || !Number.isFinite(d.value)) continue if (d.value < min) min = d.value if (d.value > max) max = d.value } } return { niceMinVal: min < 0 ? niceNum(min) : 0, niceMaxVal: max <= 0 ? 1 : niceNum(max), } } /** * Tooltip formatter for pie slices. `item.value` is the dataset row * object (e.g. `{ name: 'A', value: 10 }`); we pull the value by * `encode.value`'s dimension index, formatted via the reactive `formatter` * if present. Slice name passes through `labelFormatter`. */ function buildPieTooltipFormatter( formatter: ((value: number) => string) | undefined, labelFormatter: ((value: string | number) => string | number) | undefined, ) { return createTooltipFormatter((item) => { const encodeIndex = item.encode?.value?.at(0) ?? 1 const values = item.value && typeof item.value === 'object' && !Array.isArray(item.value) ? (Object.values(item.value) as (string | number)[]) : [] const raw = values[encodeIndex] const formattedValue = typeof raw === 'number' && formatter ? formatter(raw) : (raw ?? '') const marker = typeof item.marker === 'string' ? item.marker : '' const seriesName = item.seriesName ? `${item.seriesName}: ` : '' const name = labelFormatter ? String(labelFormatter(item.name ?? '')) : String(item.name ?? '') return { name, seriesName, marker, value: formattedValue } }) }