Clean up chart tooltip handling (#959)

This commit is contained in:
Marshall Polaris 2022-09-28 21:43:04 -07:00 committed by GitHub
parent be010da9f5
commit 8862425120
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 173 additions and 186 deletions

View File

@ -32,7 +32,8 @@ const getBetPoints = (bets: Bet[]) => {
} }
const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => { const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => {
const { x, y, xScale, datum } = props const { p, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
return ( return (
<Row className="items-center gap-2 text-sm"> <Row className="items-center gap-2 text-sm">

View File

@ -162,7 +162,8 @@ export const ChoiceContractChart = (props: {
const ChoiceTooltip = useMemo( const ChoiceTooltip = useMemo(
() => (props: MultiValueHistoryTooltipProps<Bet>) => { () => (props: MultiValueHistoryTooltipProps<Bet>) => {
const { x, y, xScale, datum } = props const { p, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const legendItems = sortBy( const legendItems = sortBy(
y.map((p, i) => ({ y.map((p, i) => ({

View File

@ -25,7 +25,8 @@ const getNumericChartData = (contract: NumericContract) => {
} }
const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => {
const { x, y } = props const { p } = props
const { x, y } = p
return ( return (
<span className="text-sm"> <span className="text-sm">
<strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)} <strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)}

View File

@ -46,7 +46,8 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
const PseudoNumericChartTooltip = ( const PseudoNumericChartTooltip = (
props: SingleValueHistoryTooltipProps<Bet> props: SingleValueHistoryTooltipProps<Bet>
) => { ) => {
const { x, y, xScale, datum } = props const { p, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
return ( return (
<Row className="items-center gap-2 text-sm"> <Row className="items-center gap-2 text-sm">

View File

@ -3,7 +3,6 @@ import { bisector } from 'd3-array'
import { axisBottom, axisLeft } from 'd3-axis' import { axisBottom, axisLeft } from 'd3-axis'
import { D3BrushEvent } from 'd3-brush' import { D3BrushEvent } from 'd3-brush'
import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale'
import { pointer } from 'd3-selection'
import { import {
curveLinear, curveLinear,
curveStepAfter, curveStepAfter,
@ -18,17 +17,25 @@ import {
AreaPath, AreaPath,
AreaWithTopStroke, AreaWithTopStroke,
TooltipContent, TooltipContent,
TooltipContainer,
TooltipPosition,
formatPct, formatPct,
} from './helpers' } from './helpers'
import { useEvent } from 'web/hooks/use-event' import { useEvent } from 'web/hooks/use-event'
export type MultiPoint<T = never> = { x: Date; y: number[]; datum?: T } export type MultiPoint<T = never> = {
export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T } x: Date
export type DistributionPoint<T = never> = { x: number; y: number; datum?: T } y: number[]
datum?: T
type PositionValue<P> = TooltipPosition & { p: P } }
export type HistoryPoint<T = never> = {
x: Date
y: number
datum?: T
}
export type DistributionPoint<T = never> = {
x: number
y: number
datum?: T
}
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)
@ -48,8 +55,6 @@ export const SingleValueDistributionChart = <T,>(props: {
const [viewXScale, setViewXScale] = const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>() useState<ScaleContinuousNumeric<number, number>>()
const [mouseState, setMouseState] =
useState<PositionValue<DistributionPoint<T>>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale]) const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale])
@ -69,67 +74,48 @@ export const SingleValueDistributionChart = <T,>(props: {
setViewXScale(() => setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
) )
setMouseState(undefined)
} else { } else {
setViewXScale(undefined) setViewXScale(undefined)
setMouseState(undefined)
} }
}) })
const onMouseOver = useEvent((ev: React.PointerEvent) => { const onMouseOver = useEvent((mouseX: number) => {
if (ev.pointerType === 'mouse') { const queryX = xScale.invert(mouseX)
const [mouseX, mouseY] = pointer(ev) const item = data[xBisector.left(data, queryX) - 1]
const queryX = xScale.invert(mouseX) if (item == null) {
const item = data[xBisector.left(data, queryX) - 1] // this can happen if you are on the very left or right edge of the chart,
if (item == null) { // so your queryX is out of bounds
// this can happen if you are on the very left or right edge of the chart, return
// so your queryX is out of bounds
return
}
const p = { x: queryX, y: item.y, datum: item.datum }
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
} }
}) return { x: queryX, y: item.y, datum: item.datum }
const onMouseLeave = useEvent(() => {
setMouseState(undefined)
}) })
return ( return (
<div className="relative"> <SVGChart
{mouseState && Tooltip && ( w={w}
<TooltipContainer className="text-sm" {...mouseState}> h={h}
<Tooltip xScale={xScale} {...mouseState.p} /> xAxis={xAxis}
</TooltipContainer> yAxis={yAxis}
)} onSelect={onSelect}
<SVGChart onMouseOver={onMouseOver}
w={w} Tooltip={Tooltip}
h={h} >
xAxis={xAxis} <AreaWithTopStroke
yAxis={yAxis} color={color}
onSelect={onSelect} data={data}
onMouseOver={onMouseOver} px={px}
onMouseLeave={onMouseLeave} py0={py0}
> py1={py1}
<AreaWithTopStroke curve={curveLinear}
color={color} />
data={data} </SVGChart>
px={px}
py0={py0}
py1={py1}
curve={curveLinear}
/>
</SVGChart>
</div>
) )
} }
export type SingleValueDistributionTooltipProps<T = unknown> = export type SingleValueDistributionTooltipProps<T = unknown> = {
DistributionPoint<T> & { p: DistributionPoint<T>
xScale: React.ComponentProps< xScale: React.ComponentProps<typeof SingleValueDistributionChart<T>>['xScale']
typeof SingleValueDistributionChart<T> }
>['xScale']
}
export const MultiValueHistoryChart = <T,>(props: { export const MultiValueHistoryChart = <T,>(props: {
data: MultiPoint<T>[] data: MultiPoint<T>[]
@ -144,7 +130,6 @@ export const MultiValueHistoryChart = <T,>(props: {
const { colors, data, yScale, w, h, Tooltip, pct } = props const { colors, data, yScale, w, h, Tooltip, pct } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const [mouseState, setMouseState] = useState<PositionValue<MultiPoint<T>>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
type SP = SeriesPoint<MultiPoint<T>> type SP = SeriesPoint<MultiPoint<T>>
@ -177,65 +162,49 @@ export const MultiValueHistoryChart = <T,>(props: {
setViewXScale(() => setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
) )
setMouseState(undefined)
} else { } else {
setViewXScale(undefined) setViewXScale(undefined)
setMouseState(undefined)
} }
}) })
const onMouseOver = useEvent((ev: React.PointerEvent) => { const onMouseOver = useEvent((mouseX: number) => {
if (ev.pointerType === 'mouse') { const queryX = xScale.invert(mouseX)
const [mouseX, mouseY] = pointer(ev) const item = data[xBisector.left(data, queryX) - 1]
const queryX = xScale.invert(mouseX) if (item == null) {
const item = data[xBisector.left(data, queryX) - 1] // this can happen if you are on the very left or right edge of the chart,
if (item == null) { // so your queryX is out of bounds
// this can happen if you are on the very left or right edge of the chart, return
// so your queryX is out of bounds
return
}
const p = { x: queryX, y: item.y, datum: item.datum }
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
} }
}) return { x: queryX, y: item.y, datum: item.datum }
const onMouseLeave = useEvent(() => {
setMouseState(undefined)
}) })
return ( return (
<div className="relative"> <SVGChart
{mouseState && Tooltip && ( w={w}
<TooltipContainer top={mouseState.top} left={mouseState.left}> h={h}
<Tooltip xScale={xScale} {...mouseState.p} /> xAxis={xAxis}
</TooltipContainer> yAxis={yAxis}
)} onSelect={onSelect}
<SVGChart onMouseOver={onMouseOver}
w={w} Tooltip={Tooltip}
h={h} >
xAxis={xAxis} {series.map((s, i) => (
yAxis={yAxis} <AreaPath
onSelect={onSelect} key={i}
onMouseOver={onMouseOver} data={s}
onMouseLeave={onMouseLeave} px={px}
> py0={py0}
{series.map((s, i) => ( py1={py1}
<AreaPath curve={curveStepAfter}
key={i} fill={colors[i]}
data={s} />
px={px} ))}
py0={py0} </SVGChart>
py1={py1}
curve={curveStepAfter}
fill={colors[i]}
/>
))}
</SVGChart>
</div>
) )
} }
export type MultiValueHistoryTooltipProps<T = unknown> = MultiPoint<T> & { export type MultiValueHistoryTooltipProps<T = unknown> = {
p: MultiPoint<T>
xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale'] xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale']
} }
@ -252,7 +221,6 @@ export const SingleValueHistoryChart = <T,>(props: {
const { color, data, pct, yScale, w, h, Tooltip } = props const { color, data, pct, yScale, w, h, Tooltip } = props
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint<T>>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale]) const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale])
@ -276,61 +244,45 @@ export const SingleValueHistoryChart = <T,>(props: {
setViewXScale(() => setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
) )
setMouseState(undefined)
} else { } else {
setViewXScale(undefined) setViewXScale(undefined)
setMouseState(undefined)
} }
}) })
const onMouseOver = useEvent((ev: React.PointerEvent) => { const onMouseOver = useEvent((mouseX: number) => {
if (ev.pointerType === 'mouse') { const queryX = xScale.invert(mouseX)
const [mouseX, mouseY] = pointer(ev) const item = data[xBisector.left(data, queryX) - 1]
const queryX = xScale.invert(mouseX) if (item == null) {
const item = data[xBisector.left(data, queryX) - 1] // this can happen if you are on the very left or right edge of the chart,
if (item == null) { // so your queryX is out of bounds
// this can happen if you are on the very left or right edge of the chart, return
// so your queryX is out of bounds
return
}
const p = { x: queryX, y: item.y, datum: item.datum }
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
} }
}) return { x: queryX, y: item.y, datum: item.datum }
const onMouseLeave = useEvent(() => {
setMouseState(undefined)
}) })
return ( return (
<div className="relative"> <SVGChart
{mouseState && Tooltip && ( w={w}
<TooltipContainer top={mouseState.top} left={mouseState.left}> h={h}
<Tooltip xScale={xScale} {...mouseState.p} /> xAxis={xAxis}
</TooltipContainer> yAxis={yAxis}
)} onSelect={onSelect}
<SVGChart onMouseOver={onMouseOver}
w={w} Tooltip={Tooltip}
h={h} >
xAxis={xAxis} <AreaWithTopStroke
yAxis={yAxis} color={color}
onSelect={onSelect} data={data}
onMouseOver={onMouseOver} px={px}
onMouseLeave={onMouseLeave} py0={py0}
> py1={py1}
<AreaWithTopStroke curve={curveStepAfter}
color={color} />
data={data} </SVGChart>
px={px}
py0={py0}
py1={py1}
curve={curveStepAfter}
/>
</SVGChart>
</div>
) )
} }
export type SingleValueHistoryTooltipProps<T = unknown> = HistoryPoint<T> & { export type SingleValueHistoryTooltipProps<T = unknown> = {
p: HistoryPoint<T>
xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale'] xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale']
} }

View File

@ -1,5 +1,13 @@
import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' import {
import { select } from 'd3-selection' ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { pointer, select } from 'd3-selection'
import { Axis } from 'd3-axis' import { Axis } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush' import { brushX, D3BrushEvent } from 'd3-brush'
import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { area, line, curveStepAfter, CurveFactory } from 'd3-shape'
@ -108,19 +116,18 @@ export const AreaWithTopStroke = <P,>(props: {
) )
} }
export const SVGChart = <X, Y>(props: { export const SVGChart = <X, Y, P, XS>(props: {
children: ReactNode children: ReactNode
w: number w: number
h: number h: number
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<Y> yAxis: Axis<Y>
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (ev: React.PointerEvent) => void onMouseOver?: (mouseX: number, mouseY: number) => P | undefined
onMouseLeave?: (ev: React.PointerEvent) => void Tooltip?: TooltipContent<{ xScale: XS } & { p: P }>
pct?: boolean
}) => { }) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } = const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
props const [mouseState, setMouseState] = useState<TooltipPosition & { p: P }>()
const overlayRef = useRef<SVGGElement>(null) const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X const innerW = w - MARGIN_X
const innerH = h - MARGIN_Y const innerH = h - MARGIN_Y
@ -139,6 +146,7 @@ export const SVGChart = <X, Y>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouseState(undefined)
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -156,29 +164,52 @@ export const SVGChart = <X, Y>(props: {
} }
}, [innerW, innerH, onSelect]) }, [innerW, innerH, onSelect])
const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [mouseX, mouseY] = pointer(ev)
const p = onMouseOver(mouseX, mouseY)
if (p != null) {
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
} else {
setMouseState(undefined)
}
}
}
const onPointerLeave = () => {
setMouseState(undefined)
}
return ( return (
<svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> <div className="relative">
<clipPath id={clipPathId}> {mouseState && Tooltip && (
<rect x={0} y={0} width={innerW} height={innerH} /> <TooltipContainer top={mouseState.top} left={mouseState.left}>
</clipPath> <Tooltip xScale={xAxis.scale() as XS} p={mouseState.p} />
<g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> </TooltipContainer>
<XAxis axis={xAxis} w={innerW} h={innerH} /> )}
<YAxis axis={yAxis} w={innerW} h={innerH} /> <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<g clipPath={`url(#${clipPathId})`}>{children}</g> <clipPath id={clipPathId}>
<g <rect x={0} y={0} width={innerW} height={innerH} />
ref={overlayRef} </clipPath>
x="0" <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}>
y="0" <XAxis axis={xAxis} w={innerW} h={innerH} />
width={innerW} <YAxis axis={yAxis} w={innerW} h={innerH} />
height={innerH} <g clipPath={`url(#${clipPathId})`}>{children}</g>
fill="none" <g
pointerEvents="all" ref={overlayRef}
onPointerEnter={onMouseOver} x="0"
onPointerMove={onMouseOver} y="0"
onPointerLeave={onMouseLeave} width={innerW}
/> height={innerH}
</g> fill="none"
</svg> pointerEvents="all"
onPointerEnter={onPointerMove}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
</g>
</svg>
</div>
) )
} }