import { getCommonOptions, mergeEchartWidgetConfig, type EchartOptionsProps, } from '../echart' import type { HistogramConfig, HistogramWidgetConfig } from './types' import { buildLegendConfig, buildGridConfig, createTooltipPositioner, createTooltipFormatter, niceNum, buildHistogramSeriesLabelConfig, } from '../utils/chart-config' import { downloadToCSV, downloadToPNG, type DownloadItem } from '../actions' import type { ConfigProps } from '../loader/types' export interface HistogramDownloadConfigProps extends ConfigProps { ticks: number[] labelFormatter?: (value: string | number) => string | number } export function histogramDataToCSV( data: number[][], ticks: number[], labelFormatter?: (value: string | number) => string | number, ): string[][] { if (!data?.length || data[0]?.length === 0) return [] const dataLength = data[0]?.length ?? 0 const labels = createAxisLabels(dataLength, ticks, labelFormatter) const seriesCount = data.length const isMulti = seriesCount > 1 const headers = isMulti ? [ 'Bin', ...Array.from({ length: seriesCount }, (_, i) => `Series ${i + 1}`), ] : ['Bin', 'Value'] return [ headers, ...labels.map((label, i) => [ label, ...data.map((series) => String(series[i] ?? 0)), ]), ] } export function histogramDownloadConfig({ refUI, ticks, labelFormatter, }: HistogramDownloadConfigProps): DownloadItem[] { return [ { ...downloadToPNG, modifier: () => downloadToPNG.modifier(refUI), }, { ...downloadToCSV, modifier: async (data) => downloadToCSV.modifier(histogramDataToCSV(data, ticks, labelFormatter)), }, ] } /** * Creates formatted axis labels from tick boundaries. * * @param dataLength - Number of data points (determines number of labels). * @param ticks - Bin boundaries. If `ticks.length === dataLength + 1`, all * bins are ranges. If `ticks.length === dataLength`, the last bin is * open-ended (`+`). A last tick of `Infinity` also produces `+`. * @param labelFormatter - Optional formatter applied to each individual tick * value when building the bin range label. */ function createAxisLabels( dataLength: number, ticks: number[], labelFormatter?: (value: string | number) => string | number, ): string[] { const fmt = (v: number) => labelFormatter ? String(labelFormatter(v)) : String(v) return Array.from({ length: dataLength }, (_, i) => { const low = ticks[i] ?? i const high = ticks[i + 1] return high !== undefined && isFinite(high) ? `${fmt(low)}-${fmt(high)}` : `${fmt(low)}+` }) } /** * Generates ECharts configuration for distribution histogram widgets with * adjacent bars (minimal gap) and axis formatting styled with the CARTO theme. * * Accepts raw `number[][]` data and `ticks` boundaries. The ticks and * `labelFormatter` are used to create the x-axis category labels; the raw * numeric data is embedded directly in each series. * * @param props - Histogram configuration including raw data, ticks, and theme. * @returns Widget config with ECharts option object. */ export function histogramConfig(props: HistogramConfig): HistogramWidgetConfig { return { type: 'histogram', option: mergeEchartWidgetConfig(getCommonOptions(props), getOption(props)), formatter: props.formatter, labelFormatter: props.labelFormatter, } } function getOption({ data = [], ticks, theme, formatter, labelFormatter, }: HistogramConfig): EchartOptionsProps { const hasLegend = (data?.length ?? 0) > 1 const dataLength = data[0]?.length ?? 0 const axisLabels = createAxisLabels(dataLength, ticks, labelFormatter) let niceMin = 0 let niceMax = 1 return { legend: buildLegendConfig({ hasLegend, labelFormatter }), grid: buildGridConfig(hasLegend, theme), xAxis: { type: 'category', data: axisLabels, axisLine: { show: false, }, axisLabel: { fontSize: theme.typography.overlineDelicate.fontSize, fontFamily: theme.typography.overlineDelicate.fontFamily, showMinLabel: undefined, showMaxLabel: undefined, hideOverlap: true, margin: 0, padding: [ parseInt(theme.spacing(0.5)), parseInt(theme.spacing(0.5)), 0, parseInt(theme.spacing(0.5)), ], color: theme.palette.black[60], }, axisTick: { show: false, }, splitLine: { show: true, lineStyle: { color: theme.palette.black[4], }, }, }, yAxis: { type: 'value' as const, 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 }, splitNumber: 1, axisLabel: { fontSize: theme.typography.overlineDelicate.fontSize, fontFamily: theme.typography.overlineDelicate.fontFamily, margin: parseInt(theme.spacing(1)), show: true, showMaxLabel: true, showMinLabel: true, verticalAlign: 'bottom' as const, formatter: (value: number) => { if (value !== niceMax && value !== niceMin) return '' if (value === 0) return '' return formatter ? formatter(value) : String(value) }, }, axisLine: { show: false, }, axisTick: { show: false, }, splitLine: { show: true, lineStyle: { color: theme.palette.black[4], }, }, }, tooltip: { position: createTooltipPositioner(theme), formatter: createTooltipFormatter((item) => { const _value = item.value as number const formattedValue = typeof _value === 'number' && formatter ? formatter(_value) : String(_value ?? '') const marker = typeof item.marker === 'string' ? item.marker : '' const seriesName = item.seriesName ? `${item.seriesName}: ` : '' const name = item.name return { name, seriesName, marker, value: formattedValue } }), }, series: data.map((seriesData: number[]) => ({ type: 'bar', data: seriesData, barGap: '1%', barCategoryGap: '1%', emphasis: { focus: 'series', }, ...buildHistogramSeriesLabelConfig(formatter), })), } as EchartOptionsProps }