import type { Theme } from '@mui/material' import type { LegendComponentOption } from 'echarts' import type { CallbackDataParams, TopLevelFormatterParams, } from 'echarts/types/dist/shared' /** * Shared EChart configuration builders for chart widgets */ /** * Rounds a value up to the nearest "nice" number. * A nice number is a multiple of 10^floor(log10(value)). * * Examples: 547 → 600, 200 → 200, 1200 → 2000, 18 → 20, 5 → 5, -547 → -500 */ export function niceNum(value: number): number { if (value === 0) return 0 const absValue = Math.abs(value) const base = Math.pow(10, Math.floor(Math.log10(absValue))) const rounded = Math.ceil(absValue / base) * base return value < 0 ? -rounded : rounded } /** * Builds standard legend configuration for chart widgets * * @param params - Legend configuration parameters * @param params.hasLegend - Whether to show the legend * @param params.labelFormatter - Optional formatter for legend item names * @returns Legend configuration object */ export function buildLegendConfig({ hasLegend, labelFormatter, }: { hasLegend: boolean labelFormatter?: (value: string | number) => string | number }): LegendComponentOption { return { show: hasLegend, icon: 'circle' as const, left: 0, bottom: 0, orient: 'horizontal', type: 'scroll', ...(labelFormatter && { formatter: (name: string) => String(labelFormatter(name)), }), } } /** * Builds standard grid configuration with legend-aware spacing * * @param hasLegend - Whether the chart has a legend * @param theme - MUI theme for spacing * @param additionalConfig - Additional grid configuration to merge * @returns Grid configuration object */ export function buildGridConfig(hasLegend: boolean, theme: Theme) { return { ...(!hasLegend && { bottom: parseInt(theme.spacing(3)) }), ...(hasLegend && { bottom: parseInt(theme.spacing(7)) }), } } /** * Builds the shared axis-label text style for chart widgets, matching the * design's "Overline (Delicate)" token (Inter, 10px) in the secondary text * colour (`rgba(44,48,50,0.6)`). Spread into an ECharts `axisLabel` for both * x and y axes so every chart's labels stay consistent. * * Note: the token's `letterSpacing` and `textTransform: uppercase` are not * representable in ECharts canvas text, so only font size/family/colour are * applied. * * @param theme - MUI theme providing the typography token and palette * @returns `{ fontSize, fontFamily, color }` for an ECharts `axisLabel` */ export function buildAxisLabelStyle(theme: Theme) { return { fontSize: theme.typography.overlineDelicate?.fontSize, fontFamily: theme.typography.overlineDelicate?.fontFamily, color: theme.palette.black?.[60] ?? theme.palette.text.secondary, } } /** * Creates a tooltip position calculator that handles overflow * Used by bar, histogram, and scatterplot widgets * * @param theme - MUI theme for spacing * @returns Tooltip position function */ export function createTooltipPositioner(theme: Theme) { return function ( point: [number, number], _params: unknown, _dom: unknown, _rect: unknown, size: { contentSize: [number, number]; viewSize: [number, number] }, ) { const position = { top: parseInt(theme.spacing(0.5)) } as Record< string, number > // Position tooltip left or right based on available space if (size.contentSize[0] < size.viewSize[0] - point[0]) { position.left = point[0] } else { position.right = size.viewSize[0] - point[0] } return position } } /** * Creates an axis label formatter for ECharts * Used to format numeric axis labels with a widget formatter * * @param formatter - Optional formatter function from widget config * @returns Axis label formatter function or undefined */ export function createAxisLabelFormatter( formatter?: (value: number) => string, ) { if (!formatter) return undefined return (value: number) => formatter(value) } /** * Applies labelFormatter to xAxis configuration * Applies to any xAxis regardless of axis type (category, value, etc.) * * @param xAxis - Existing xAxis configuration * @param labelFormatter - Optional labelFormatter function from widget config * @returns Updated xAxis configuration */ export function applyXAxisFormatter( xAxis: unknown, formatter?: (value: string | number) => string | number, ) { const xAxisIsObject = xAxis && !Array.isArray(xAxis) const xAxisTyped = xAxis as { type?: string; axisLabel?: unknown } const axisFormatter = formatter && xAxisIsObject ? (value: string | number) => String(formatter(value)) : undefined return { ...xAxisTyped, axisLabel: { ...(typeof xAxisTyped.axisLabel === 'object' && xAxisTyped.axisLabel ? xAxisTyped.axisLabel : {}), formatter: axisFormatter, }, } } /** * Applies formatter to yAxis configuration * Only applies to single axis objects (not arrays) with type 'value' * * @param yAxis - Existing yAxis configuration * @param formatter - Optional formatter function from widget config * @returns Updated yAxis configuration or undefined if no changes needed */ export function applyYAxisFormatter( yAxis: unknown, formatter?: (value: number) => string, ) { let axisFormatter = createAxisLabelFormatter(formatter) const yAxisIsObject = yAxis && !Array.isArray(yAxis) const yAxisTyped = yAxis as { type?: string; axisLabel?: unknown } if (!yAxisIsObject || yAxisTyped.type !== 'value') { axisFormatter = undefined } return { ...yAxisTyped, axisLabel: { ...(typeof yAxisTyped.axisLabel === 'object' && yAxisTyped.axisLabel ? yAxisTyped.axisLabel : {}), formatter: axisFormatter, }, } } /** * Creates a tooltip formatter for ECharts * Formats numeric values in tooltip using widget formatter * Handles both axis trigger (array) and item trigger (object) modes * * @param formatter - Optional formatter function from widget config * @returns Tooltip formatter function or undefined */ export function createTooltipFormatter( callback: ( item: CallbackDataParams, items: CallbackDataParams[], ) => { name: string seriesName: string marker: string value: string | number }, ) { return (params: TopLevelFormatterParams) => { // Handle both array (axis trigger) and object (item trigger) const items = Array.isArray(params) ? params : [params] const tooltip = (name: string, callback: string) => `