import type { Theme } from '@mui/material' /** * v1's slider handle — 9×36 SVG with a white pill body, secondary-colored * stroke, and three grip lines. Inlined as a data URL so the chart can * render it without a network fetch. */ const SLIDER_HANDLE_ICON = 'image://data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSIzNiIgdmlld0JveD0iMCAwIDkgMzYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjAuNSIgeT0iLTAuNSIgd2lkdGg9IjgiIGhlaWdodD0iMTgiIHJ4PSI0IiB0cmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAwIDI3KSIgZmlsbD0id2hpdGUiIHN0cm9rZT0iIzM1OEJFNyIvPgo8cmVjdCB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgdHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgMyAyMykiIGZpbGw9IiMzNThCRTciLz4KPHJlY3Qgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDMgMTkpIiBmaWxsPSIjMzU4QkU3Ii8+CjxyZWN0IHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiB0cmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAzIDE1KSIgZmlsbD0iIzM1OEJFNyIvPgo8L3N2Zz4=' const SLIDER_HEIGHT = 32 const SLIDER_BOTTOM = 0 /** Which chart axis (or axes) zoom applies to. */ export type ZoomAxis = 'x' | 'y' export interface CreateAddZoomOptions { /** * Axes to enable zoom on. Default `['x']` (matches bar / histogram / * timeseries — horizontal pan/zoom on the category axis). Pass * `['x', 'y']` for a scatterplot-style 2D zoom (mouse-wheel scales both * axes; a horizontal slider sits at the bottom for x-range and a * vertical slider sits on the right for y-range). */ axes?: readonly ZoomAxis[] } /** * Theme-bound zoom config transform: adds one inside `dataZoom` * (mouse-wheel + drag) per axis plus a styled slider per axis with the * v1 chrome (blue-tinted filler, secondary-color data preview, white * pill handles, CARTO typography). The x-slider is horizontal at the * bottom; the y-slider is vertical on the right. * * Returned function has stable identity for a given theme + axes * combination; pair with `useMemo` at the call site. * * **No `start` / `end`** are emitted: ECharts defaults to `0–100` on the * first `setOption` and then keeps the runtime slider state across * subsequent merges (no `replaceMerge` for `dataZoom`). Re-running the * transform on data/formatter/RelativeData updates therefore preserves the * user's dragged range instead of snapping it back. */ export function createAddZoom( theme: Theme, options: CreateAddZoomOptions = {}, ): (option: unknown) => unknown { const axes = options.axes ?? ['x'] const includeX = axes.includes('x') const includeY = axes.includes('y') const sliderStyles = getZoomSliderStyles(theme) const baseSliderProps = { throttle: 0, showDetail: false, brushSelect: false, moveHandleSize: 8, handleSize: '100%', ...sliderStyles, } return (option: unknown): unknown => { if (option == null || typeof option !== 'object') return option const cfg = option as Record const dataZoom: object[] = [] if (includeX) { dataZoom.push({ type: 'inside', xAxisIndex: [0], throttle: 0 }) } if (includeY) { dataZoom.push({ type: 'inside', yAxisIndex: [0], throttle: 0 }) } if (includeX) { dataZoom.push({ type: 'slider', xAxisIndex: [0], height: SLIDER_HEIGHT, bottom: SLIDER_BOTTOM, handleIcon: SLIDER_HANDLE_ICON, ...baseSliderProps, }) } if (includeY) { // Vertical slider — `width` is its thickness, `right` pins it to // the right edge. We omit `handleIcon` so ECharts renders its // default vertical handle (the inline SVG is shaped for a // horizontal slider and looks wrong when rotated). dataZoom.push({ type: 'slider', yAxisIndex: [0], width: SLIDER_HEIGHT, right: SLIDER_BOTTOM, ...baseSliderProps, }) } return { ...cfg, dataZoom } } } /** * Back-compat untyped transform — produces the bare `dataZoom` config from * the original v2 implementation. Use {@link createAddZoom} when a theme is * available so the slider matches the v1 styling. * * `start` / `end` are intentionally omitted so ECharts preserves the user's * runtime range across subsequent `setOption` merges (see {@link createAddZoom}). */ export const addZoom = (option: unknown): unknown => { if (option == null || typeof option !== 'object') return option const cfg = option as Record return { ...cfg, dataZoom: [ { type: 'inside' }, { type: 'slider', height: SLIDER_HEIGHT, bottom: SLIDER_BOTTOM, }, ], } } /** Style block matching v1's `getEChartZoomSliderStyles`. */ function getZoomSliderStyles(theme: Theme) { const secondary = theme.palette.secondary?.main ?? '#358BE7' return { fillerColor: 'rgba(53, 139, 231, 0.25)', borderColor: 'rgba(53, 139, 231, 0.3)', borderWidth: 0.5, backgroundColor: 'transparent', borderRadius: 4, dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 1, color: secondary }, }, selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 1, color: secondary }, }, handleStyle: { color: theme.palette.common?.white ?? '#fff', borderColor: 'rgba(3, 111, 226, 0.08)', borderWidth: 1, shadowBlur: 3, shadowColor: 'rgba(0, 0, 0, 0.1)', shadowOffsetX: 0, shadowOffsetY: 1, }, textStyle: { color: theme.palette.black?.[60] ?? theme.palette.text?.primary, fontSize: parseInt( (theme.typography.overlineDelicate?.fontSize as string | undefined) ?? '11', ), fontFamily: theme.typography.overlineDelicate?.fontFamily ?? theme.typography.caption?.fontFamily, }, } } /** * Visual layout constants used by the data-fusion mergers (bar, histogram, * timeseries) when laying out the slider against the legend / grid. */ export const ZOOM_LAYOUT = { /** Slider height in px (matches `createAddZoom` output). */ sliderHeight: SLIDER_HEIGHT, /** Vertical gap between chart grid and the slider. */ sliderGap: 8, /** Slider `bottom` when a legend is shown — sits above the legend row. */ sliderBottomWithLegend: 28, } as const