Implement basic graph tooltip slice marker thingy (#995)
This commit is contained in:
parent
f085df96e3
commit
a55d85d4b6
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user