Implement basic graph tooltip slice marker thingy (#995)
This commit is contained in:
parent
f085df96e3
commit
a55d85d4b6
|
@ -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<T = unknown> = Point<Date, number, T>
|
|||
export type DistributionPoint<T = unknown> = Point<number, number, T>
|
||||
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 = <X, Y, P extends Point<X, Y>>(
|
||||
const dataAtPointSelector = <X, Y, P extends Point<X, Y>>(
|
||||
data: P[],
|
||||
xScale: ContinuousScale<X>
|
||||
) => {
|
||||
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 = <P extends DistributionPoint>(props: {
|
|||
}) => {
|
||||
const { data, w, h, color, margin, yScale, curve, Tooltip } = props
|
||||
|
||||
const [mouse, setMouse] = useState<MouseProps<P>>()
|
||||
const [viewXScale, setViewXScale] =
|
||||
useState<ScaleContinuousNumeric<number, number>>()
|
||||
const xScale = viewXScale ?? props.xScale
|
||||
|
@ -78,13 +104,19 @@ export const DistributionChart = <P extends DistributionPoint>(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<P>) => {
|
||||
if (ev.selection) {
|
||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||
|
@ -103,8 +135,10 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
|
|||
margin={margin}
|
||||
xAxis={xAxis}
|
||||
yAxis={yAxis}
|
||||
mouse={mouse}
|
||||
onSelect={onSelect}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseLeave={onMouseLeave}
|
||||
Tooltip={Tooltip}
|
||||
>
|
||||
<AreaWithTopStroke
|
||||
|
@ -134,6 +168,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(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 xScale = viewXScale ?? props.xScale
|
||||
|
||||
|
@ -168,13 +203,19 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(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<P>) => {
|
||||
if (ev.selection) {
|
||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||
|
@ -193,8 +234,10 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(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 = <P extends HistoryPoint>(props: {
|
|||
Tooltip?: TooltipComponent<Date, P>
|
||||
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 xScale = viewXScale ?? props.xScale
|
||||
|
||||
|
@ -253,13 +298,30 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(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<P>) => {
|
||||
if (ev.selection) {
|
||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||
|
@ -285,8 +347,10 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(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 = <P extends HistoryPoint>(props: {
|
|||
py1={py1}
|
||||
curve={curve ?? curveLinear}
|
||||
/>
|
||||
{mouse && (
|
||||
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
|
||||
)}
|
||||
</SVGChart>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 = <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: {
|
||||
children: ReactNode
|
||||
w: number
|
||||
|
@ -130,8 +144,10 @@ export const SVGChart = <X, TT>(props: {
|
|||
margin: Margin
|
||||
xAxis: Axis<X>
|
||||
yAxis: Axis<number>
|
||||
mouse: MouseProps<TT> | undefined
|
||||
onSelect?: (ev: D3BrushEvent<any>) => void
|
||||
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
|
||||
onMouseOver?: (mouseX: number, mouseY: number) => void
|
||||
onMouseLeave?: () => void
|
||||
Tooltip?: TooltipComponent<X, TT>
|
||||
}) => {
|
||||
const {
|
||||
|
@ -141,11 +157,12 @@ export const SVGChart = <X, TT>(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<SVGGElement>(null)
|
||||
const innerW = w - (margin.left + margin.right)
|
||||
|
@ -165,7 +182,7 @@ export const SVGChart = <X, TT>(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 = <X, TT>(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<T> = { x: number; y: number; data: T }
|
||||
|
||||
export const computeColorStops = <P,>(
|
||||
data: P[],
|
||||
pc: (p: P) => string,
|
||||
|
|
Loading…
Reference in New Issue
Block a user