Clean up chart tooltip handling (#959)
This commit is contained in:
		
							parent
							
								
									be010da9f5
								
							
						
					
					
						commit
						8862425120
					
				|  | @ -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"> | ||||
|  |  | |||
|  | @ -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) => ({ | ||||
|  |  | |||
|  | @ -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)} | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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'] | ||||
| } | ||||
|  |  | |||
|  | @ -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> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user