"use client" import React, { useEffect, useMemo, useRef } from "react" import { useContextSelector } from "use-context-selector" import { useMergedRef, useMousePosition, useMouseUp } from "../../../hooks" import { useDateRangeFormatter } from "../../../hooks/useDateRangeFormatter" import { isMouseWithinElement } from "../../../utils/element" import { HydrationGuard } from "../../HydrationGuard" import { useIsLessThanBreakpoint } from "../../Media" import { ChartContainer } from "../ChartContainer" import { ChartContext, ChartContextProvider } from "../ChartContext" import { ChartTooltip, RenderTooltipFn } from "../ChartTooltip" import { Highcharts, HighchartsReact, RefObject } from "../Highcharts" import { createChartOptions, createSeriesOptions, createTooltipOptions, createXAxisOptions, createYAxisOptions, type CreateYAxisOptions, type CreateXAxisOptions, DEFAULT_DATE_TIME_LABEL_FORMATS, DEFAULT_HIGHCHART_OPTIONS, DEFAULT_MARKER_STYLES, OUTLIER_MARKER_STYLES, SINGLE_POINT_DATE_LABEL_FORMAT, } from "../utils" import { getPointIndex } from "../utils/points" import { AxisTitleProps, RangeProps } from "../utils/types" import { getCorrespondingVolumeData } from "./getCorrespondingVolumeData" import { TimeSeriesChartEmptyState } from "./TimeSeriesChartEmptyState" import { TimeSeriesChartSkeleton } from "./TimeSeriesChartSkeleton" export type AccessorProps = { getDate: (d: T) => Date getValue?: (d: T) => number getScatterValue?: (d: T) => number getColumnValue?: (d: T) => number getIsOutlier?: (d: T) => boolean } export type StandaloneColumn = { items: S[] getDate: (d: S) => Date getValue: (d: S) => number yAxisMin?: number yAxisMax?: number } export type TimeSeriesChartProps = AxisTitleProps & AccessorProps & RangeProps & { className?: string /** * Points in data must be sorted in chronological order for zooming and panning to work. */ data: T[] // Use standalone columns for a separate column series at the bottom of the chart for e.g. volume data standaloneColumn?: StandaloneColumn renderTooltip?: RenderTooltipFn< T, { matchedColumnData?: S } > tooltipDelay?: number interactive?: boolean lineType?: "spline" | "areaspline" | "line" | "area" lineColor?: string lineWidth?: number markerRadius?: number hideAxes?: boolean hideYAxis?: boolean columnAxisTitle?: string singlePointDateLabelFormat?: string overrides?: { tooltip?: Highcharts.TooltipOptions yAxisOptions?: Partial xAxisOptions?: Partial } scatterOptions?: Partial & { tooltipDelay?: number } areaOptions?: Partial & { tooltipDelay?: number } lineOptions?: Partial & { tooltipDelay?: number } splineOptions?: Partial & { tooltipDelay?: number } areaSplineOptions?: Partial & { tooltipDelay?: number } } const STANDALONE_COLUMN_NAME = "standalone" const TimeSeriesChartBase = ({ data, getColumnValue, getScatterValue, getIsOutlier, standaloneColumn, getDate, getValue, className, renderTooltip: RenderTooltip, interactive = false, lineType = "spline", lineWidth = 2, hideAxes = false, hideYAxis = false, xAxisTitle, yAxisTitle, columnAxisTitle, hideAxisTitlesBreakpoint = "sm", rangeStart, rangeEnd, yAxisMin, yAxisMax, singlePointDateLabelFormat = SINGLE_POINT_DATE_LABEL_FORMAT, overrides = { tooltip: {}, yAxisOptions: {}, xAxisOptions: {} }, scatterOptions, lineOptions, formatXAxisLabels, formatYAxisLabels, tooltipDelay, splineOptions, areaSplineOptions, areaOptions, }: TimeSeriesChartProps) => { 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) const chartRef = useRef(null) const mousePosition = useMousePosition({ throttle: 100 }) const defaultDateFormatter = useDateRangeFormatter( rangeStart ?? (data[0] ? getDate(data[0]) : new Date()), rangeEnd ?? (data[data.length - 1] ? getDate(data[data.length - 1]) : new Date()), ) 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, ) }, [setXAxisRange, rangeStartTime, rangeEndTime]) useEffect(() => { setHoveredPoint(undefined) }, [data, setHoveredPoint]) const mouseUpRef = useMouseUp(() => setIsPanning(false)) const mergedRef = useMergedRef(chartRef, mouseUpRef) // Doing this because hoveredPoint.index is not reliable when trying to map back to data // as highcharts can reorder series under the hood. const dataIndexMap = useMemo(() => { const scatterData = getScatterValue ? data.map((dp, index) => ({ x: getDate(dp).getTime(), y: getScatterValue(dp), dataIndex: index, })) : [] const lineData = getValue ? data.map((dp, index) => ({ x: getDate(dp).getTime(), y: getValue(dp), dataIndex: index, })) : [] return new Map( scatterData.concat(lineData).map(dp => [`${dp.x}-${dp.y}`, dp.dataIndex]), ) }, [data, getScatterValue, getValue, getDate]) const series = useMemo(() => { const series: Highcharts.SeriesOptionsType[] = [] const columnOptions: Highcharts.SeriesColumnOptions = { type: "column", color: "rgb(var(--color-bg-additional-3))", borderColor: "rgb(var(--color-bg-additional-3))", borderWidth: 1, borderRadius: 0, maxPointWidth: 32, } if (standaloneColumn) { series.push( createSeriesOptions({ ...columnOptions, name: STANDALONE_COLUMN_NAME, data: standaloneColumn.items.map(dp => [ standaloneColumn.getDate(dp).getTime(), standaloneColumn.getValue(dp), ]), yAxis: 2, states: { inactive: { opacity: 1, }, hover: { enabled: false, }, }, groupPadding: 0, pointPadding: 0, borderWidth: 2, enableMouseTracking: false, }), ) } if (getColumnValue) { series.push( createSeriesOptions({ ...columnOptions, data: data.map(dp => [getDate(dp).getTime(), getColumnValue(dp)]), yAxis: 1, states: { hover: { color: "var(--color-bg-additional-3)", }, }, }), ) } if (getScatterValue) { series.push( createSeriesOptions({ type: "scatter", data: data.map(dp => ({ x: getDate(dp).getTime(), y: getScatterValue(dp), marker: getIsOutlier && getIsOutlier(dp) ? OUTLIER_MARKER_STYLES : DEFAULT_MARKER_STYLES, })), yAxis: 0, ...scatterOptions, }), ) } // Only add line series if getValue is provided if (getValue) { if (lineType === "areaspline") { series.push( createSeriesOptions({ type: "areaspline", data: data.map(dp => [getDate(dp).getTime(), getValue(dp)]), yAxis: 0, marker: { enabled: false, }, lineColor: "rgb(var(--color-blue-4))", color: { linearGradient: { x1: 0, x2: 0, y1: 0, y2: 1 }, stops: [ [0, "rgb(var(--color-blue-4) / 50%)"], [1, "rgb(var(--color-bg-app))"], // end ], }, lineWidth, ...areaSplineOptions, }), ) } else if (lineType === "area") { series.push( createSeriesOptions({ type: "area", data: data.map(dp => [getDate(dp).getTime(), getValue(dp)]), yAxis: 0, marker: { enabled: false, }, lineColor: "rgb(var(--color-blue-4))", color: { linearGradient: { x1: 0, x2: 0, y1: 0, y2: 1 }, stops: [ [0, "rgb(var(--color-blue-4) / 50%)"], [1, "rgb(var(--color-bg-app))"], // end ], }, lineWidth, ...areaOptions, }), ) } else if (lineType === "line") { series.push( createSeriesOptions({ type: lineType, data: data.map(dp => [getDate(dp).getTime(), getValue(dp)]), yAxis: 0, color: "rgb(var(--color-blue-4))", marker: { enabled: false, }, lineWidth, ...lineOptions, }), ) } else { series.push( createSeriesOptions({ type: lineType, data: data.map(dp => [getDate(dp).getTime(), getValue(dp)]), yAxis: 0, color: "rgb(var(--color-blue-4))", marker: { enabled: false, }, lineWidth, ...splineOptions, }), ) } } return series }, [ areaOptions, standaloneColumn, getColumnValue, getScatterValue, lineType, data, getDate, scatterOptions, getIsOutlier, lineWidth, areaSplineOptions, getValue, lineOptions, splineOptions, ]) const options: Highcharts.Options = useMemo(() => { return { ...DEFAULT_HIGHCHART_OPTIONS, chart: createChartOptions({ animation: false, interactive, spacing: hideAxes ? [0, 0, 0, 0] : Highcharts.defaultOptions.chart?.spacing, events: { load() { this.xAxis[0].setExtremes(rangeStartTime, rangeEndTime) setChart(this) }, }, marginLeft: hideAxes || hideYAxis ? 0 : undefined, marginRight: hideAxes || hideYAxis ? 0 : undefined, }), yAxis: [ createYAxisOptions({ visible: !hideAxes && !hideYAxis, axisTitle: hideAxisTitle ? undefined : columnAxisTitle, min: yAxisMin, max: yAxisMax, ceiling: yAxisMax, startOnTick: false, endOnTick: false, labels: { align: "left", x: -2, // overlap the y-axis slightly over the chart ...(formatYAxisLabels ? { formatter() { return formatYAxisLabels(this.value) }, } : {}), }, ...overridesRef.current.yAxisOptions, }), createYAxisOptions({ opposite: true, visible: false, axisTitle: hideAxisTitle ? undefined : yAxisTitle, min: yAxisMin, max: yAxisMax, ceiling: yAxisMax, startOnTick: false, endOnTick: false, }), createYAxisOptions({ visible: false, axisTitle: undefined, height: "30%", top: "70%", min: standaloneColumn?.yAxisMin, max: standaloneColumn?.yAxisMax, ceiling: standaloneColumn?.yAxisMax, startOnTick: false, endOnTick: false, }), ], xAxis: createXAxisOptions({ type: "datetime", visible: !hideAxes, axisTitle: hideAxisTitle ? undefined : xAxisTitle, dateTimeLabelFormats: data.length === 1 ? { ...DEFAULT_DATE_TIME_LABEL_FORMATS, // Highcharts uses millisecond date time label format if there is only one point millisecond: singlePointDateLabelFormat, } : DEFAULT_DATE_TIME_LABEL_FORMATS, events: { afterSetExtremes(event) { setIsZoomed( event.userMin !== rangeStartTime || event.userMax !== rangeEndTime, ) }, setExtremes(event) { if (event.trigger === "pan") { setIsPanning(true) } }, }, labels: { formatter() { return formatXAxisLabels ? formatXAxisLabels(this.value) : defaultDateFormatter(new Date(this.value)) }, }, ...overridesRef.current.xAxisOptions, }), series, tooltip: createTooltipOptions({ formatter() { // Never show 0 values setHoveredPoint(this.y === 0 ? undefined : this) return undefined }, ...overridesRef.current.tooltip, }), } }, [ interactive, hideAxes, hideYAxis, hideAxisTitle, columnAxisTitle, yAxisMin, yAxisMax, formatYAxisLabels, yAxisTitle, standaloneColumn?.yAxisMin, standaloneColumn?.yAxisMax, xAxisTitle, data.length, singlePointDateLabelFormat, series, rangeStartTime, rangeEndTime, setChart, setIsZoomed, setIsPanning, formatXAxisLabels, defaultDateFormatter, setHoveredPoint, ]) const tooltipData = hoveredPoint && (data[dataIndexMap.get(`${hoveredPoint.x}-${hoveredPoint.y}`) ?? -1] ?? data[getPointIndex(hoveredPoint)]) const resolvedTooltipDelay = (hoveredPoint?.series?.type === "scatter" ? scatterOptions?.tooltipDelay : hoveredPoint?.series?.type === "line" ? lineOptions?.tooltipDelay : tooltipDelay) ?? tooltipDelay useEffect(() => { if (!isMouseWithinElement(mousePosition, chartRef.current)) { setHoveredPoint(undefined) } }, [mousePosition, setHoveredPoint]) return ( { setHoveredPoint(undefined) }} > {tooltipData && RenderTooltip && ( } delay={resolvedTooltipDelay} pointType="marker" /> )} ) } const TimeSeriesChartWithContext = ({ fallback, ...rest }: TimeSeriesChartProps & { fallback?: React.ReactNode }) => { return ( } > ) } export const TimeSeriesChart = Object.assign(TimeSeriesChartWithContext, { Skeleton: TimeSeriesChartSkeleton, EmptyState: TimeSeriesChartEmptyState, })