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 { x, y, xScale, datum } = props
const { p, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain()
return (
<Row className="items-center gap-2 text-sm">

View File

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

View File

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

View File

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

View File

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