Implement basic graph tooltip slice marker thingy (#995)

This commit is contained in:
Marshall Polaris 2022-10-04 12:55:51 -07:00 committed by GitHub
parent f085df96e3
commit a55d85d4b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 119 additions and 38 deletions

View File

@ -7,6 +7,8 @@ import {
CurveFactory, CurveFactory,
SeriesPoint, SeriesPoint,
curveLinear, curveLinear,
curveStepBefore,
curveStepAfter,
stack, stack,
stackOrderReverse, stackOrderReverse,
} from 'd3-shape' } from 'd3-shape'
@ -19,6 +21,8 @@ import {
AreaPath, AreaPath,
AreaWithTopStroke, AreaWithTopStroke,
Point, Point,
MouseProps,
SliceMarker,
TooltipComponent, TooltipComponent,
computeColorStops, computeColorStops,
formatPct, formatPct,
@ -32,21 +36,42 @@ export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T> export type DistributionPoint<T = unknown> = Point<number, number, T>
export type ValueKind = 'm$' | 'percent' | 'amount' 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 getTickValues = (min: number, max: number, n: number) => {
const step = (max - min) / (n - 1) const step = (max - min) / (n - 1)
return [min, ...range(1, n - 1).map((i) => min + step * i), max] return [min, ...range(1, n - 1).map((i) => min + step * i), max]
} }
const betAtPointSelector = <X, Y, P extends Point<X, Y>>( const dataAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[], data: P[],
xScale: ContinuousScale<X> xScale: ContinuousScale<X>
) => { ) => {
const bisect = bisector((p: P) => p.x) const bisect = bisector((p: P) => p.x)
return (posX: number) => { return (posX: number) => {
const x = xScale.invert(posX) const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1] const i = bisect.left(data, x)
const result = item ? { ...item, x: posX } : undefined const prev = data[i - 1] as P | undefined
return result const next = data[i] as P | undefined
return { prev, next, x: posX }
} }
} }
@ -64,6 +89,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
}) => { }) => {
const { data, w, h, color, margin, yScale, curve, Tooltip } = props const { data, w, h, color, margin, yScale, curve, Tooltip } = props
const [mouse, setMouse] = useState<MouseProps<P>>()
const [viewXScale, setViewXScale] = const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>() useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -78,13 +104,19 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, xScale, yScale]) }, [w, xScale, yScale])
const selector = betAtPointSelector(data, xScale) const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => { const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX) const p = selector(mouseX)
props.onMouseOver?.(p) props.onMouseOver?.(p.prev)
return p if (p.prev) {
setMouse({ x: mouseX, y: mouseY, data: p.prev })
} else {
setMouse(undefined)
}
}) })
const onMouseLeave = useEvent(() => setMouse(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -103,8 +135,10 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
margin={margin} margin={margin}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
mouse={mouse}
onSelect={onSelect} onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip} Tooltip={Tooltip}
> >
<AreaWithTopStroke <AreaWithTopStroke
@ -134,6 +168,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
}) => { }) => {
const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props
const [mouse, setMouse] = useState<MouseProps<P>>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -168,13 +203,19 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data) return d3Stack(data)
}, [data]) }, [data])
const selector = betAtPointSelector(data, xScale) const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => { const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX) const p = selector(mouseX)
props.onMouseOver?.(p) props.onMouseOver?.(p.prev)
return p if (p.prev) {
setMouse({ x: mouseX, y: mouseY, data: p.prev })
} else {
setMouse(undefined)
}
}) })
const onMouseLeave = useEvent(() => setMouse(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
@ -193,8 +234,10 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
margin={margin} margin={margin}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
mouse={mouse}
onSelect={onSelect} onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip} Tooltip={Tooltip}
> >
{series.map((s, i) => ( {series.map((s, i) => (
@ -226,8 +269,10 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
Tooltip?: TooltipComponent<Date, P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean 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<MouseProps<P> & SliceExtent>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -253,12 +298,29 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, h, yKind, xScale, yScale]) }, [w, h, yKind, xScale, yScale])
const selector = betAtPointSelector(data, xScale) const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => { const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX) const p = selector(mouseX)
props.onMouseOver?.(p) props.onMouseOver?.(p.prev)
return p 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<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
@ -285,8 +347,10 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
margin={margin} margin={margin}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
mouse={mouse}
onSelect={onSelect} onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip} Tooltip={Tooltip}
> >
{stops && ( {stops && (
@ -306,6 +370,9 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
py1={py1} py1={py1}
curve={curve ?? curveLinear} curve={curve ?? curveLinear}
/> />
{mouse && (
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
)}
</SVGChart> </SVGChart>
) )
} }

View File

@ -1,12 +1,4 @@
import { import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { pointer, select } from 'd3-selection' import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis' import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush' import { brushX, D3BrushEvent } from 'd3-brush'
@ -123,6 +115,28 @@ export const AreaWithTopStroke = <P,>(props: {
) )
} }
export const SliceMarker = (props: {
color: string
x: number
y0: number
y1: number
}) => {
const { color, x, y0, y1 } = props
return (
<g>
<line stroke="white" strokeWidth={3} x1={x} x2={x} y1={y0} y2={y1} />
<circle
stroke="white"
strokeWidth={3}
fill={color}
cx={x}
cy={y1}
r={5}
/>
</g>
)
}
export const SVGChart = <X, TT>(props: { export const SVGChart = <X, TT>(props: {
children: ReactNode children: ReactNode
w: number w: number
@ -130,8 +144,10 @@ export const SVGChart = <X, TT>(props: {
margin: Margin margin: Margin
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<number> yAxis: Axis<number>
mouse: MouseProps<TT> | undefined
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined onMouseOver?: (mouseX: number, mouseY: number) => void
onMouseLeave?: () => void
Tooltip?: TooltipComponent<X, TT> Tooltip?: TooltipComponent<X, TT>
}) => { }) => {
const { const {
@ -141,11 +157,12 @@ export const SVGChart = <X, TT>(props: {
margin, margin,
xAxis, xAxis,
yAxis, yAxis,
onMouseOver, mouse,
onSelect, onSelect,
onMouseOver,
onMouseLeave,
Tooltip, Tooltip,
} = props } = props
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
const tooltipMeasure = useMeasureSize() const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null) const overlayRef = useRef<SVGGElement>(null)
const innerW = w - (margin.left + margin.right) const innerW = w - (margin.left + margin.right)
@ -165,7 +182,7 @@ export const SVGChart = <X, TT>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouse(undefined) onMouseLeave?.()
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -181,22 +198,17 @@ export const SVGChart = <X, TT>(props: {
.select('.selection') .select('.selection')
.attr('shape-rendering', 'null') .attr('shape-rendering', 'null')
} }
}, [innerW, innerH, onSelect]) }, [innerW, innerH, onSelect, onMouseLeave])
const onPointerMove = (ev: React.PointerEvent) => { const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) { if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev) const [x, y] = pointer(ev)
const data = onMouseOver(x, y) onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouse(undefined)
}
} }
} }
const onPointerLeave = () => { const onPointerLeave = () => {
setMouse(undefined) onMouseLeave?.()
} }
return ( return (
@ -308,6 +320,8 @@ export const TooltipContainer = (props: {
) )
} }
export type MouseProps<T> = { x: number; y: number; data: T }
export const computeColorStops = <P,>( export const computeColorStops = <P,>(
data: P[], data: P[],
pc: (p: P) => string, pc: (p: P) => string,