More refactoring to make chart tooltips more flexible (#975)

This commit is contained in:
Marshall Polaris 2022-09-30 16:16:04 -07:00 committed by GitHub
parent 1fc2f15dae
commit 38b7c898f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 88 additions and 72 deletions

View File

@ -25,19 +25,19 @@ const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
y: b.probAfter, y: b.probAfter,
datum: b, obj: b,
})) }))
} }
const BinaryChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { p, xScale } = props const { data, mouseX, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(x, start, end)}</span> <span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatPct(y)}</span> <span className="text-greyscale-6">{formatPct(data.y)}</span>
</Row> </Row>
) )
} }

View File

@ -114,7 +114,7 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
points.push({ points.push({
x: new Date(bet.createdTime), x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
datum: bet, obj: bet,
}) })
} }
return points return points
@ -181,12 +181,12 @@ export const ChoiceContractChart = (props: {
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
const ChoiceTooltip = useMemo( const ChoiceTooltip = useMemo(
() => (props: TooltipProps<MultiPoint<Bet>>) => { () => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { p, xScale } = props const { data, mouseX, xScale } = props
const { x, y, datum } = p
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
const legendItems = sortBy( const legendItems = sortBy(
y.map((p, i) => ({ data.y.map((p, i) => ({
color: CATEGORY_COLORS[i], color: CATEGORY_COLORS[i],
label: answers[i].text, label: answers[i].text,
value: formatPct(p), value: formatPct(p),
@ -197,9 +197,11 @@ export const ChoiceContractChart = (props: {
return ( return (
<> <>
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />} {data.obj && (
<Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} />
)}
<span className="text-semibold text-base"> <span className="text-semibold text-base">
{formatDateInRange(x, start, end)} {formatDateInRange(d, start, end)}
</span> </span>
</Row> </Row>
<Legend className="max-w-xs" items={legendItems} /> <Legend className="max-w-xs" items={legendItems} />

View File

@ -21,12 +21,15 @@ const getNumericChartData = (contract: NumericContract) => {
})) }))
} }
const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => { const NumericChartTooltip = (
const { x, y } = props.p props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return ( return (
<> <>
<span className="text-semibold">{formatLargeNumber(x)}</span> <span className="text-semibold">{formatLargeNumber(x)}</span>
<span className="text-greyscale-6">{formatPct(y, 2)}</span> <span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</> </>
) )
} }

View File

@ -37,19 +37,21 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
y: scaleP(b.probAfter), y: scaleP(b.probAfter),
datum: b, obj: b,
})) }))
} }
const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => { const PseudoNumericChartTooltip = (
const { p, xScale } = props props: TooltipProps<Date, HistoryPoint<Bet>>
const { x, y, datum } = p ) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(x, start, end)}</span> <span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatLargeNumber(y)}</span> <span className="text-greyscale-6">{formatLargeNumber(data.y)}</span>
</Row> </Row>
) )
} }

View File

@ -13,6 +13,7 @@ import {
import { range } from 'lodash' import { range } from 'lodash'
import { import {
ContinuousScale,
SVGChart, SVGChart,
AreaPath, AreaPath,
AreaWithTopStroke, AreaWithTopStroke,
@ -31,6 +32,19 @@ const getTickValues = (min: number, max: number, n: number) => {
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>>(
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
}
}
export const DistributionChart = <P extends DistributionPoint>(props: { export const DistributionChart = <P extends DistributionPoint>(props: {
data: P[] data: P[]
w: number w: number
@ -39,7 +53,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
xScale: ScaleContinuousNumeric<number, number> xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<number, P>
}) => { }) => {
const { color, data, yScale, w, h, Tooltip } = props const { color, data, yScale, w, h, Tooltip } = props
@ -50,7 +64,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100) const xAxis = axisBottom<number>(xScale).ticks(w / 100)
@ -58,6 +71,8 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, xScale, yScale]) }, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
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]
@ -69,14 +84,6 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
const result = item ? { ...item, x: queryX } : undefined
props.onMouseOver?.(result)
return result
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -107,7 +114,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { colors, data, yScale, w, h, Tooltip, pct } = props const { colors, data, yScale, w, h, Tooltip, pct } = props
@ -119,7 +126,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const px = useCallback((p: SP) => xScale(p.data.x), [xScale]) const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
@ -141,6 +147,8 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data) return d3Stack(data)
}, [data]) }, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
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]
@ -152,14 +160,6 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
const result = item ? { ...item, x: queryX } : undefined
props.onMouseOver?.(result)
return result
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}
@ -193,7 +193,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { color, data, yScale, w, h, Tooltip, pct } = props const { color, data, yScale, w, h, Tooltip, pct } = props
@ -204,7 +204,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
@ -218,6 +217,8 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale]) }, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
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]
@ -229,14 +230,6 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
} }
}) })
const onMouseOver = useEvent((mouseX: number) => {
const queryX = xScale.invert(mouseX)
const item = data[xBisector.left(data, queryX) - 1]
const result = item ? { ...item, x: queryX } : undefined
props.onMouseOver?.(result)
return result
})
return ( return (
<SVGChart <SVGChart
w={w} w={w}

View File

@ -17,7 +17,12 @@ import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
export type Point<X, Y, T = unknown> = { x: X; y: Y; datum?: T } export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
export interface ContinuousScale<T> extends AxisScale<T> {
invert(n: number): T
}
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
@ -118,18 +123,18 @@ export const AreaWithTopStroke = <P,>(props: {
) )
} }
export const SVGChart = <X, Y, P extends Point<X, Y>>(props: { export const SVGChart = <X, TT>(props: {
children: ReactNode children: ReactNode
w: number w: number
h: number h: number
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<number> yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => P | undefined onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<P> Tooltip?: TooltipComponent<X, TT>
}) => { }) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props
const [mouseState, setMouseState] = useState<{ pos: TooltipPosition; p: P }>() const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>()
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
@ -148,7 +153,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouseState(undefined) setMouse(undefined)
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -168,26 +173,32 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
const onPointerMove = (ev: React.PointerEvent) => { const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) { if (ev.pointerType === 'mouse' && onMouseOver) {
const [mouseX, mouseY] = pointer(ev) const [x, y] = pointer(ev)
const p = onMouseOver(mouseX, mouseY) const data = onMouseOver(x, y)
if (p != null) { if (data !== undefined) {
const pos = getTooltipPosition(mouseX, mouseY, innerW, innerH) setMouse({ x, y, data })
setMouseState({ pos, p })
} else { } else {
setMouseState(undefined) setMouse(undefined)
} }
} }
} }
const onPointerLeave = () => { const onPointerLeave = () => {
setMouseState(undefined) setMouse(undefined)
} }
return ( return (
<div className="relative"> <div className="relative">
{mouseState && Tooltip && ( {mouse && Tooltip && (
<TooltipContainer pos={mouseState.pos}> <TooltipContainer
<Tooltip xScale={xAxis.scale()} p={mouseState.p} /> pos={getTooltipPosition(mouse.x, mouse.y, innerW, innerH)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
/>
</TooltipContainer> </TooltipContainer>
)} )}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}> <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
@ -243,8 +254,13 @@ export const getTooltipPosition = (
return result return result
} }
export type TooltipProps<P> = { p: P; xScale: XScale<P> } export type TooltipProps<X, T> = {
export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>> mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: { export const TooltipContainer = (props: {
pos: TooltipPosition pos: TooltipPosition
className?: string className?: string