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 type { ScatterplotEChartsOption, ScatterplotOptionFactoryInput, ScatterplotOptionsInput, ScatterplotWidgetData, } from './types' /** * Builds the **structural** ECharts option for a scatterplot widget — both * axes `type: 'value'` (not category, unlike Bar/Histogram), grid, tooltip * triggered per-item rather than per-axis. Intentionally data-agnostic: no * series, no dataset, no `legend.show` (those depend on data and are added * by {@link createScatterplotOptionFactory}). * * Styling parity with v1: dark themed tooltip via `createTooltipFormatter` * + `createTooltipPositioner`, `buildGridConfig`-based grid, polished * axisLine/Tick/splitLine, `overlineDelicate` axis labels, structural * legend wired via `buildLegendConfig` (toggled by the merger), and the * CARTO `qualitative.bold + secondary` palette — same pattern bar / * histogram / pie already use. {@link createScatterplotOptionFactory} * wraps this builder in its structural-phase branch. */ export function scatterplotOptions({ theme, xFormatter, yFormatter, }: ScatterplotOptionsInput): ScatterplotEChartsOption { return { grid: { left: parseInt(theme.spacing(1)), top: parseInt(theme.spacing(3)), right: parseInt(theme.spacing(1)), // Default: no legend. Merger bumps `bottom` when there are >1 series. ...buildGridConfig(false, theme), containLabel: true, }, tooltip: { // Per-point trigger — different from Bar's 'axis' trigger because // points don't share an x-coordinate. trigger: 'item', 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, }, position: createTooltipPositioner(theme), formatter: buildScatterTooltipFormatter(xFormatter, yFormatter), }, // Legend styling baked here; `show` is toggled by the merger based on // series count. legend: { ...buildLegendConfig({ hasLegend: false, labelFormatter: undefined }), }, axisPointer: { lineStyle: { color: theme.palette.grey[400] } }, color: [ theme.palette.secondary.main, ...Object.values( (theme.palette as { qualitative?: { bold?: Record } }) .qualitative?.bold ?? {}, ), ], xAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false }, axisLabel: { ...buildAxisLabelStyle(theme), margin: parseInt(theme.spacing(1)), hideOverlap: true, showMinLabel: true, showMaxLabel: true, ...(xFormatter && { formatter: xFormatter }), }, splitLine: { show: true, lineStyle: { color: theme.palette.black?.[4] ?? theme.palette.divider }, }, }, yAxis: { type: 'value', axisLine: { show: false }, axisTick: { show: false }, axisLabel: { ...buildAxisLabelStyle(theme), margin: parseInt(theme.spacing(1)), hideOverlap: true, showMinLabel: true, showMaxLabel: true, ...(yFormatter && { formatter: yFormatter }), }, splitLine: { show: true, lineStyle: { color: theme.palette.black?.[4] ?? theme.palette.divider }, }, }, } as ScatterplotEChartsOption } /** * Returns the scatterplot 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 scatterplotOptions}, 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` * (`ScatterplotWidgetData`) into the option via the dataset API. Each * series's `[x, y]` tuples land in `dataset[i].source` as 2-column * rows; the series uses positional encoding (`encode: { x: 0, y: 1 }`) * and `type: 'scatter'`. niceMin/niceMax are computed over both axes * so the chart frames data on rounded extents and numeric jitters * don't shift gridlines per render. Reactive `ctx.formatter` (driven * by RelativeData) re-derives the y-axis label and tooltip at fusion * time; `xFormatter` stays baked at structural-build time (relative * is a values-axis concept; x is coordinate-space). * * When `ZoomToggle` installs a `dataZoom` slider, grid bottom is * extended and the slider is positioned above the legend row (if any) — * same layout dance bar / histogram / timeseries do. */ export function createScatterplotOptionFactory( options: ScatterplotOptionFactoryInput, ): OptionFactory { const { theme, xFormatter, yFormatter, optionsOverride } = options const series = options.series const symbolSize = options.symbolSize ?? 8 const selection = options.selection const selectionSet = selection && selection.length > 0 ? new Set(selection) : null return (option, data, ctx) => { if (option == null) { const structural = scatterplotOptions({ theme, xFormatter, yFormatter }) return optionsOverride ? (mergeOptions( structural as unknown as Record, optionsOverride as Partial>, ) as EChartsOption) : structural } const seriesArr = Array.isArray(data) ? (data as ScatterplotWidgetData) : [] 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 baseXAxis = typeof option.xAxis === 'object' && !Array.isArray(option.xAxis) ? option.xAxis : {} const baseYAxis = typeof option.yAxis === 'object' && !Array.isArray(option.yAxis) ? option.yAxis : {} const reactiveFormatter = ctx?.formatter const { niceMinX, niceMaxX, niceMinY, niceMaxY } = computeScatterBounds(seriesArr) // Dim non-selected points via `series.itemStyle.color`. The selection // key is `${seriesIndex}:${dataIndex}`; we read those off the params // ECharts hands to the callback per-data. // // 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 items would stay dimmed forever after an external clear. // Keeping the key always-present means a plain setOption merge swaps // the callback in place, no `replaceMerge` and no entry-animation // flash on selection on/off. const makeDimColor = (seriesIdx: number) => (params: CallbackDataParams) => { const base = params.color as string if (!selectionSet) return base const key = `${seriesIdx}:${params.dataIndex}` return selectionSet.has(key) ? base : echarts.color.modifyAlpha(base, 0.15) } // Zoom slider layout: when ZoomToggle has installed `dataZoom`, // reserve grid space and lift any x-slider above the legend (if // present). Scatter supports 2D zoom via `axes: ['x', 'y']` — in // that case there's also a vertical y-slider on the right edge, so // we reserve `grid.right` separately. Inside-only dataZoom entries // (no `type: 'slider'`) take no grid space. const dataZoomLayout = layoutDataZoomForScatter(option.dataZoom, hasLegend) const hasXSlider = dataZoomLayout?.hasXSlider ?? false const hasYSlider = dataZoomLayout?.hasYSlider ?? false const fallbackBottom = typeof baseGrid.bottom === 'number' ? baseGrid.bottom : 24 const baseBottom = hasLegend ? 56 : fallbackBottom const gridBottom = hasXSlider ? baseBottom + ZOOM_LAYOUT.sliderHeight + ZOOM_LAYOUT.sliderGap : baseBottom const fallbackRight = typeof baseGrid.right === 'number' ? baseGrid.right : 8 const gridRight = hasYSlider ? fallbackRight + ZOOM_LAYOUT.sliderHeight + ZOOM_LAYOUT.sliderGap : fallbackRight return { ...option, // ECharts dataset.source wants a mutable [number, number][] shape; we // hold readonly tuples internally, so cast at the boundary. dataset: seriesArr.map((s) => ({ source: s as unknown as number[][] })), series: seriesArr.map((_, i) => { const overrideColor = resolveThemeColor(theme, series?.[i]?.color) return { type: 'scatter' as const, datasetIndex: i, name: series?.[i]?.name ?? `Series ${i + 1}`, encode: { x: 0, y: 1 }, symbolSize, emphasis: { focus: 'series' }, itemStyle: { color: makeDimColor(i) }, ...(overrideColor ? { color: overrideColor } : {}), } }), legend: { ...baseLegend, show: hasLegend }, grid: { ...baseGrid, bottom: gridBottom, right: gridRight }, ...(dataZoomLayout ? { dataZoom: dataZoomLayout.entries } : {}), xAxis: { ...baseXAxis, min: niceMinX, max: niceMaxX, } as EChartsOption['xAxis'], yAxis: { ...baseYAxis, min: niceMinY, max: niceMaxY, axisLabel: { ...((baseYAxis as { axisLabel?: object }).axisLabel ?? {}), // Re-derive the y-axis formatter at fusion time so RelativeData's // percent formatter (written to `state.formatter`) flows through // without rebuilding the structural option. Falls back to the // structural `yFormatter` already baked in `baseYAxis.axisLabel` // (which `String(value)` if neither is set). ...(reactiveFormatter ? { formatter: reactiveFormatter } : {}), }, } as EChartsOption['yAxis'], tooltip: { ...baseTooltip, // Rebuild the tooltip formatter so the live y-axis formatter is // applied to the y-coordinate in the (x, y) label. xFormatter is // structural — relative is a values-axis concept, so xFormatter // doesn't change under RelativeData. formatter: buildReactiveScatterTooltipFormatter( (baseTooltip as { formatter?: unknown }).formatter, reactiveFormatter, xFormatter, ), }, } as EChartsOption } } /** * Lay out the `dataZoom` array for the scatter chart: * - Detect whether any horizontal (x-axis) slider is present — if so * lift it above the legend row when a legend is shown. * - Detect whether any vertical (y-axis) slider is present — the * caller reserves `grid.right` so the slider doesn't overlap the * plot area. * * Returns `null` when there's no `dataZoom` so callers can skip the * layout adjustment entirely. An entry is considered an "x-slider" if * it has `xAxisIndex` set (or no axis index — defaults to x in ECharts). * A "y-slider" has `yAxisIndex` set. */ function layoutDataZoomForScatter( dataZoom: unknown, hasLegend: boolean, ): { entries: unknown[]; hasXSlider: boolean; hasYSlider: boolean } | null { if (!Array.isArray(dataZoom) || dataZoom.length === 0) return null let hasXSlider = false let hasYSlider = false const entries = dataZoom.map((entry: unknown) => { if (entry == null || typeof entry !== 'object') return entry const dz = entry as { type?: string xAxisIndex?: unknown yAxisIndex?: unknown bottom?: number } if (dz.type !== 'slider') return dz const targetsY = dz.yAxisIndex !== undefined if (targetsY) { hasYSlider = true return dz } // Either explicit x or defaulted (ECharts defaults sliders to xAxis // when no axis index is provided). hasXSlider = true if (hasLegend) { return { ...dz, bottom: ZOOM_LAYOUT.sliderBottomWithLegend } } return dz }) return { entries, hasXSlider, hasYSlider } } /** * If a reactive (store-driven) y formatter is provided, re-build the * scatter tooltip formatter using it. Otherwise, return the structural * formatter unchanged so the original `xFormatter` / `yFormatter` * baked into `scatterplotOptions` still applies. The structural * formatter has stable identity per `scatterplotOptions` call, so this * path doesn't churn ECharts on every render. */ function buildReactiveScatterTooltipFormatter( structuralFormatter: unknown, reactiveYFormatter: ((value: number) => string) | undefined, xFormatter: ((value: number) => string) | undefined, ) { if (!reactiveYFormatter) return structuralFormatter return createTooltipFormatter((item) => { const value = item.value as readonly [number, number] | undefined const x = value?.[0] const y = value?.[1] const formattedX = typeof x === 'number' ? (xFormatter ? xFormatter(x) : String(x)) : '' const formattedY = typeof y === 'number' ? reactiveYFormatter(y) : String(y ?? '') const marker = typeof item.marker === 'string' ? item.marker : '' const seriesName = item.seriesName ? `${item.seriesName}: ` : '' return { name: `(${formattedX}, ${formattedY})`, seriesName, marker, value: '', } }) } function buildScatterTooltipFormatter( xFormatter: ((value: number) => string) | undefined, yFormatter: ((value: number) => string) | undefined, ) { return createTooltipFormatter((item) => { const value = item.value as readonly [number, number] | undefined const x = value?.[0] const y = value?.[1] const formattedX = typeof x === 'number' ? (xFormatter ? xFormatter(x) : String(x)) : '' const formattedY = typeof y === 'number' ? (yFormatter ? yFormatter(y) : String(y)) : '' const marker = typeof item.marker === 'string' ? item.marker : '' const seriesName = item.seriesName ? `${item.seriesName}: ` : '' return { name: `(${formattedX}, ${formattedY})`, seriesName, marker, value: '', } }) } function computeScatterBounds(seriesArr: ScatterplotWidgetData): { niceMinX: number niceMaxX: number niceMinY: number niceMaxY: number } { let minX = Infinity let maxX = -Infinity let minY = Infinity let maxY = -Infinity for (const series of seriesArr) { for (const point of series) { const x = point?.[0] const y = point?.[1] if (typeof x === 'number' && Number.isFinite(x)) { if (x < minX) minX = x if (x > maxX) maxX = x } if (typeof y === 'number' && Number.isFinite(y)) { if (y < minY) minY = y if (y > maxY) maxY = y } } } // Mirror bar's `computeNiceBounds`: clamp min to 0 when data is // non-negative (gridline reads cleanly from zero), apply `niceNum` to // negative mins, and floor max=0 to 1 so the chart always has range. // Scatter can have free coordinates so we apply this per-axis. const niceMaxX = Number.isFinite(maxX) ? (maxX <= 0 ? 1 : niceNum(maxX)) : 1 const niceMaxY = Number.isFinite(maxY) ? (maxY <= 0 ? 1 : niceNum(maxY)) : 1 const niceMinX = Number.isFinite(minX) ? (minX < 0 ? niceNum(minX) : 0) : 0 const niceMinY = Number.isFinite(minY) ? (minY < 0 ? niceNum(minY) : 0) : 0 return { niceMinX, niceMaxX, niceMinY, niceMaxY } }