From be010da9f567efcc55a9830190b8a538f0f4ac4f Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 28 Sep 2022 21:14:34 -0700 Subject: [PATCH] Refactor chart tooltip stuff, add bet avatar to tooltips (#958) * Use objects instead of tuples for chart data * Carry bet data down into charts * Refactor to invert control of chart tooltip display * Jazz up the chart tooltips with avatars * Tidying --- web/components/avatar.tsx | 5 +- web/components/charts/contract/binary.tsx | 35 ++- web/components/charts/contract/choice.tsx | 83 ++++--- web/components/charts/contract/numeric.tsx | 33 ++- .../charts/contract/pseudo-numeric.tsx | 36 ++- web/components/charts/generic-charts.tsx | 235 ++++++------------ web/components/charts/helpers.tsx | 70 +++++- 7 files changed, 288 insertions(+), 209 deletions(-) diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index abb67d46..27861909 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -8,13 +8,14 @@ export function Avatar(props: { username?: string avatarUrl?: string noLink?: boolean - size?: number | 'xs' | 'sm' + size?: number | 'xxs' | 'xs' | 'sm' className?: string }) { const { username, noLink, size, className } = props const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) - const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 + const s = + size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const sizeInPx = s * 4 const onClick = diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index aa79c354..74ac472b 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -12,13 +12,34 @@ 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' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' const getBetPoints = (bets: Bet[]) => { - return sortBy(bets, (b) => b.createdTime).map( - (b) => [new Date(b.createdTime), b.probAfter] as const + return sortBy(bets, (b) => b.createdTime).map((b) => ({ + x: new Date(b.createdTime), + y: b.probAfter, + datum: b, + })) +} + +const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps) => { + const { x, y, xScale, datum } = props + const [start, end] = xScale.domain() + return ( + + {datum && } + {formatPct(y)} + {formatDateInRange(x, start, end)} + ) } @@ -34,16 +55,16 @@ export const BinaryContractChart = (props: { const betPoints = useMemo(() => getBetPoints(bets), [bets]) const data = useMemo( () => [ - [startDate, startP] as const, + { x: startDate, y: startP }, ...betPoints, - [endDate ?? MAX_DATE, endP] as const, + { x: endDate ?? MAX_DATE, y: endP }, ], [startDate, startP, endDate, endP, betPoints] ) const rightmostDate = getRightmostVisibleDate( endDate, - last(betPoints)?.[0], + last(betPoints)?.x, new Date(Date.now()) ) const visibleRange = [startDate, rightmostDate] @@ -53,6 +74,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 && ( @@ -63,6 +85,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 56ab018e..08d20442 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -8,14 +8,23 @@ import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useIsMobile } from 'web/hooks/use-is-mobile' import { + Legend, MARGIN_X, MARGIN_Y, MAX_DATE, getDateRange, getRightmostVisibleDate, + formatPct, + formatDateInRange, } from '../helpers' -import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' +import { + MultiPoint, + MultiValueHistoryChart, + MultiValueHistoryTooltipProps, +} from '../generic-charts' import { useElementWidth } from 'web/hooks/use-element-width' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' // thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors const CATEGORY_COLORS = [ @@ -92,28 +101,13 @@ const getTrackedAnswers = ( ).slice(0, topN) } -const getStartPoint = (answers: Answer[], start: Date) => { - return [start, answers.map((_) => 0)] as const -} - -const getEndPoint = ( - answers: Answer[], - contract: FreeResponseContract | MultipleChoiceContract, - end: Date -) => { - return [ - end, - answers.map((a) => getOutcomeProbability(contract, a.id)), - ] as const -} - const getBetPoints = (answers: Answer[], bets: Bet[]) => { const sortedBets = sortBy(bets, (b) => b.createdTime) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) const sharesByOutcome = Object.fromEntries( Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) ) - const points: MultiPoint[] = [] + const points: MultiPoint[] = [] for (const bet of sortedBets) { const { outcome, shares } = bet sharesByOutcome[outcome] += shares @@ -121,10 +115,11 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => { const sharesSquared = sum( Object.values(sharesByOutcome).map((shares) => shares ** 2) ) - points.push([ - new Date(bet.createdTime), - answers.map((answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared), - ]) + points.push({ + x: new Date(bet.createdTime), + y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), + datum: bet, + }) } return points } @@ -135,7 +130,7 @@ export const ChoiceContractChart = (props: { height?: number }) => { const { contract, bets } = props - const [contractStart, contractEnd] = getDateRange(contract) + const [start, end] = getDateRange(contract) const answers = useMemo( () => getTrackedAnswers(contract, CATEGORY_COLORS.length), [contract] @@ -143,24 +138,54 @@ export const ChoiceContractChart = (props: { const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const data = useMemo( () => [ - getStartPoint(answers, contractStart), + { x: start, y: answers.map((_) => 0) }, ...betPoints, - getEndPoint(answers, contract, contractEnd ?? MAX_DATE), + { + x: end ?? MAX_DATE, + y: answers.map((a) => getOutcomeProbability(contract, a.id)), + }, ], - [answers, contract, betPoints, contractStart, contractEnd] + [answers, contract, betPoints, start, end] ) const rightmostDate = getRightmostVisibleDate( - contractEnd, - last(betPoints)?.[0], + end, + last(betPoints)?.x, new Date(Date.now()) ) - const visibleRange = [contractStart, rightmostDate] + const visibleRange = [start, rightmostDate] const isMobile = useIsMobile(800) const containerRef = useRef(null) const width = useElementWidth(containerRef) ?? 0 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, datum } = 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 ( +
+ + {datum && } + {formatDateInRange(x, start, end)} + + +
+ ) + }, + [answers] + ) + return (
{width > 0 && ( @@ -171,7 +196,7 @@ export const ChoiceContractChart = (props: { yScale={yScale} 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 d19147f6..b45a6cca 100644 --- a/web/components/charts/contract/numeric.tsx +++ b/web/components/charts/contract/numeric.tsx @@ -1,21 +1,35 @@ import { useMemo, useRef } from 'react' -import { max, range } from 'lodash' +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) => { const { totalShares, bucketCount, min, max } = contract const step = (max - min) / bucketCount const bucketProbs = getDpmOutcomeProbabilities(totalShares) - return range(bucketCount).map( - (i) => [min + step * (i + 0.5), bucketProbs[`${i}`]] as const + return range(bucketCount).map((i) => ({ + x: min + step * (i + 0.5), + y: bucketProbs[`${i}`], + })) +} + +const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => { + const { x, y } = props + return ( + + {formatPct(y, 2)} {formatLargeNumber(x)} + ) } @@ -24,16 +38,14 @@ export const NumericContractChart = (props: { height?: number }) => { const { contract } = props + const { min, max } = contract const data = useMemo(() => getNumericChartData(contract), [contract]) const isMobile = useIsMobile(800) const containerRef = useRef(null) const width = useElementWidth(containerRef) ?? 0 const height = props.height ?? (isMobile ? 150 : 250) - const maxY = max(data.map((d) => d[1])) as number - const xScale = scaleLinear( - [contract.min, contract.max], - [0, width - MARGIN_X] - ) + const maxY = Math.max(...data.map((d) => d.y)) + const xScale = scaleLinear([min, max], [0, width - MARGIN_X]) const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) return (
@@ -45,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 2b23eb4d..56359bc7 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,9 +14,15 @@ 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' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/avatar' // mqp: note that we have an idiosyncratic version of 'log scale' // contracts. the values are stored "linearly" and can include zero. @@ -29,8 +36,24 @@ const getScaleP = (min: number, max: number, isLogScale: boolean) => { } const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => { - return sortBy(bets, (b) => b.createdTime).map( - (b) => [new Date(b.createdTime), scaleP(b.probAfter)] as const + return sortBy(bets, (b) => b.createdTime).map((b) => ({ + x: new Date(b.createdTime), + y: scaleP(b.probAfter), + datum: b, + })) +} + +const PseudoNumericChartTooltip = ( + props: SingleValueHistoryTooltipProps +) => { + const { x, y, xScale, datum } = props + const [start, end] = xScale.domain() + return ( + + {datum && } + {formatLargeNumber(y)} + {formatDateInRange(x, start, end)} + ) } @@ -51,15 +74,15 @@ export const PseudoNumericContractChart = (props: { const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP]) const data = useMemo( () => [ - [startDate, startP] as const, + { x: startDate, y: startP }, ...betPoints, - [endDate ?? MAX_DATE, endP] as const, + { x: endDate ?? MAX_DATE, y: endP }, ], [betPoints, startDate, startP, endDate, endP] ) const rightmostDate = getRightmostVisibleDate( endDate, - last(betPoints)?.[0], + last(betPoints)?.x, new Date(Date.now()) ) const visibleRange = [startDate, rightmostDate] @@ -82,6 +105,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 0d262e17..d9872b0e 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -11,127 +11,59 @@ 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 = readonly [Date, number[]] // [time, [ordered outcome probs]] -export type HistoryPoint = readonly [Date, number] // [time, number or percentage] -export type DistributionPoint = readonly [number, number] // [outcome amount, prob] -export type PositionValue

= TooltipPosition & { p: P } +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 } -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) -} +type PositionValue

= TooltipPosition & { p: P } 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] } -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[] +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] = - useState>() + useState>>() const xScale = viewXScale ?? props.xScale - const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) + const px = useCallback((p: DistributionPoint) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) - const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: DistributionPoint) => p[0]) + 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) => { + const onSelect = useEvent((ev: D3BrushEvent>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -154,8 +86,8 @@ export const SingleValueDistributionChart = (props: { // so your queryX is out of bounds return } - const [_x, y] = item - setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + const p = { x: queryX, y: item.y, datum: item.datum } + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) } }) @@ -165,10 +97,10 @@ export const SingleValueDistributionChart = (props: { return (
- {mouseState && ( - - {fmtY(mouseState.p[1])} {fmtX(mouseState.p[0])} - + {mouseState && Tooltip && ( + + + )} = + DistributionPoint & { + xScale: React.ComponentProps< + typeof SingleValueDistributionChart + >['xScale'] + } + +export const MultiValueHistoryChart = (props: { + data: MultiPoint[] w: number h: number - labels: readonly string[] colors: readonly string[] xScale: ScaleTime yScale: ScaleContinuousNumeric + Tooltip?: TooltipContent> pct?: boolean }) => { - const { colors, data, yScale, labels, w, h, pct } = props + const { colors, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState>() - const [mouseState, setMouseState] = useState>() + const [mouseState, setMouseState] = useState>>() const xScale = viewXScale ?? props.xScale - type SP = SeriesPoint - const px = useCallback((p: SP) => xScale(p.data[0]), [xScale]) + type SP = SeriesPoint> + 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: MultiPoint) => p[0]) - - 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 xBisector = bisector((p: MultiPoint) => p.x) + 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(() => { - const d3Stack = stack() - .keys(range(0, labels.length)) - .value(([_date, probs], o) => probs[o]) + const d3Stack = stack, number>() + .keys(range(0, Math.max(...data.map(({ y }) => y.length)))) + .value(({ y }, o) => y[o]) .order(stackOrderReverse) return d3Stack(data) - }, [data, labels.length]) + }, [data]) - const onSelect = useEvent((ev: D3BrushEvent) => { + const onSelect = useEvent((ev: D3BrushEvent>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -260,8 +194,8 @@ export const MultiValueHistoryChart = (props: { // so your queryX is out of bounds return } - const [_x, ys] = item - setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] }) + const p = { x: queryX, y: item.y, datum: item.datum } + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) } }) @@ -269,24 +203,12 @@ export const MultiValueHistoryChart = (props: { setMouseState(undefined) }) - const mouseProbs = mouseState?.p[1] ?? [] - 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[0])} - - + {mouseState && Tooltip && ( + + + )} = 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>() + const [mouseState, setMouseState] = useState>>() const xScale = viewXScale ?? props.xScale - const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) + const px = useCallback((p: HistoryPoint) => xScale(p.x), [xScale]) const py0 = yScale(yScale.domain()[0]) - const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) - const xBisector = bisector((p: HistoryPoint) => p[0]) - - 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 py1 = useCallback((p: HistoryPoint) => yScale(p.y), [yScale]) + const xBisector = bisector((p: HistoryPoint) => p.x) + 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) => { + const onSelect = useEvent((ev: D3BrushEvent>) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => @@ -370,8 +293,8 @@ export const SingleValueHistoryChart = (props: { // so your queryX is out of bounds return } - const [_x, y] = item - setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + const p = { x: queryX, y: item.y, datum: item.datum } + setMouseState({ top: mouseY - 10, left: mouseX + 60, p }) } }) @@ -381,10 +304,10 @@ export const SingleValueHistoryChart = (props: { return (
- {mouseState && ( - - {fmtY(mouseState.p[1])} {fmtX(mouseState.p[0])} - + {mouseState && Tooltip && ( + + + )} ) } + +export type SingleValueHistoryTooltipProps = HistoryPoint & { + xScale: React.ComponentProps>['xScale'] +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 644a421c..2ed59ce2 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -4,9 +4,11 @@ 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' +import { Row } from 'web/components/layout/row' export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export const MARGIN_X = MARGIN.right + MARGIN.left @@ -180,9 +182,9 @@ export const SVGChart = (props: { ) } +export type TooltipContent

= React.ComponentType

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 +201,27 @@ export const ChartTooltip = ( ) } +export type LegendItem = { color: string; label: string; value?: string } +export const Legend = (props: { className?: string; items: LegendItem[] }) => { + const { items, className } = props + return ( +

    + {items.map((item) => ( +
  1. + + + {item.label} + + {item.value} +
  2. + ))} +
+ ) +} + export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const isClosed = !!closeTime && Date.now() > closeTime @@ -220,3 +243,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) +}