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 { HistogramEChartsOption, HistogramOptionFactoryInput, HistogramOptionsInput, HistogramWidgetData, } from './types' /** * Builds the **structural** ECharts option for a histogram widget — axes, * grid, tooltip, value-axis label formatter, themed legend. Mirrors * {@link import('../bar/options').barOptions} so the two widgets share v1 * look-and-feel: themed dark tooltip with positioner, CARTO color palette, * structural legend (`show` toggled by the merger based on series count), * y-axis labels rendered inside the plot via the `niceNum` closure pattern. * * Intentional deviations from bar (histogram-specific): * - **No `xAxis.axisLabel.formatter`.** Bin labels are pre-formatted in * the merger via `formatNumber(lo)–formatNumber(hi)` and optionally * the consumer's `labelFormatter`. The strings on the axis are * already display-ready, so there's nothing to post-format here. * - **Tooltip formatter reads the array-tuple row shape.** The dataset * uses positional `encode: { x: 0, y: 1 }`, so `item.value` comes in * as `[binLabel, count]` rather than bar's `{ name, value }` object. * * Intentionally data-agnostic: no series, no dataset, no x-axis category * data (those depend on data + ticks and are added by the option factory's * merge phase via {@link createHistogramOptionFactory}). */ export function histogramOptions({ theme, formatter, }: HistogramOptionsInput): HistogramEChartsOption { // Closure shared between yAxis min/max callbacks and the label formatter, // so only the rounded extents are labelled (matches v1 + bar). 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: buildHistogramTooltipFormatter(formatter), }, // Legend styling baked here; `show` is toggled by the merger based on // series count. Histogram doesn't accept `labelFormatter` at the // options layer (bin labels are pre-formatted in the merger), so we // skip the legend `labelFormatter` argument too. legend: { ...buildLegendConfig({ hasLegend: false }), }, 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 bin label a candidate so `hideOverlap` // can pack the maximum number that physically fit. The category-axis // default (`'auto'`) sub-samples first and leaves only one/two labels // even when there's room for more. interval: 0, hideOverlap: true, }, }, 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 HistogramEChartsOption } /** * Returns the histogram 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 histogramOptions}, optionally merging * the consumer-supplied `optionsOverride`. Called once by Provider to * seed `rawOptions` in the store. * - **Merge phase** (`option != null`) — fuses post-pipeline `state.data` * (`HistogramWidgetData` — `number[][]`) into the option via the * dataset API. Each series gets its own dataset of `[binLabel, count]` * pairs derived from `ticks`; series reference their dataset by * `datasetIndex` and are encoded by column position. Reactive * `ctx.formatter` (driven by RelativeData → `%`) plus `niceMin`/`niceMax` * are re-derived here so the y-axis label formatter and the tooltip * pick up live store values, mirroring bar. */ export function createHistogramOptionFactory( options: HistogramOptionFactoryInput, ): OptionFactory { const { theme, formatter, ticks, series, labelFormatter, selection } = options const optionsOverride = options.optionsOverride const selectionSet = selection && selection.length > 0 ? new Set(selection) : null const binLabels: string[] = [] for (let i = 0; i < ticks.length - 1; i++) { const lo = ticks[i]! const hi = ticks[i + 1]! const raw = `${formatNumber(lo)}–${formatNumber(hi)}` binLabels.push(labelFormatter ? labelFormatter(raw) : raw) } return (option, data, ctx) => { if (option == null) { const structural = histogramOptions({ theme, formatter }) return optionsOverride ? (mergeOptions( structural as unknown as Record, optionsOverride as Partial>, ) as EChartsOption) : structural } const seriesArr: HistogramWidgetData = Array.isArray(data) ? (data as HistogramWidgetData) : [] if (seriesArr.length === 0 || binLabels.length === 0) { return { ...option, dataset: [], series: [] } } const hasLegend = seriesArr.length > 1 const seriesTemplates = Array.isArray(option.series) ? option.series : [] const broadcastTemplate = seriesTemplates[0] ?? {} const baseYAxis = typeof option.yAxis === 'object' && !Array.isArray(option.yAxis) ? option.yAxis : {} 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 baseLegend = typeof option.legend === 'object' && !Array.isArray(option.legend) ? option.legend : {} // Reactive (live store) formatter from ctx — distinct from the // closure-time `formatter` 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 // Closure shared between the yAxis min/max callbacks and the label // formatter, so only the rounded extents are labelled (matches v1 + // bar). Delegating the extent to ECharts (rather than precomputing // scalars from the raw counts) keeps stacked bins inside the plot: // when StackToggle marks the series, ECharts feeds the *post-stack* // extent to these callbacks, so `niceNum` rounds the stacked total. 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. 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 // Dim non-selected bins via `series.itemStyle.color`. Per-row // `itemStyle` on dataset sources is silently ignored when // `series.encode` is in play. // // 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 bins 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 return selectionSet.has(params.dataIndex) ? base : echarts.color.modifyAlpha(base, 0.15) }, } return { ...option, dataset: seriesArr.map((counts) => ({ source: binLabels.map( (label, i) => [label, counts[i] ?? 0] as [string, number], ), })), series: seriesArr.map((_, i) => { const template = (seriesTemplates[i] as object | undefined) ?? (broadcastTemplate as object) 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: 0, y: 1 }, barCategoryGap: '0%', 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: buildHistogramTooltipFormatter(liveFormatter), }, } as EChartsOption } } /** * Tooltip formatter for the histogram's positional `[binLabel, count]` * row shape. ECharts surfaces the x-axis category through `item.name` * (already pre-formatted by the bin-label generator), and `item.value` * comes in as the array tuple — distinct from bar's `{ name, value }` * object row. */ function buildHistogramTooltipFormatter( formatter: ((value: number) => string) | undefined, ) { return createTooltipFormatter((item) => { const row = item.value as [string, number] | undefined const raw = row?.[1] const formattedValue = typeof raw === 'number' && formatter ? formatter(raw) : (raw ?? '') const marker = typeof item.marker === 'string' ? item.marker : '' const seriesName = item.seriesName ? `${item.seriesName}: ` : '' return { name: String(item.name ?? ''), seriesName, marker, value: formattedValue, } }) } function formatNumber(n: number): string { if (Number.isInteger(n)) return String(n) return Number(n.toFixed(2)).toString() }