import { useCallback, useMemo, useState } from 'react' import { bisector } from 'd3-array' import { axisBottom, axisLeft } from 'd3-axis' import { D3BrushEvent } from 'd3-brush' import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' import { curveLinear, curveStepAfter, stack, stackOrderReverse, SeriesPoint, } from 'd3-shape' import { range } from 'lodash' import { SVGChart, AreaPath, AreaWithTopStroke, TooltipContent, formatPct, } from './helpers' import { useEvent } from 'web/hooks/use-event' export type MultiPoint = { x: Date; y: number[]; datum?: T } export type HistoryPoint = { x: Date; y: number; datum?: T } export type DistributionPoint = { x: number; y: number; datum?: T } const getTickValues = (min: number, max: number, n: number) => { const step = (max - min) / (n - 1) return [min, ...range(1, n - 1).map((i) => min + step * i), max] } export const SingleValueDistributionChart = (props: { data: DistributionPoint[] w: number h: number color: string xScale: ScaleContinuousNumeric yScale: ScaleContinuousNumeric Tooltip?: TooltipContent> }) => { const { color, data, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale const px = useCallback((p: DistributionPoint) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) const py1 = useCallback((p: DistributionPoint) => yScale(p.y), [yScale]) const xBisector = bisector((p: DistributionPoint) => p.x) const { xAxis, yAxis } = useMemo(() => { const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = axisLeft(yScale).tickFormat((n) => formatPct(n, 2)) return { xAxis, yAxis } }, [w, xScale, yScale]) const onSelect = useEvent((ev: D3BrushEvent>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) } else { setViewXScale(undefined) } }) const onMouseOver = useEvent((mouseX: number) => { const queryX = xScale.invert(mouseX) const item = data[xBisector.left(data, queryX) - 1] if (item == null) { // this can happen if you are on the very left or right edge of the chart, // so your queryX is out of bounds return } return { x: queryX, y: item.y, datum: item.datum } }) return ( ) } export type SingleValueDistributionTooltipProps = { p: DistributionPoint xScale: React.ComponentProps>['xScale'] } export const MultiValueHistoryChart = (props: { data: MultiPoint[] w: number h: number colors: readonly string[] xScale: ScaleTime yScale: ScaleContinuousNumeric Tooltip?: TooltipContent> pct?: boolean }) => { const { colors, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale type SP = SeriesPoint> const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) const xBisector = bisector((p: MultiPoint) => p.x) const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = pct ? axisLeft(yScale) .tickValues(pctTickValues) .tickFormat((n) => formatPct(n)) : axisLeft(yScale) return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const series = useMemo(() => { const d3Stack = stack, number>() .keys(range(0, Math.max(...data.map(({ y }) => y.length)))) .value(({ y }, o) => y[o]) .order(stackOrderReverse) return d3Stack(data) }, [data]) const onSelect = useEvent((ev: D3BrushEvent>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) } else { setViewXScale(undefined) } }) const onMouseOver = useEvent((mouseX: number) => { const queryX = xScale.invert(mouseX) const item = data[xBisector.left(data, queryX) - 1] if (item == null) { // this can happen if you are on the very left or right edge of the chart, // so your queryX is out of bounds return } return { x: queryX, y: item.y, datum: item.datum } }) return ( {series.map((s, i) => ( ))} ) } export type MultiValueHistoryTooltipProps = { p: MultiPoint xScale: React.ComponentProps>['xScale'] } export const SingleValueHistoryChart = (props: { data: HistoryPoint[] w: number h: number color: string xScale: ScaleTime yScale: ScaleContinuousNumeric Tooltip?: TooltipContent> pct?: boolean }) => { const { color, data, pct, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale const px = useCallback((p: HistoryPoint) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) const py1 = useCallback((p: HistoryPoint) => yScale(p.y), [yScale]) const xBisector = bisector((p: HistoryPoint) => p.x) const { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = pct ? axisLeft(yScale) .tickValues(pctTickValues) .tickFormat((n) => formatPct(n)) : axisLeft(yScale) return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const onSelect = useEvent((ev: D3BrushEvent>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) } else { setViewXScale(undefined) } }) const onMouseOver = useEvent((mouseX: number) => { const queryX = xScale.invert(mouseX) const item = data[xBisector.left(data, queryX) - 1] if (item == null) { // this can happen if you are on the very left or right edge of the chart, // so your queryX is out of bounds return } return { x: queryX, y: item.y, datum: item.datum } }) return ( ) } export type SingleValueHistoryTooltipProps = { p: HistoryPoint xScale: React.ComponentProps>['xScale'] }