"use client" import React, { useEffect, useMemo, useRef } from "react" import { useContextSelector } from "use-context-selector" import { useIsLessThanBreakpoint } from "../../../components/Media" import { useMouseUp } from "../../../hooks" import { HydrationGuard } from "../../HydrationGuard" import { ChartContainer } from "../ChartContainer" import { ChartContext, ChartContextProvider } from "../ChartContext" import { ChartTooltip, RenderTooltipFn } from "../ChartTooltip" import { Highcharts, HighchartsReact, RefObject } from "../Highcharts" import { DEFAULT_HIGHCHART_OPTIONS, createChartOptions, createSeriesOptions, createTooltipOptions, createXAxisOptions, createYAxisOptions, } from "../utils" import { getPointIndex, getSeriesIndex } from "../utils/points" import { AxisTitleProps, RangeProps } from "../utils/types" import { ScatterplotEmptyState } from "./ScatterplotEmptyState" import { ScatterplotSkeleton } from "./ScatterplotSkeleton" const SCATTERPLOT_BOOST_THRESHOLD = 5000 export type SeriesProps = Pick< Highcharts.SeriesScatterOptions, "color" | "name" | "marker" > & { data: readonly T[] getX: (d: T) => number getY: (d: T) => number } export type ScatterplotProps = AxisTitleProps & RangeProps & { className?: string series: SeriesProps[] renderTooltip?: RenderTooltipFn tooltipDelay?: number interactive?: boolean onClickPoint?: ( dataPoint: T, event: Highcharts.SeriesClickEventObject, ) => unknown overrides?: { xAxis?: Highcharts.XAxisOptions } formatYAxisLabels?: (value: number | string) => string formatXAxisLabels?: (value: number | string) => string } export const ScatterplotBase = ({ className, series: seriesProp, renderTooltip: RenderTooltip, interactive = false, onClickPoint, yAxisTitle, xAxisTitle, hideAxisTitlesBreakpoint = "sm", rangeStart, rangeEnd, overrides = { xAxis: {} }, formatXAxisLabels, formatYAxisLabels, tooltipDelay, }: ScatterplotProps) => { const [ setChart, setIsZoomed, setIsPanning, setXAxisRange, hoveredPoint, setHoveredPoint, ] = useContextSelector(ChartContext, v => [ v.setChart, v.setIsZoomed, v.setIsPanning, v.setXAxisRange, v.hoveredPoint, v.setHoveredPoint, ]) const chartComponentRef = useRef(null) const hideAxisTitle = useIsLessThanBreakpoint(hideAxisTitlesBreakpoint) useEffect(() => { return () => setChart(undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const overridesRef = useRef(overrides) useEffect(() => { overridesRef.current = overrides }, [overrides]) const rangeStartTime = rangeStart?.getTime() const rangeEndTime = rangeEnd?.getTime() useEffect(() => { setXAxisRange([rangeStartTime, rangeEndTime]) chartComponentRef.current?.chart.xAxis[0].setExtremes( rangeStartTime, rangeEndTime, ) }, [rangeStartTime, rangeEndTime, setXAxisRange]) const mouseClickRef = useMouseUp(() => setIsPanning(false)) const seriesData = useMemo( () => seriesProp.map(({ data }) => data), [seriesProp], ) useEffect(() => { setHoveredPoint(undefined) }, [seriesProp, setHoveredPoint]) const series = useMemo(() => { const seriesData: Highcharts.SeriesOptionsType[] = seriesProp.map( ({ data, getX, getY, color, ...rest }, seriesIndex) => { const isBoostModeEnabled = data.length >= SCATTERPLOT_BOOST_THRESHOLD return createSeriesOptions({ id: "scatterplot", type: "scatter", data: data.map((dp, pointIndex) => { if (isBoostModeEnabled) { // Highcharts boost mode does not support object format points return [getX(dp), getY(dp)] } return { name: `${seriesIndex}:${pointIndex}`, x: getX(dp), y: getY(dp), } }), cursor: onClickPoint ? "pointer" : undefined, events: { click: event => { return onClickPoint?.(data[getPointIndex(event.point)], event) }, }, boostThreshold: SCATTERPLOT_BOOST_THRESHOLD, turboThreshold: SCATTERPLOT_BOOST_THRESHOLD, marker: { radius: isBoostModeEnabled ? 2 : 4, }, ...(color ? { color } : {}), ...rest, }) }, ) return seriesData }, [onClickPoint, seriesProp]) const options: Highcharts.Options = useMemo(() => { return { ...DEFAULT_HIGHCHART_OPTIONS, chart: createChartOptions({ interactive, zooming: { type: "xy", }, events: { load() { this.xAxis[0].setExtremes(rangeStartTime, rangeEndTime) setChart(this) }, }, }), yAxis: createYAxisOptions({ axisTitle: hideAxisTitle ? undefined : yAxisTitle, labels: formatYAxisLabels ? { formatter() { return formatYAxisLabels(this.value) }, } : undefined, }), xAxis: createXAxisOptions({ type: "datetime", ...overridesRef.current.xAxis, axisTitle: hideAxisTitle ? undefined : xAxisTitle, events: { afterSetExtremes(event) { setIsZoomed( event.userMin !== rangeStartTime || event.userMax !== rangeEndTime, ) }, setExtremes(event) { if (event.trigger === "pan") { setIsPanning(true) } }, }, labels: formatXAxisLabels ? { formatter() { return formatXAxisLabels(this.value) }, } : undefined, }), series, tooltip: createTooltipOptions({ formatter() { setHoveredPoint(this) return undefined }, }), } }, [ formatXAxisLabels, formatYAxisLabels, hideAxisTitle, interactive, rangeEndTime, rangeStartTime, series, setChart, setHoveredPoint, setIsPanning, setIsZoomed, xAxisTitle, yAxisTitle, ]) return ( { setHoveredPoint(undefined) }} > {hoveredPoint && seriesData[getSeriesIndex(hoveredPoint)]?.[ getPointIndex(hoveredPoint) ] && RenderTooltip && ( } delay={tooltipDelay} pointType="marker" /> )} ) } const ScatterplotWithContext = ({ fallback, ...rest }: ScatterplotProps & { fallback?: React.ReactNode }) => { return ( } > ) } export const Scatterplot = Object.assign(ScatterplotWithContext, { Skeleton: ScatterplotSkeleton, EmptyState: ScatterplotEmptyState, })