import type { EChartsOption } from 'echarts' import * as echarts from 'echarts' import type { CallbackDataParams } from 'echarts/types/dist/shared' import { buildAxisLabelStyle, buildGridConfig, buildLegendConfig, createTooltipFormatter, createTooltipPositioner, niceNum, } from '../../widgets/utils/chart-config' import { ZOOM_LAYOUT } from '../actions/zoom-toggle' import type { OptionFactory } from '../echart' import { mergeOptions, resolveThemeColor } from '../utils' import { positionDataZoomForLegend } from '../utils/data-zoom-layout' import type { BarEChartsOption, BarOptionFactoryInput, BarOptionsInput, BarWidgetData, } from './types' /** * Builds the **structural** ECharts option for a bar widget — axes, grid, * tooltip styling, themed legend. Intentionally data-agnostic: no series, * no dataset, no `legend.show` (those depend on data and are added by the * option factory's merge phase). This separation is what lets data-side * pipeline transforms (Searcher, RelativeData) drive the rendered chart — * the merge happens at render time inside the Echart bridge. * * Styling matches the v1 `barConfig` look-and-feel: minimal axes (only * min/max y-labels rendered inside the plot via `niceNum`), themed tooltip, * scroll legend, and CARTO color palette. The y-axis min/max + label * formatter and the tooltip formatter are wired here for the no-data case; * {@link createBarOptionFactory} re-derives them at fusion time so reactive * formatter changes (RelativeData) and stack templates (StackToggle) flow * through to the chart. */ export function barOptions({ theme, formatter, labelFormatter, }: BarOptionsInput): BarEChartsOption { // Closure shared between yAxis min/max callbacks and the label formatter, // so only the rounded extents are labelled (matches v1). let niceMin = 0 let niceMax = 1 return { grid: { left: parseInt(theme.spacing(1)), top: parseInt(theme.spacing(3)), right: parseInt(theme.spacing(1)), // Default: no legend. Merger bumps this when there are >1 series. ...buildGridConfig(false, theme), containLabel: true, }, tooltip: { trigger: 'axis', 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, }, axisPointer: { type: 'line' }, position: createTooltipPositioner(theme), formatter: buildBarTooltipFormatter(formatter, labelFormatter), }, // Legend styling baked here; `show` is toggled by the merger based on // series count. legend: { ...buildLegendConfig({ hasLegend: false, labelFormatter }), }, axisPointer: { lineStyle: { color: theme.palette.grey[400] } }, color: [ theme.palette.secondary.main, ...Object.values( (theme.palette as { qualitative?: { bold?: Record } }) .qualitative?.bold ?? {}, ), ], xAxis: { type: 'category', axisLine: { show: false }, axisTick: { show: false }, axisLabel: { ...buildAxisLabelStyle(theme), padding: [parseInt(theme.spacing(0.5)), 0, 0, 0], margin: 0, // `interval: 0` makes every category label a candidate so `hideOverlap` // can pack the maximum number that physically fit; the category-axis // default (`'auto'`) sub-samples first and shows fewer than fit. interval: 0, hideOverlap: true, ...(labelFormatter && { formatter: (v: string | number) => String(labelFormatter(v)), }), }, }, yAxis: { type: 'value', min: (extent: { min: number }) => { niceMin = extent.min < 0 ? niceNum(extent.min) : 0 return niceMin }, max: (extent: { min: number; max: number }) => { niceMax = extent.max <= 0 ? 1 : niceNum(extent.max) return niceMax }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: true, lineStyle: { color: theme.palette.black?.[4] ?? theme.palette.divider }, }, axisLabel: { ...buildAxisLabelStyle(theme), margin: parseInt(theme.spacing(1)), show: true, showMaxLabel: true, showMinLabel: true, verticalAlign: 'bottom', inside: true, formatter: (value: number) => { if (value !== niceMax && value !== niceMin) return '' if (value === 0) return '' return formatter ? formatter(value) : String(value) }, }, }, } as BarEChartsOption } /** * Returns the bar widget's {@link OptionFactory} — a single closure that * handles BOTH option-construction phases: * * - **Structural phase** (`option == null`) — builds the theme-aware * structural option via {@link barOptions}, optionally merging the * consumer-supplied `optionsOverride` on top. Called once by Provider * to seed `rawOptions` in the store; configTransforms (StackToggle / * ZoomToggle / BrushToggle) then mutate it via the pipeline middleware. * - **Merge phase** (`option != null`) — fuses post-pipeline `state.data` * (`BarWidgetData`) into the option via the dataset API: one dataset * per series, each series referencing its dataset by index, encoded * by `name` (x) and `value` (y). Spreads any series-template fields * already on the incoming option (e.g. `{ stack: 'total' }` from * `addStack`) into every emitted series so configTransforms compose * end-to-end. Reactive `ctx.formatter` / `ctx.labelFormatter` drive * the y-axis min/max-only label and the tooltip formatter at fusion * time so RelativeData's percent formatter flows through without a * structural rebuild. * * Stable identity when the inputs don't change (consumers should wrap the * call in `useMemo` keyed on the same inputs). */ export function createBarOptionFactory( options: BarOptionFactoryInput, ): OptionFactory { const { theme, formatter, labelFormatter, optionsOverride } = options const series = options.series const selection = options.selection const selectionSet = selection && selection.length > 0 ? new Set(selection) : null return (option, data, ctx) => { // Structural phase: Provider seeds rawOptions with this branch. No data // is read; we just emit the theme-aware base (optionally with override). if (option == null) { const structural = barOptions({ theme, formatter, labelFormatter }) return optionsOverride ? (mergeOptions( structural as unknown as Record, optionsOverride as Partial>, ) as EChartsOption) : structural } const seriesArr = Array.isArray(data) ? (data as BarWidgetData) : [] if (seriesArr.length === 0) { return { ...option, dataset: [], series: [] } } const hasLegend = seriesArr.length > 1 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 baseTooltip = typeof option.tooltip === 'object' && !Array.isArray(option.tooltip) ? option.tooltip : {} const baseYAxis = typeof option.yAxis === 'object' && !Array.isArray(option.yAxis) ? option.yAxis : {} const seriesTemplates = Array.isArray(option.series) ? option.series : [] const broadcastTemplate = seriesTemplates[0] ?? {} // Reactive (live store) formatters from ctx — distinct from the // closure-time `formatter` / `labelFormatter` captured for the // structural-build branch above. RelativeData can install a percent // formatter on the store after the factory was constructed; the merge // phase reads `ctx` to pick that up. const liveFormatter = ctx?.formatter const liveLabelFormatter = ctx?.labelFormatter // Closure shared between the yAxis min/max callbacks and the label // formatter, so only the rounded extents are labelled (matches v1). // Delegating the extent to ECharts (rather than precomputing scalars // from the raw data) is what keeps stacked bars inside the plot: when // StackToggle marks the series, ECharts feeds the *post-stack* extent // to these callbacks, so `niceNum` rounds the stacked total instead of // a single series. let niceMin = 0 let niceMax = 1 // Zoom slider layout: when ZoomToggle has installed `dataZoom`, push the // slider above the legend (if any) and reserve room in the grid below. const dataZoomLayout = positionDataZoomForLegend(option.dataZoom, hasLegend) const fallbackBottom = typeof baseGrid.bottom === 'number' ? baseGrid.bottom : 24 const baseBottom = hasLegend ? 56 : fallbackBottom const gridBottom = dataZoomLayout ? baseBottom + ZOOM_LAYOUT.sliderHeight + ZOOM_LAYOUT.sliderGap : baseBottom // When a selection is active, dim non-selected bars by routing the // resolved palette color through `modifyAlpha`. Per-row `itemStyle` on // dataset object-rows is silently ignored when `series.encode` is in // play — the callback approach is the standard ECharts pattern for // per-data styling derived from a dataset. // // We *always* emit `itemStyle.color` (a passthrough when nothing is // selected), not conditionally — dropping the key between renders // would let ECharts' default merge keep the previous callback alive // and bars would stay dimmed forever after an external clear. Always // emitting lets normal merge swap the callback in place, no // `replaceMerge` and no entry-animation flash on selection on/off. const dimItemStyle = { color: (params: CallbackDataParams) => { const base = 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[] })), series: seriesArr.map((_, i) => { const template = (seriesTemplates[i] as object | undefined) ?? (broadcastTemplate as object) // Per-series `color` override: ECharts sets `params.color` from // `series[i].color` when resolving styles, so the dim callback // above keeps working — it just dims the user's colour rather // than the palette default. const overrideColor = resolveThemeColor(theme, series?.[i]?.color) return { ...(typeof template === 'object' ? template : {}), type: 'bar' as const, datasetIndex: i, name: series?.[i]?.name ?? `Series ${i + 1}`, encode: { x: 'name', y: 'value' }, barMaxWidth: 100, emphasis: { focus: 'series' }, itemStyle: dimItemStyle, ...(overrideColor ? { color: overrideColor } : {}), } }), legend: { ...baseLegend, show: hasLegend }, grid: { ...baseGrid, bottom: gridBottom, }, ...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}), yAxis: { ...baseYAxis, min: (extent: { min: number }) => { niceMin = extent.min < 0 ? niceNum(extent.min) : 0 return niceMin }, max: (extent: { min: number; max: number }) => { niceMax = extent.max <= 0 ? 1 : niceNum(extent.max) return niceMax }, axisLabel: { ...((baseYAxis as { axisLabel?: object }).axisLabel ?? {}), formatter: (value: number) => { if (value !== niceMax && value !== niceMin) return '' if (value === 0) return '' return liveFormatter ? liveFormatter(value) : String(value) }, }, } as EChartsOption['yAxis'], tooltip: { ...baseTooltip, formatter: buildBarTooltipFormatter(liveFormatter, liveLabelFormatter), }, } as EChartsOption } } function buildBarTooltipFormatter( formatter: ((value: number) => string) | undefined, labelFormatter: ((value: string | number) => string | number) | undefined, ) { return createTooltipFormatter((item) => { const row = item.value as { name?: string | number; value?: number } const raw = row?.value 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 ?? '')) : (item.name ?? '') return { name: String(name), seriesName, marker, value: formattedValue } }) }