From b9376a725e57862894f4c504631059c8f62d4331 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Mon, 26 Sep 2022 21:54:31 -0700 Subject: [PATCH] Clean up a bunch of tooltip stuff, add FR legend tooltips --- web/components/charts/contract/choice.tsx | 35 ++-- web/components/charts/generic-charts.tsx | 225 +++++++++++++--------- web/components/charts/helpers.tsx | 44 ++--- 3 files changed, 174 insertions(+), 130 deletions(-) diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 0d3c457f..635b0475 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -14,27 +14,25 @@ const getMultiChartData = ( contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { - const { totalBets, outcomeType } = contract + const { answers, totalBets, outcomeType } = contract const sortedBets = sortBy(bets, (b) => b.createdTime) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) - const outcomes = Object.keys(betsByOutcome).filter((outcome) => { + const validAnswers = answers.filter((answer) => { const maxProb = Math.max( - ...betsByOutcome[outcome].map((bet) => bet.probAfter) + ...betsByOutcome[answer.id].map((bet) => bet.probAfter) ) return ( - (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && maxProb > 0.02 && - totalBets[outcome] > 0.000000001 + totalBets[answer.id] > 0.000000001 ) }) - const trackedOutcomes = sortBy( - outcomes, - (outcome) => -1 * getOutcomeProbability(contract, outcome) - ) - .slice(0, 10) - .reverse() + const trackedAnswers = sortBy( + validAnswers, + (answer) => -1 * getOutcomeProbability(contract, answer.id) + ).slice(0, 10) const points: MultiPoint[] = [] @@ -51,23 +49,26 @@ const getMultiChartData = ( ) points.push([ new Date(bet.createdTime), - trackedOutcomes.map( - (outcome) => sharesByOutcome[outcome] ** 2 / sharesSquared + trackedAnswers.map( + (answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared ), ]) } const allPoints: MultiPoint[] = [ - [new Date(contract.createdTime), trackedOutcomes.map((_) => 0)], + [new Date(contract.createdTime), trackedAnswers.map((_) => 0)], ...points, [ new Date(Date.now()), - trackedOutcomes.map((outcome) => - getOutcomeProbability(contract, outcome) + trackedAnswers.map((answer) => + getOutcomeProbability(contract, answer.id) ), ], ] - return { points: allPoints, labels: trackedOutcomes } + return { + points: allPoints, + labels: trackedAnswers.map((answer) => answer.text), + } } export const ChoiceContractChart = (props: { diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index bbcb0685..897f2a40 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback } from 'react' +import { useCallback, useMemo, useState } from 'react' import { axisBottom, axisLeft, @@ -7,6 +7,7 @@ import { curveStepAfter, pointer, stack, + stackOrderReverse, ScaleTime, ScaleContinuousNumeric, SeriesPoint, @@ -14,13 +15,21 @@ import { import { range } from 'lodash' 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 { 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 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

= TooltipPosition & { p: P } const formatPct = (n: number, digits?: number) => { 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] } +type LegendItem = { color: string; label: string; value?: string } + +const Legend = (props: { className?: string; items: LegendItem[] }) => { + const { items, className } = props + return ( +

    + {items.map((item) => ( +
  1. + + + {item.label} + + {item.value} +
  2. + ))} +
+ ) +} + export const SingleValueDistributionChart = (props: { data: DistributionPoint[] w: number @@ -75,46 +106,40 @@ export const SingleValueDistributionChart = (props: { yScale: ScaleContinuousNumeric }) => { const { color, data, xScale, yScale, w, h } = props - const tooltipRef = useRef(null) + const [mouseState, setMouseState] = + useState>() const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) const py0 = yScale(0) 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(xScale).tickFormat(formatX) - const yAxis = axisLeft(yScale).tickFormat(formatY) - 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(xScale).tickFormat(fmtX) + const yAxis = axisLeft(yScale).tickFormat(fmtY) + return { fmtX, fmtY, xAxis, yAxis } + }, [xScale, yScale]) + const onMouseOver = useEvent((event: React.PointerEvent) => { - const tt = tooltipRef.current - if (tt != null) { - const [mouseX, mouseY] = pointer(event) - const queryX = xScale.invert(mouseX) - const [_x, y] = data[xBisector.center(data, queryX)] - tt.innerHTML = `${formatY(y)} ${formatX(queryX)}` - tt.style.display = 'block' - tt.style.top = mouseY - 10 + 'px' - tt.style.left = mouseX + 20 + 'px' - } + const [mouseX, mouseY] = pointer(event) + const queryX = xScale.invert(mouseX) + const [_x, y] = data[xBisector.center(data, queryX)] + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) }) const onMouseLeave = useEvent(() => { - const tt = tooltipRef.current - if (tt != null) { - tt.style.display = 'none' - } + setMouseState(undefined) }) return (
-
+ {mouseState && ( + + {fmtY(mouseState.p[1])} {fmtX(mouseState.p[0])} + + )} { const { colors, data, xScale, yScale, labels, w, h, pct } = props - const tooltipRef = useRef(null) + const [mouseState, setMouseState] = useState>() - const px = useCallback( - (p: SeriesPoint) => xScale(p.data[0]), - [xScale] - ) - const py0 = useCallback( - (p: SeriesPoint) => yScale(p[0]), - [yScale] - ) - const py1 = useCallback( - (p: SeriesPoint) => yScale(p[1]), - [yScale] - ) + type SP = SeriesPoint + const px = useCallback((p: SP) => xScale(p.data[0]), [xScale]) + const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) + const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) + const xBisector = bisector((p: MultiPoint) => p[0]) - const [xStart, xEnd] = xScale.domain() - const fmtX = getFormatterForDateRange(xStart, xEnd) - const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + const { fmtX, fmtY, xAxis, yAxis, series } = useMemo(() => { + const [start, end] = xScale.domain() + const fmtX = getFormatterForDateRange(start, end) + const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) - const [min, max] = yScale.domain() - const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const [min, max] = yScale.domain() + const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const xAxis = axisBottom(xScale).tickFormat(fmtX) + const yAxis = axisLeft(yScale) + .tickValues(tickValues) + .tickFormat(fmtY) - const xAxis = axisBottom(xScale).tickFormat(fmtX) - const yAxis = axisLeft(yScale).tickValues(tickValues).tickFormat(fmtY) + const d3Stack = stack() + .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() - .keys(range(0, labels.length)) - .value(([_date, probs], o) => probs[o]) + const onMouseOver = useEvent((event: React.PointerEvent) => { + const [mouseX, mouseY] = pointer(event) + 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 (
-
- - {d3Stack(data).map((s, i) => ( + {mouseState && ( + + {fmtX(mouseState.p[0])} + ({ + color: colors[i], + label: labels[i], + value: fmtY(p), + }))} + /> + + )} + + {series.map((s, i) => ( { const { color, data, xScale, yScale, pct, w, h } = props - const tooltipRef = useRef(null) + const [mouseState, setMouseState] = useState>() const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) const py0 = yScale(0) const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) - const [start, end] = xScale.domain() - const formatX = getFormatterForDateRange(start, end) - const formatY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { + const [start, end] = xScale.domain() + const fmtX = getFormatterForDateRange(start, end) + const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) - const [min, max] = yScale.domain() - const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) - - const xAxis = axisBottom(xScale).tickFormat(formatX) - const yAxis = axisLeft(yScale) - .tickValues(tickValues) - .tickFormat(formatY) + const [min, max] = yScale.domain() + const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const xAxis = axisBottom(xScale).tickFormat(fmtX) + const yAxis = axisLeft(yScale) + .tickValues(tickValues) + .tickFormat(fmtY) + return { fmtX, fmtY, xAxis, yAxis } + }, [h, pct, xScale, yScale]) const xBisector = bisector((p: HistoryPoint) => p[0]) const onMouseOver = useEvent((event: React.PointerEvent) => { - const tt = tooltipRef.current - if (tt != null) { - const [mouseX, mouseY] = pointer(event) - const queryX = xScale.invert(mouseX) - const [_x, y] = data[xBisector.center(data, queryX)] - tt.innerHTML = `${formatY(y)} ${formatX(queryX)}` - tt.style.display = 'block' - tt.style.top = mouseY - 10 + 'px' - tt.style.left = mouseX + 20 + 'px' - } + const [mouseX, mouseY] = pointer(event) + const queryX = xScale.invert(mouseX) + const [_x, y] = data[xBisector.center(data, queryX)] + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) }) const onMouseLeave = useEvent(() => { - const tt = tooltipRef.current - if (tt != null) { - tt.style.display = 'none' - } + setMouseState(undefined) }) return (
-
+ {mouseState && ( + + {fmtY(mouseState.p[1])} {fmtX(mouseState.p[0])} + + )} (props: { - w: number - h: number - axis: Axis -}) => { +export const XAxis = (props: { w: number; h: number; axis: Axis }) => { const { h, axis } = props const axisRef = useRef(null) useEffect(() => { @@ -33,11 +21,7 @@ export const XAxis = (props: { return } -export const YAxis = (props: { - w: number - h: number - axis: Axis -}) => { +export const YAxis = (props: { w: number; h: number; axis: Axis }) => { const { w, h, axis } = props const axisRef = useRef(null) useEffect(() => { @@ -109,7 +93,7 @@ export const AreaWithTopStroke = (props: { ) } -export const SVGChart = (props: { +export const SVGChart = (props: { children: ReactNode w: number h: number @@ -131,8 +115,8 @@ export const SVGChart = (props: { (props: { ) } +export type TooltipPosition = { top: number; left: number } + +export const ChartTooltip = ( + props: TooltipPosition & { children: React.ReactNode } +) => { + const { top, left, children } = props + return ( +
+ {children} +
+ ) +} + export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const now = Date.now()