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 { TimeseriesEChartsOption, TimeseriesOptionFactoryInput, TimeseriesOptionsInput, TimeseriesWidgetData, } from './types' /** * Builds the **structural** ECharts option for a timeseries widget — * time x-axis, value y-axis, themed tooltip, themed legend, CARTO color * palette. Mirrors {@link import('../bar/options').barOptions} so all * four ECharts widgets share v1 look-and-feel. * * Intentional deviations from bar (timeseries-specific): * - **X-axis is `type: 'time'`** (not 'category'). ECharts handles * uneven sample spacing and zoom-level-aware label formatting. * `labelFormatter` is wrapped so the consumer sees a `Date`, not * a numeric timestamp. * - **Tooltip body reads `{ name, value }` rows**, same as bar, but * the `name` may arrive as `Date | number | string`. The * `labelFormatter` receives a `Date` regardless. * * Intentionally data-agnostic: no series, no dataset, no `legend.show` * (those depend on data and are added by the option factory's merge * phase via {@link createTimeseriesOptionFactory}). */ export function timeseriesOptions({ theme, formatter, labelFormatter, }: TimeseriesOptionsInput): TimeseriesEChartsOption { // 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: buildTimeseriesTooltipFormatter(formatter, labelFormatter), }, // Legend styling baked here; `show` is toggled by the merger based on // series count. 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: 'time', axisLine: { show: false }, axisTick: { show: false }, axisLabel: { ...buildAxisLabelStyle(theme), padding: [parseInt(theme.spacing(0.5)), 0, 0, 0], margin: 0, hideOverlap: true, ...(labelFormatter && { formatter: (value: number) => labelFormatter(new Date(value)), }), }, }, 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 TimeseriesEChartsOption } /** * Returns the timeseries 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 timeseriesOptions}, 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` * (`TimeseriesWidgetData`) into the option via the dataset API: one * dataset per series, each series referencing its dataset by index, * encoded by `name` (x — time) and `value` (y). Mirrors {@link import('../bar/options').createBarOptionFactory}: * series-template merge for `addStack`, reactive formatters from * `ctx`, `niceNum`-rounded y-axis bounds at fusion time, and * `positionDataZoomForLegend` layout for ZoomToggle sliders. */ export function createTimeseriesOptionFactory( options: TimeseriesOptionFactoryInput, ): OptionFactory { const { theme, formatter, labelFormatter, optionsOverride } = options const series = options.series const smooth = options.smooth ?? true const area = options.area ?? false const selection = options.selection const selectionSet = selection && selection.length > 0 ? new Set(selection) : null // `name` may be Date | number | string. Normalize to the same type the // selection is keyed on (Date → ms) so Set lookups match. const normalizeName = (n: Date | number | string): string | number => n instanceof Date ? n.getTime() : n return (option, data, ctx) => { if (option == null) { const structural = timeseriesOptions({ theme, formatter, labelFormatter }) return optionsOverride ? (mergeOptions( structural as unknown as Record, optionsOverride as Partial>, ) as EChartsOption) : structural } const seriesArr = Array.isArray(data) ? (data as TimeseriesWidgetData) : [] if (seriesArr.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. `labelFormatter` (Date → string) is structural-only — // not relativizable — so the merge branch reads the closure-time value. 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 data) keeps stacked lines 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 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 // Dim non-selected points via `series.itemStyle.color`. Per-row // `itemStyle` on dataset object-rows 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 points 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 datum = params.value as | { name?: Date | number | string } | undefined const raw = datum?.name const base = params.color as string if (!selectionSet || raw == null) return base return selectionSet.has(normalizeName(raw)) ? 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) // For line series, set BOTH `series[i].color` (legend swatch + // markers) AND `series[i].lineStyle.color` (the line itself) so // the override paints everywhere a series has a colour slot. const overrideColor = resolveThemeColor(theme, series?.[i]?.color) return { ...(typeof template === 'object' ? template : {}), type: 'line' as const, datasetIndex: i, name: series?.[i]?.name ?? `Series ${i + 1}`, encode: { x: 'name', y: 'value' }, smooth, // When a selection is active, surface markers so the per-point // color callback has something to dim — a continuous line would // hide the visual selection feedback. showSymbol: selectionSet != null, ...(area ? { areaStyle: {} } : {}), emphasis: { focus: 'series' }, itemStyle: dimItemStyle, ...(overrideColor ? { color: overrideColor, lineStyle: { 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: buildTimeseriesTooltipFormatter( liveFormatter, labelFormatter, ), }, } as EChartsOption } } /** * Tooltip formatter for the timeseries `{ name, value }` row shape. * `name` arrives as `Date | number | string` (the time-axis stores the * raw value the consumer supplied). The consumer's `labelFormatter` * expects a `Date`, so we coerce non-Date values via `new Date(...)` * before invoking it. */ function buildTimeseriesTooltipFormatter( formatter: ((value: number) => string) | undefined, labelFormatter: ((value: Date) => string) | undefined, ) { return createTooltipFormatter((item) => { const row = item.value as | { name?: Date | number | string; value?: number } | undefined 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 rawName = row?.name ?? item.name const name = labelFormatter && rawName != null ? labelFormatter(rawName instanceof Date ? rawName : new Date(rawName)) : (rawName ?? '') return { name: String(name), seriesName, marker, value: formattedValue } }) }