From 9fe3c24baecef693c1528c031a15aaecfa5b0368 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 28 Sep 2022 20:35:15 -0700 Subject: [PATCH] Refactor to invert control of chart tooltip display --- web/components/charts/contract/binary.tsx | 19 ++- web/components/charts/contract/choice.tsx | 34 +++- web/components/charts/contract/numeric.tsx | 18 +- .../charts/contract/pseudo-numeric.tsx | 18 +- web/components/charts/generic-charts.tsx | 155 ++++++------------ web/components/charts/helpers.tsx | 48 +++++- 6 files changed, 184 insertions(+), 108 deletions(-) diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index 6703ed82..828fe198 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -12,8 +12,13 @@ import { MAX_DATE, getDateRange, getRightmostVisibleDate, + formatDateInRange, + formatPct, } from '../helpers' -import { SingleValueHistoryChart } from '../generic-charts' +import { + SingleValueHistoryTooltipProps, + SingleValueHistoryChart, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' const getBetPoints = (bets: Bet[]) => { @@ -24,6 +29,16 @@ const getBetPoints = (bets: Bet[]) => { })) } +const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps) => { + const { x, y, xScale } = props + const [start, end] = xScale.domain() + return ( + + {formatPct(y)} {formatDateInRange(x, start, end)} + + ) +} + export const BinaryContractChart = (props: { contract: BinaryContract bets: Bet[] @@ -55,6 +70,7 @@ export const BinaryContractChart = (props: { const height = props.height ?? (isMobile ? 250 : 350) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + return (
{width > 0 && ( @@ -65,6 +81,7 @@ export const BinaryContractChart = (props: { yScale={yScale} data={data} color="#11b981" + Tooltip={BinaryChartTooltip} pct /> )} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index ac39992c..25e5e9ec 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -13,8 +13,15 @@ import { MAX_DATE, getDateRange, getRightmostVisibleDate, + formatPct, + formatDateInRange, } from '../helpers' -import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' +import { + Legend, + MultiPoint, + MultiValueHistoryChart, + MultiValueHistoryTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' // thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors @@ -150,6 +157,30 @@ export const ChoiceContractChart = (props: { const height = props.height ?? (isMobile ? 150 : 250) const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true) const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + + const ChoiceTooltip = useMemo( + () => (props: MultiValueHistoryTooltipProps) => { + const { x, y, xScale } = props + const [start, end] = xScale.domain() + const legendItems = sortBy( + y.map((p, i) => ({ + color: CATEGORY_COLORS[i], + label: answers[i].text, + value: formatPct(p), + p, + })), + (item) => -item.p + ).slice(0, 10) + return ( +
+

{formatDateInRange(x, start, end)}

+ +
+ ) + }, + [answers] + ) + return (
{width > 0 && ( @@ -161,6 +192,7 @@ export const ChoiceContractChart = (props: { data={data} colors={CATEGORY_COLORS} labels={answers.map((answer) => answer.text)} + Tooltip={ChoiceTooltip} pct /> )} diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx index bcd15afa..b45a6cca 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -2,12 +2,16 @@ import { useMemo, useRef } from 'react' import { range } from 'lodash' import { scaleLinear } from 'd3-scale' +import { formatLargeNumber } from 'common/util/format' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { NumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' -import { MARGIN_X, MARGIN_Y } from '../helpers' -import { SingleValueDistributionChart } from '../generic-charts' +import { MARGIN_X, MARGIN_Y, formatPct } from '../helpers' +import { + SingleValueDistributionChart, + SingleValueDistributionTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' const getNumericChartData = (contract: NumericContract) => { @@ -20,6 +24,15 @@ const getNumericChartData = (contract: NumericContract) => { })) } +const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { + const { x, y } = props + return ( + + {formatPct(y, 2)} {formatLargeNumber(x)} + + ) +} + export const NumericContractChart = (props: { contract: NumericContract height?: number @@ -44,6 +57,7 @@ export const NumericContractChart = (props: { yScale={yScale} data={data} color={NUMERIC_GRAPH_COLOR} + Tooltip={NumericChartTooltip} /> )}
diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index 4bb5d469..c13c7e5e 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -4,6 +4,7 @@ import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' import { Bet } from 'common/bet' import { getInitialProbability, getProbability } from 'common/calculate' +import { formatLargeNumber } from 'common/util/format' import { PseudoNumericContract } from 'common/contract' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { useIsMobile } from 'web/hooks/use-is-mobile' @@ -13,8 +14,12 @@ import { MAX_DATE, getDateRange, getRightmostVisibleDate, + formatDateInRange, } from '../helpers' -import { SingleValueHistoryChart } from '../generic-charts' +import { + SingleValueHistoryChart, + SingleValueHistoryTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' // mqp: note that we have an idiosyncratic version of 'log scale' @@ -36,6 +41,16 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { })) } +const PseudoNumericChartTooltip = (props: SingleValueHistoryTooltipProps) => { + const { x, y, xScale } = props + const [start, end] = xScale.domain() + return ( + + {formatLargeNumber(y)} {formatDateInRange(x, start, end)} + + ) +} + export const PseudoNumericContractChart = (props: { contract: PseudoNumericContract bets: Bet[] @@ -84,6 +99,7 @@ export const PseudoNumericContractChart = (props: { xScale={xScale} yScale={yScale} data={data} + Tooltip={PseudoNumericChartTooltip} color={NUMERIC_GRAPH_COLOR} /> )} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index dae59a0d..a4834f4e 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -11,69 +11,26 @@ import { stackOrderReverse, SeriesPoint, } from 'd3-shape' -import { range, sortBy } from 'lodash' -import dayjs from 'dayjs' +import { range } from 'lodash' import { SVGChart, AreaPath, AreaWithTopStroke, - ChartTooltip, + TooltipContent, + TooltipContainer, TooltipPosition, + formatPct, } 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 = { x: Date; y: number[]; datum?: T } -export type HistoryPoint = { x: Date; y: number; datum?: T } -export type DistributionPoint = { x: number; y: number; datum?: T } +export type MultiPoint = { x: Date; y: number[]; datum?: T } +export type HistoryPoint = { x: Date; y: number; datum?: T } +export type DistributionPoint = { x: number; y: number; datum?: T } type PositionValue

= TooltipPosition & { p: P } -const formatPct = (n: number, digits?: number) => { - return `${(n * 100).toFixed(digits ?? 0)}%` -} - -const formatDate = ( - date: Date, - opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean } -) => { - const { includeYear, includeHour, includeMinute } = opts - const d = dayjs(date) - const now = Date.now() - if ( - d.add(1, 'minute').isAfter(now) && - d.subtract(1, 'minute').isBefore(now) - ) { - return 'Now' - } else { - const dayName = d.isSame(now, 'day') - ? 'Today' - : d.add(1, 'day').isSame(now, 'day') - ? 'Yesterday' - : null - let format = dayName ? `[${dayName}]` : 'MMM D' - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - return d.format(format) - } -} - -const getFormatterForDateRange = (start: Date, end: Date) => { - const opts = { - includeYear: !dayjs(start).isSame(end, 'year'), - includeHour: dayjs(start).add(8, 'day').isAfter(end), - includeMinute: dayjs(end).diff(start, 'hours') < 2, - } - return (d: Date) => formatDate(d, opts) -} - const getTickValues = (min: number, max: number, n: number) => { const step = (max - min) / (n - 1) return [min, ...range(1, n - 1).map((i) => min + step * i), max] @@ -81,7 +38,7 @@ const getTickValues = (min: number, max: number, n: number) => { type LegendItem = { color: string; label: string; value?: string } -const Legend = (props: { className?: string; items: LegendItem[] }) => { +export const Legend = (props: { className?: string; items: LegendItem[] }) => { const { items, className } = props return (

    @@ -101,18 +58,17 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => { ) } -export const SingleValueDistributionChart = (props: { +export const SingleValueDistributionChart = (props: { data: DistributionPoint[] w: number h: number color: string xScale: ScaleContinuousNumeric yScale: ScaleContinuousNumeric + Tooltip?: TooltipContent> }) => { - const { color, data, yScale, w, h } = props + const { color, data, yScale, w, h, Tooltip } = props - // note that we have to type this funkily in order to succesfully store - // a function inside of useState const [viewXScale, setViewXScale] = useState>() const [mouseState, setMouseState] = @@ -124,12 +80,10 @@ export const SingleValueDistributionChart = (props: { const py1 = useCallback((p: DistributionPoint) => yScale(p.y), [yScale]) const xBisector = bisector((p: DistributionPoint) => p.x) - const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { - const fmtX = (n: number) => formatLargeNumber(n) - const fmtY = (n: number) => formatPct(n, 2) + const { xAxis, yAxis } = useMemo(() => { const xAxis = axisBottom(xScale).ticks(w / 100) - const yAxis = axisLeft(yScale).tickFormat(fmtY) - return { fmtX, fmtY, xAxis, yAxis } + const yAxis = axisLeft(yScale).tickFormat((n) => formatPct(n, 2)) + return { xAxis, yAxis } }, [w, xScale, yScale]) const onSelect = useEvent((ev: D3BrushEvent>) => { @@ -166,10 +120,10 @@ export const SingleValueDistributionChart = (props: { return (
    - {mouseState && ( - - {fmtY(mouseState.p.y)} {fmtX(mouseState.p.x)} - + {mouseState && Tooltip && ( + + + )} (props: { ) } -export const MultiValueHistoryChart = (props: { +export type SingleValueDistributionTooltipProps = + DistributionPoint & { + xScale: React.ComponentProps< + typeof SingleValueDistributionChart + >['xScale'] + } + +export const MultiValueHistoryChart = (props: { data: MultiPoint[] w: number h: number @@ -201,9 +162,10 @@ export const MultiValueHistoryChart = (props: { colors: readonly string[] xScale: ScaleTime yScale: ScaleContinuousNumeric + Tooltip?: TooltipContent> pct?: boolean }) => { - const { colors, data, yScale, labels, w, h, pct } = props + const { colors, data, yScale, labels, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState>() const [mouseState, setMouseState] = useState>>() @@ -215,19 +177,15 @@ export const MultiValueHistoryChart = (props: { const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) const xBisector = bisector((p: MultiPoint) => p.x) - 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 { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = pct - ? axisLeft(yScale).tickValues(pctTickValues).tickFormat(fmtY) + ? axisLeft(yScale).tickValues(pctTickValues).tickFormat(formatPct) : axisLeft(yScale) - return { fmtX, fmtY, xAxis, yAxis } + return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const series = useMemo(() => { @@ -270,24 +228,12 @@ export const MultiValueHistoryChart = (props: { setMouseState(undefined) }) - const mouseProbs = mouseState?.p.y ?? [] - const legendItems = sortBy( - mouseProbs.map((p, i) => ({ - color: colors[i], - label: labels[i], - value: fmtY(p), - p, - })), - (item) => -item.p - ).slice(0, 10) - return (
    - {mouseState && ( - - {fmtX(mouseState.p.x)} - - + {mouseState && Tooltip && ( + + + )} (props: { ) } -export const SingleValueHistoryChart = (props: { +export type MultiValueHistoryTooltipProps = MultiPoint & { + xScale: React.ComponentProps>['xScale'] +} + +export const SingleValueHistoryChart = (props: { data: HistoryPoint[] w: number h: number color: string xScale: ScaleTime yScale: ScaleContinuousNumeric + Tooltip?: TooltipContent> pct?: boolean }) => { - const { color, data, pct, yScale, w, h } = props + const { color, data, pct, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState>() const [mouseState, setMouseState] = useState>>() @@ -334,18 +285,14 @@ export const SingleValueHistoryChart = (props: { const py1 = useCallback((p: HistoryPoint) => yScale(p.y), [yScale]) const xBisector = bisector((p: HistoryPoint) => p.x) - 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 { xAxis, yAxis } = useMemo(() => { const [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = pct - ? axisLeft(yScale).tickValues(pctTickValues).tickFormat(fmtY) + ? axisLeft(yScale).tickValues(pctTickValues).tickFormat(formatPct) : axisLeft(yScale) - return { fmtX, fmtY, xAxis, yAxis } + return { xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const onSelect = useEvent((ev: D3BrushEvent>) => { @@ -382,10 +329,10 @@ export const SingleValueHistoryChart = (props: { return (
    - {mouseState && ( - - {fmtY(mouseState.p.y)} {fmtX(mouseState.p.x)}{' '} - + {mouseState && Tooltip && ( + + + )} (props: {
    ) } + +export type SingleValueHistoryTooltipProps = HistoryPoint & { + xScale: React.ComponentProps>['xScale'] +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 644a421c..017529af 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -4,6 +4,7 @@ import { Axis } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' +import dayjs from 'dayjs' import clsx from 'clsx' import { Contract } from 'common/contract' @@ -182,7 +183,7 @@ export const SVGChart = (props: { export type TooltipPosition = { top: number; left: number } -export const ChartTooltip = ( +export const TooltipContainer = ( props: TooltipPosition & { className?: string; children: React.ReactNode } ) => { const { top, left, className, children } = props @@ -199,6 +200,8 @@ export const ChartTooltip = ( ) } +export type TooltipContent

    = React.ComponentType

    + export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const isClosed = !!closeTime && Date.now() > closeTime @@ -220,3 +223,46 @@ export const getRightmostVisibleDate = ( return now } } + +export const formatPct = (n: number, digits?: number) => { + return `${(n * 100).toFixed(digits ?? 0)}%` +} + +export const formatDate = ( + date: Date, + opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean } +) => { + const { includeYear, includeHour, includeMinute } = opts + const d = dayjs(date) + const now = Date.now() + if ( + d.add(1, 'minute').isAfter(now) && + d.subtract(1, 'minute').isBefore(now) + ) { + return 'Now' + } else { + const dayName = d.isSame(now, 'day') + ? 'Today' + : d.add(1, 'day').isSame(now, 'day') + ? 'Yesterday' + : null + let format = dayName ? `[${dayName}]` : 'MMM D' + if (includeMinute) { + format += ', h:mma' + } else if (includeHour) { + format += ', ha' + } else if (includeYear) { + format += ', YYYY' + } + return d.format(format) + } +} + +export const formatDateInRange = (d: Date, start: Date, end: Date) => { + const opts = { + includeYear: !dayjs(start).isSame(end, 'year'), + includeHour: dayjs(start).add(8, 'day').isAfter(end), + includeMinute: dayjs(end).diff(start, 'hours') < 2, + } + return formatDate(d, opts) +}