Clean up a bunch of tooltip stuff, add FR legend tooltips

This commit is contained in:
Marshall Polaris 2022-09-26 21:54:31 -07:00
parent 7fa11bdc4e
commit b9376a725e
3 changed files with 174 additions and 130 deletions

View File

@ -14,27 +14,25 @@ const getMultiChartData = (
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const { totalBets, outcomeType } = contract const { answers, totalBets, outcomeType } = contract
const sortedBets = sortBy(bets, (b) => b.createdTime) const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const validAnswers = answers.filter((answer) => {
const maxProb = Math.max( const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter) ...betsByOutcome[answer.id].map((bet) => bet.probAfter)
) )
return ( return (
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
maxProb > 0.02 && maxProb > 0.02 &&
totalBets[outcome] > 0.000000001 totalBets[answer.id] > 0.000000001
) )
}) })
const trackedOutcomes = sortBy( const trackedAnswers = sortBy(
outcomes, validAnswers,
(outcome) => -1 * getOutcomeProbability(contract, outcome) (answer) => -1 * getOutcomeProbability(contract, answer.id)
) ).slice(0, 10)
.slice(0, 10)
.reverse()
const points: MultiPoint[] = [] const points: MultiPoint[] = []
@ -51,23 +49,26 @@ const getMultiChartData = (
) )
points.push([ points.push([
new Date(bet.createdTime), new Date(bet.createdTime),
trackedOutcomes.map( trackedAnswers.map(
(outcome) => sharesByOutcome[outcome] ** 2 / sharesSquared (answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared
), ),
]) ])
} }
const allPoints: MultiPoint[] = [ const allPoints: MultiPoint[] = [
[new Date(contract.createdTime), trackedOutcomes.map((_) => 0)], [new Date(contract.createdTime), trackedAnswers.map((_) => 0)],
...points, ...points,
[ [
new Date(Date.now()), new Date(Date.now()),
trackedOutcomes.map((outcome) => trackedAnswers.map((answer) =>
getOutcomeProbability(contract, outcome) getOutcomeProbability(contract, answer.id)
), ),
], ],
] ]
return { points: allPoints, labels: trackedOutcomes } return {
points: allPoints,
labels: trackedAnswers.map((answer) => answer.text),
}
} }
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {

View File

@ -1,4 +1,4 @@
import { useRef, useCallback } from 'react' import { useCallback, useMemo, useState } from 'react'
import { import {
axisBottom, axisBottom,
axisLeft, axisLeft,
@ -7,6 +7,7 @@ import {
curveStepAfter, curveStepAfter,
pointer, pointer,
stack, stack,
stackOrderReverse,
ScaleTime, ScaleTime,
ScaleContinuousNumeric, ScaleContinuousNumeric,
SeriesPoint, SeriesPoint,
@ -14,13 +15,21 @@ import {
import { range } from 'lodash' import { range } from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { SVGChart, AreaPath, AreaWithTopStroke } from './helpers' import {
SVGChart,
AreaPath,
AreaWithTopStroke,
ChartTooltip,
TooltipPosition,
} from './helpers'
import { formatLargeNumber } from 'common/util/format' import { formatLargeNumber } from 'common/util/format'
import { useEvent } from 'web/hooks/use-event' import { useEvent } from 'web/hooks/use-event'
import { Row } from 'web/components/layout/row'
export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]] export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]]
export type HistoryPoint = readonly [Date, number] // [time, number or percentage] export type HistoryPoint = readonly [Date, number] // [time, number or percentage]
export type DistributionPoint = readonly [number, number] // [number, prob] export type DistributionPoint = readonly [number, number] // [outcome amount, prob]
export type PositionValue<P> = TooltipPosition & { p: P }
const formatPct = (n: number, digits?: number) => { const formatPct = (n: number, digits?: number) => {
return `${(n * 100).toFixed(digits ?? 0)}%` return `${(n * 100).toFixed(digits ?? 0)}%`
@ -66,6 +75,28 @@ 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]
} }
type LegendItem = { color: string; label: string; value?: string }
const Legend = (props: { className?: string; items: LegendItem[] }) => {
const { items, className } = props
return (
<ol className={className}>
{items.map((item) => (
<li key={item.label} className="flex flex-row justify-between">
<Row className="mr-4 items-center">
<span
className="mr-2 h-4 w-4"
style={{ backgroundColor: item.color }}
></span>
{item.label}
</Row>
{item.value}
</li>
))}
</ol>
)
}
export const SingleValueDistributionChart = (props: { export const SingleValueDistributionChart = (props: {
data: DistributionPoint[] data: DistributionPoint[]
w: number w: number
@ -75,46 +106,40 @@ export const SingleValueDistributionChart = (props: {
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
}) => { }) => {
const { color, data, xScale, yScale, w, h } = props const { color, data, xScale, yScale, w, h } = props
const tooltipRef = useRef<HTMLDivElement>(null) const [mouseState, setMouseState] =
useState<PositionValue<DistributionPoint>>()
const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale])
const py0 = yScale(0) const py0 = yScale(0)
const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale]) const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale])
const formatX = (n: number) => formatLargeNumber(n)
const formatY = (n: number) => formatPct(n, 2)
const xAxis = axisBottom<number>(xScale).tickFormat(formatX)
const yAxis = axisLeft<number>(yScale).tickFormat(formatY)
const xBisector = bisector((p: DistributionPoint) => p[0]) const xBisector = bisector((p: DistributionPoint) => p[0])
const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
const fmtX = (n: number) => formatLargeNumber(n)
const fmtY = (n: number) => formatPct(n, 2)
const xAxis = axisBottom<number>(xScale).tickFormat(fmtX)
const yAxis = axisLeft<number>(yScale).tickFormat(fmtY)
return { fmtX, fmtY, xAxis, yAxis }
}, [xScale, yScale])
const onMouseOver = useEvent((event: React.PointerEvent) => { const onMouseOver = useEvent((event: React.PointerEvent) => {
const tt = tooltipRef.current const [mouseX, mouseY] = pointer(event)
if (tt != null) { const queryX = xScale.invert(mouseX)
const [mouseX, mouseY] = pointer(event) const [_x, y] = data[xBisector.center(data, queryX)]
const queryX = xScale.invert(mouseX) setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
const [_x, y] = data[xBisector.center(data, queryX)]
tt.innerHTML = `<strong>${formatY(y)}</strong> ${formatX(queryX)}`
tt.style.display = 'block'
tt.style.top = mouseY - 10 + 'px'
tt.style.left = mouseX + 20 + 'px'
}
}) })
const onMouseLeave = useEvent(() => { const onMouseLeave = useEvent(() => {
const tt = tooltipRef.current setMouseState(undefined)
if (tt != null) {
tt.style.display = 'none'
}
}) })
return ( return (
<div className="relative"> <div className="relative">
<div {mouseState && (
ref={tooltipRef} <ChartTooltip {...mouseState}>
style={{ display: 'none' }} <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])}
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white" </ChartTooltip>
/> )}
<SVGChart <SVGChart
w={w} w={w}
h={h} h={h}
@ -147,46 +172,71 @@ export const MultiValueHistoryChart = (props: {
pct?: boolean pct?: boolean
}) => { }) => {
const { colors, data, xScale, yScale, labels, w, h, pct } = props const { colors, data, xScale, yScale, labels, w, h, pct } = props
const tooltipRef = useRef<HTMLDivElement>(null) const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>()
const px = useCallback( type SP = SeriesPoint<MultiPoint>
(p: SeriesPoint<MultiPoint>) => xScale(p.data[0]), const px = useCallback((p: SP) => xScale(p.data[0]), [xScale])
[xScale] const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const py0 = useCallback( const xBisector = bisector((p: MultiPoint) => p[0])
(p: SeriesPoint<MultiPoint>) => yScale(p[0]),
[yScale]
)
const py1 = useCallback(
(p: SeriesPoint<MultiPoint>) => yScale(p[1]),
[yScale]
)
const [xStart, xEnd] = xScale.domain() const { fmtX, fmtY, xAxis, yAxis, series } = useMemo(() => {
const fmtX = getFormatterForDateRange(xStart, xEnd) const [start, end] = xScale.domain()
const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) const fmtX = getFormatterForDateRange(start, end)
const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n))
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) const tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).tickFormat(fmtX)
const yAxis = axisLeft<number>(yScale)
.tickValues(tickValues)
.tickFormat(fmtY)
const xAxis = axisBottom<Date>(xScale).tickFormat(fmtX) const d3Stack = stack<MultiPoint, number>()
const yAxis = axisLeft<number>(yScale).tickValues(tickValues).tickFormat(fmtY) .keys(range(0, labels.length))
.value(([_date, probs], o) => probs[o])
.order(stackOrderReverse)
const series = d3Stack(data)
return { fmtX, fmtY, xAxis, yAxis, series }
}, [h, pct, xScale, yScale, data, labels.length])
const d3Stack = stack<MultiPoint, number>() const onMouseOver = useEvent((event: React.PointerEvent) => {
.keys(range(0, labels.length)) const [mouseX, mouseY] = pointer(event)
.value(([_date, probs], o) => probs[o]) const queryX = xScale.invert(mouseX)
const [_x, ys] = data[xBisector.center(data, queryX)]
setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] })
})
const onMouseLeave = useEvent(() => {
setMouseState(undefined)
})
return ( return (
<div className="relative"> <div className="relative">
<div {mouseState && (
ref={tooltipRef} <ChartTooltip {...mouseState}>
style={{ display: 'none' }} {fmtX(mouseState.p[0])}
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white" <Legend
/> className="text-sm"
<SVGChart w={w} h={h} xAxis={xAxis} yAxis={yAxis}> items={mouseState.p[1].map((p, i) => ({
{d3Stack(data).map((s, i) => ( color: colors[i],
label: labels[i],
value: fmtY(p),
}))}
/>
</ChartTooltip>
)}
<SVGChart
w={w}
h={h}
xAxis={xAxis}
yAxis={yAxis}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
>
{series.map((s, i) => (
<AreaPath <AreaPath
key={s.key} key={i}
data={s} data={s}
px={px} px={px}
py0={py0} py0={py0}
@ -210,52 +260,45 @@ export const SingleValueHistoryChart = (props: {
pct?: boolean pct?: boolean
}) => { }) => {
const { color, data, xScale, yScale, pct, w, h } = props const { color, data, xScale, yScale, pct, w, h } = props
const tooltipRef = useRef<HTMLDivElement>(null) const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>()
const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale])
const py0 = yScale(0) const py0 = yScale(0)
const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale])
const [start, end] = xScale.domain() const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
const formatX = getFormatterForDateRange(start, end) const [start, end] = xScale.domain()
const formatY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) const fmtX = getFormatterForDateRange(start, end)
const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n))
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) const tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).tickFormat(fmtX)
const xAxis = axisBottom<Date>(xScale).tickFormat(formatX) const yAxis = axisLeft<number>(yScale)
const yAxis = axisLeft<number>(yScale) .tickValues(tickValues)
.tickValues(tickValues) .tickFormat(fmtY)
.tickFormat(formatY) return { fmtX, fmtY, xAxis, yAxis }
}, [h, pct, xScale, yScale])
const xBisector = bisector((p: HistoryPoint) => p[0]) const xBisector = bisector((p: HistoryPoint) => p[0])
const onMouseOver = useEvent((event: React.PointerEvent) => { const onMouseOver = useEvent((event: React.PointerEvent) => {
const tt = tooltipRef.current const [mouseX, mouseY] = pointer(event)
if (tt != null) { const queryX = xScale.invert(mouseX)
const [mouseX, mouseY] = pointer(event) const [_x, y] = data[xBisector.center(data, queryX)]
const queryX = xScale.invert(mouseX) setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
const [_x, y] = data[xBisector.center(data, queryX)]
tt.innerHTML = `<strong>${formatY(y)}</strong> ${formatX(queryX)}`
tt.style.display = 'block'
tt.style.top = mouseY - 10 + 'px'
tt.style.left = mouseX + 20 + 'px'
}
}) })
const onMouseLeave = useEvent(() => { const onMouseLeave = useEvent(() => {
const tt = tooltipRef.current setMouseState(undefined)
if (tt != null) {
tt.style.display = 'none'
}
}) })
return ( return (
<div className="relative"> <div className="relative">
<div {mouseState && (
ref={tooltipRef} <ChartTooltip {...mouseState}>
style={{ display: 'none' }} <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])}
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white" </ChartTooltip>
/> )}
<SVGChart <SVGChart
w={w} w={w}
h={h} h={h}

View File

@ -1,13 +1,5 @@
import { ReactNode, SVGProps, memo, useRef, useEffect } from 'react' import { ReactNode, SVGProps, memo, useRef, useEffect } from 'react'
import { import { Axis, CurveFactory, area, curveStepAfter, line, select } from 'd3'
Axis,
AxisDomain,
CurveFactory,
area,
curveStepAfter,
line,
select,
} from 'd3'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
@ -16,11 +8,7 @@ export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
export const MARGIN_X = MARGIN.right + MARGIN.left export const MARGIN_X = MARGIN.right + MARGIN.left
export const MARGIN_Y = MARGIN.top + MARGIN.bottom export const MARGIN_Y = MARGIN.top + MARGIN.bottom
export const XAxis = <X extends AxisDomain>(props: { export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
w: number
h: number
axis: Axis<X>
}) => {
const { h, axis } = props const { h, axis } = props
const axisRef = useRef<SVGGElement>(null) const axisRef = useRef<SVGGElement>(null)
useEffect(() => { useEffect(() => {
@ -33,11 +21,7 @@ export const XAxis = <X extends AxisDomain>(props: {
return <g ref={axisRef} transform={`translate(0, ${h})`} /> return <g ref={axisRef} transform={`translate(0, ${h})`} />
} }
export const YAxis = <Y extends AxisDomain>(props: { export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
w: number
h: number
axis: Axis<Y>
}) => {
const { w, h, axis } = props const { w, h, axis } = props
const axisRef = useRef<SVGGElement>(null) const axisRef = useRef<SVGGElement>(null)
useEffect(() => { useEffect(() => {
@ -109,7 +93,7 @@ export const AreaWithTopStroke = <P,>(props: {
) )
} }
export const SVGChart = <X extends AxisDomain, Y extends AxisDomain>(props: { export const SVGChart = <X, Y>(props: {
children: ReactNode children: ReactNode
w: number w: number
h: number h: number
@ -131,8 +115,8 @@ export const SVGChart = <X extends AxisDomain, Y extends AxisDomain>(props: {
<rect <rect
x="0" x="0"
y="0" y="0"
width={w - MARGIN_X} width={innerW}
height={h - MARGIN_Y} height={innerH}
fill="none" fill="none"
pointerEvents="all" pointerEvents="all"
onPointerEnter={onMouseOver} onPointerEnter={onMouseOver}
@ -144,6 +128,22 @@ export const SVGChart = <X extends AxisDomain, Y extends AxisDomain>(props: {
) )
} }
export type TooltipPosition = { top: number; left: number }
export const ChartTooltip = (
props: TooltipPosition & { children: React.ReactNode }
) => {
const { top, left, children } = props
return (
<div
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2"
style={{ top, left }}
>
{children}
</div>
)
}
export const getDateRange = (contract: Contract) => { export const getDateRange = (contract: Contract) => {
const { createdTime, closeTime, resolutionTime } = contract const { createdTime, closeTime, resolutionTime } = contract
const now = Date.now() const now = Date.now()