diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index c4034a97..34a8a848 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -7,6 +7,8 @@ import { CurveFactory, SeriesPoint, curveLinear, + curveStepBefore, + curveStepAfter, stack, stackOrderReverse, } from 'd3-shape' @@ -19,6 +21,8 @@ import { AreaPath, AreaWithTopStroke, Point, + MouseProps, + SliceMarker, TooltipComponent, computeColorStops, formatPct, @@ -32,21 +36,42 @@ export type HistoryPoint = Point export type DistributionPoint = Point export type ValueKind = 'm$' | 'percent' | 'amount' +type SliceExtent = { y0: number; y1: number } + +const interpolateY = ( + curve: CurveFactory, + x: number, + x0: number, + x1: number, + y0: number, + y1: number +) => { + if (curve === curveLinear) { + const p = (x - x0) / (x1 - x0) + return y0 * (1 - p) + y1 * p + } else if (curve === curveStepAfter) { + return y0 + } else if (curve === curveStepBefore) { + return y1 + } +} + 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] } -const betAtPointSelector = >( +const dataAtPointSelector = >( data: P[], xScale: ContinuousScale ) => { const bisect = bisector((p: P) => p.x) return (posX: number) => { const x = xScale.invert(posX) - const item = data[bisect.left(data, x) - 1] - const result = item ? { ...item, x: posX } : undefined - return result + const i = bisect.left(data, x) + const prev = data[i - 1] as P | undefined + const next = data[i] as P | undefined + return { prev, next, x: posX } } } @@ -64,6 +89,7 @@ export const DistributionChart =

(props: { }) => { const { data, w, h, color, margin, yScale, curve, Tooltip } = props + const [mouse, setMouse] = useState>() const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale @@ -78,13 +104,19 @@ export const DistributionChart =

(props: { return { xAxis, yAxis } }, [w, xScale, yScale]) - const selector = betAtPointSelector(data, xScale) - const onMouseOver = useEvent((mouseX: number) => { + const selector = dataAtPointSelector(data, xScale) + const onMouseOver = useEvent((mouseX: number, mouseY: number) => { const p = selector(mouseX) - props.onMouseOver?.(p) - return p + props.onMouseOver?.(p.prev) + if (p.prev) { + setMouse({ x: mouseX, y: mouseY, data: p.prev }) + } else { + setMouse(undefined) + } }) + const onMouseLeave = useEvent(() => setMouse(undefined)) + const onSelect = useEvent((ev: D3BrushEvent

) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] @@ -103,8 +135,10 @@ export const DistributionChart =

(props: { margin={margin} xAxis={xAxis} yAxis={yAxis} + mouse={mouse} onSelect={onSelect} onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} Tooltip={Tooltip} > (props: { }) => { const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props + const [mouse, setMouse] = useState>() const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale @@ -168,13 +203,19 @@ export const MultiValueHistoryChart =

(props: { return d3Stack(data) }, [data]) - const selector = betAtPointSelector(data, xScale) - const onMouseOver = useEvent((mouseX: number) => { + const selector = dataAtPointSelector(data, xScale) + const onMouseOver = useEvent((mouseX: number, mouseY: number) => { const p = selector(mouseX) - props.onMouseOver?.(p) - return p + props.onMouseOver?.(p.prev) + if (p.prev) { + setMouse({ x: mouseX, y: mouseY, data: p.prev }) + } else { + setMouse(undefined) + } }) + const onMouseLeave = useEvent(() => setMouse(undefined)) + const onSelect = useEvent((ev: D3BrushEvent

) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] @@ -193,8 +234,10 @@ export const MultiValueHistoryChart =

(props: { margin={margin} xAxis={xAxis} yAxis={yAxis} + mouse={mouse} onSelect={onSelect} onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} Tooltip={Tooltip} > {series.map((s, i) => ( @@ -226,8 +269,10 @@ export const SingleValueHistoryChart =

(props: { Tooltip?: TooltipComponent pct?: boolean }) => { - const { data, w, h, color, margin, yScale, yKind, curve, Tooltip } = props + const { data, w, h, color, margin, yScale, yKind, Tooltip } = props + const curve = props.curve ?? curveLinear + const [mouse, setMouse] = useState & SliceExtent>() const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale @@ -253,13 +298,30 @@ export const SingleValueHistoryChart =

(props: { return { xAxis, yAxis } }, [w, h, yKind, xScale, yScale]) - const selector = betAtPointSelector(data, xScale) - const onMouseOver = useEvent((mouseX: number) => { + const selector = dataAtPointSelector(data, xScale) + const onMouseOver = useEvent((mouseX: number, mouseY: number) => { const p = selector(mouseX) - props.onMouseOver?.(p) - return p + props.onMouseOver?.(p.prev) + const x0 = p.prev ? xScale(p.prev.x) : xScale.range()[0] + const x1 = p.next ? xScale(p.next.x) : xScale.range()[1] + const y0 = p.prev ? yScale(p.prev.y) : yScale.range()[0] + const y1 = p.next ? yScale(p.next.y) : yScale.range()[1] + const markerY = interpolateY(curve, mouseX, x0, x1, y0, y1) + if (p.prev && markerY) { + setMouse({ + x: mouseX, + y: mouseY, + y0: py0, + y1: markerY, + data: p.prev, + }) + } else { + setMouse(undefined) + } }) + const onMouseLeave = useEvent(() => setMouse(undefined)) + const onSelect = useEvent((ev: D3BrushEvent

) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] @@ -285,8 +347,10 @@ export const SingleValueHistoryChart =

(props: { margin={margin} xAxis={xAxis} yAxis={yAxis} + mouse={mouse} onSelect={onSelect} onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} Tooltip={Tooltip} > {stops && ( @@ -306,6 +370,9 @@ export const SingleValueHistoryChart =

(props: { py1={py1} curve={curve ?? curveLinear} /> + {mouse && ( + + )} ) } diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 4fbfc437..efa9040b 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -1,12 +1,4 @@ -import { - ReactNode, - SVGProps, - memo, - useRef, - useEffect, - useMemo, - useState, -} from 'react' +import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' import { pointer, select } from 'd3-selection' import { Axis, AxisScale } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' @@ -123,6 +115,28 @@ export const AreaWithTopStroke = (props: { ) } +export const SliceMarker = (props: { + color: string + x: number + y0: number + y1: number +}) => { + const { color, x, y0, y1 } = props + return ( + + + + + ) +} + export const SVGChart = (props: { children: ReactNode w: number @@ -130,8 +144,10 @@ export const SVGChart = (props: { margin: Margin xAxis: Axis yAxis: Axis + mouse: MouseProps | undefined onSelect?: (ev: D3BrushEvent) => void - onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined + onMouseOver?: (mouseX: number, mouseY: number) => void + onMouseLeave?: () => void Tooltip?: TooltipComponent }) => { const { @@ -141,11 +157,12 @@ export const SVGChart = (props: { margin, xAxis, yAxis, - onMouseOver, + mouse, onSelect, + onMouseOver, + onMouseLeave, Tooltip, } = props - const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>() const tooltipMeasure = useMeasureSize() const overlayRef = useRef(null) const innerW = w - (margin.left + margin.right) @@ -165,7 +182,7 @@ export const SVGChart = (props: { if (!justSelected.current) { justSelected.current = true onSelect(ev) - setMouse(undefined) + onMouseLeave?.() if (overlayRef.current) { select(overlayRef.current).call(brush.clear) } @@ -181,22 +198,17 @@ export const SVGChart = (props: { .select('.selection') .attr('shape-rendering', 'null') } - }, [innerW, innerH, onSelect]) + }, [innerW, innerH, onSelect, onMouseLeave]) const onPointerMove = (ev: React.PointerEvent) => { if (ev.pointerType === 'mouse' && onMouseOver) { const [x, y] = pointer(ev) - const data = onMouseOver(x, y) - if (data !== undefined) { - setMouse({ x, y, data }) - } else { - setMouse(undefined) - } + onMouseOver(x, y) } } const onPointerLeave = () => { - setMouse(undefined) + onMouseLeave?.() } return ( @@ -308,6 +320,8 @@ export const TooltipContainer = (props: { ) } +export type MouseProps = { x: number; y: number; data: T } + export const computeColorStops = ( data: P[], pc: (p: P) => string,