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

View File

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

View File

@ -21,12 +21,15 @@ const getNumericChartData = (contract: NumericContract) => {
}))
}
const NumericChartTooltip = (props: TooltipProps<DistributionPoint>) => {
const { x, y } = props.p
const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint>
) => {
const { data, mouseX, xScale } = props
const x = xScale.invert(mouseX)
return (
<>
<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) => ({
x: new Date(b.createdTime),
y: scaleP(b.probAfter),
datum: b,
obj: b,
}))
}
const PseudoNumericChartTooltip = (props: TooltipProps<HistoryPoint<Bet>>) => {
const { p, xScale } = props
const { x, y, datum } = p
const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>>
) => {
const { data, mouseX, xScale } = props
const [start, end] = xScale.domain()
const d = xScale.invert(mouseX)
return (
<Row className="items-center gap-2">
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(x, start, end)}</span>
<span className="text-greyscale-6">{formatLargeNumber(y)}</span>
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
<span className="font-semibold">{formatDateInRange(d, start, end)}</span>
<span className="text-greyscale-6">{formatLargeNumber(data.y)}</span>
</Row>
)
}

View File

@ -13,6 +13,7 @@ import {
import { range } from 'lodash'
import {
ContinuousScale,
SVGChart,
AreaPath,
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]
}
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: {
data: P[]
w: number
@ -39,7 +53,7 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P>
Tooltip?: TooltipComponent<number, P>
}) => {
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 py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100)
@ -58,6 +71,8 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis }
}, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
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 (
<SVGChart
w={w}
@ -107,7 +114,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P>
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
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 py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
@ -141,6 +147,8 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
return d3Stack(data)
}, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
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 (
<SVGChart
w={w}
@ -193,7 +193,7 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<P>
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
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 py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const xBisector = bisector((p: P) => p.x)
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
@ -218,6 +217,8 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
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 (
<SVGChart
w={w}

View File

@ -17,7 +17,12 @@ import clsx from 'clsx'
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 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
w: number
h: number
xAxis: Axis<X>
yAxis: Axis<number>
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => P | undefined
Tooltip?: TooltipComponent<P>
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined
Tooltip?: TooltipComponent<X, TT>
}) => {
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 innerW = w - MARGIN_X
const innerH = h - MARGIN_Y
@ -148,7 +153,7 @@ export const SVGChart = <X, Y, P extends Point<X, Y>>(props: {
if (!justSelected.current) {
justSelected.current = true
onSelect(ev)
setMouseState(undefined)
setMouse(undefined)
if (overlayRef.current) {
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) => {
if (ev.pointerType === 'mouse' && onMouseOver) {
const [mouseX, mouseY] = pointer(ev)
const p = onMouseOver(mouseX, mouseY)
if (p != null) {
const pos = getTooltipPosition(mouseX, mouseY, innerW, innerH)
setMouseState({ pos, p })
const [x, y] = pointer(ev)
const data = onMouseOver(x, y)
if (data !== undefined) {
setMouse({ x, y, data })
} else {
setMouseState(undefined)
setMouse(undefined)
}
}
}
const onPointerLeave = () => {
setMouseState(undefined)
setMouse(undefined)
}
return (
<div className="relative">
{mouseState && Tooltip && (
<TooltipContainer pos={mouseState.pos}>
<Tooltip xScale={xAxis.scale()} p={mouseState.p} />
{mouse && Tooltip && (
<TooltipContainer
pos={getTooltipPosition(mouse.x, mouse.y, innerW, innerH)}
>
<Tooltip
xScale={xAxis.scale()}
mouseX={mouse.x}
mouseY={mouse.y}
data={mouse.data}
/>
</TooltipContainer>
)}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
@ -243,8 +254,13 @@ export const getTooltipPosition = (
return result
}
export type TooltipProps<P> = { p: P; xScale: XScale<P> }
export type TooltipComponent<P> = React.ComponentType<TooltipProps<P>>
export type TooltipProps<X, T> = {
mouseX: number
mouseY: number
xScale: ContinuousScale<X>
data: T
}
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: {
pos: TooltipPosition
className?: string