From 575740963c9df592a20e1581fa763809e7b9833c Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Mon, 26 Sep 2022 15:53:18 -0700 Subject: [PATCH] Reorganize everything into neat little files --- web/components/charts/contract/binary.tsx | 52 ++ web/components/charts/contract/choice.tsx | 105 +++ web/components/charts/contract/index.tsx | 34 + web/components/charts/contract/numeric.tsx | 53 ++ .../charts/contract/pseudo-numeric.tsx | 57 ++ web/components/charts/generic-charts.tsx | 268 +++++++ web/components/charts/helpers.tsx | 124 ++++ web/components/contract/contract-overview.tsx | 2 +- .../contract/contract-prob-graph.tsx | 654 ------------------ web/hooks/use-element-width.tsx | 17 + web/pages/embed/[username]/[contractSlug].tsx | 2 +- 11 files changed, 712 insertions(+), 656 deletions(-) create mode 100644 web/components/charts/contract/binary.tsx create mode 100644 web/components/charts/contract/choice.tsx create mode 100644 web/components/charts/contract/index.tsx create mode 100644 web/components/charts/contract/numeric.tsx create mode 100644 web/components/charts/contract/pseudo-numeric.tsx create mode 100644 web/components/charts/generic-charts.tsx create mode 100644 web/components/charts/helpers.tsx delete mode 100644 web/components/contract/contract-prob-graph.tsx create mode 100644 web/hooks/use-element-width.tsx diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx new file mode 100644 index 00000000..d9a94788 --- /dev/null +++ b/web/components/charts/contract/binary.tsx @@ -0,0 +1,52 @@ +import { useMemo, useRef } from 'react' +import { sortBy } from 'lodash' +import { scaleTime, scaleLinear } from 'd3' + +import { Bet } from 'common/bet' +import { getInitialProbability, getProbability } from 'common/calculate' +import { BinaryContract } from 'common/contract' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y, getDateRange } from '../helpers' +import { SingleValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +const getChartData = (contract: BinaryContract, bets: Bet[]) => { + const sortedBets = sortBy(bets, (b) => b.createdTime) + const startProb = getInitialProbability(contract) + const endProb = getProbability(contract) + return [ + [new Date(contract.createdTime), startProb] as const, + ...sortedBets.map((b) => [new Date(b.createdTime), b.probAfter] as const), + [new Date(Date.now()), endProb] as const, + ] +} + +export const BinaryContractChart = (props: { + contract: BinaryContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const data = useMemo(() => getChartData(contract, bets), [contract, bets]) + const isMobile = useIsMobile(800) + const containerRef = useRef(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? isMobile ? 150 : 250 + const xScale = scaleTime(getDateRange(contract), [0, width - MARGIN_X]) + const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + return ( +
+ {width && ( + + )} +
+ ) +} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx new file mode 100644 index 00000000..0d3c457f --- /dev/null +++ b/web/components/charts/contract/choice.tsx @@ -0,0 +1,105 @@ +import { useMemo, useRef } from 'react' +import { sum, sortBy, groupBy } from 'lodash' +import { scaleTime, scaleLinear, schemeCategory10 } from 'd3' + +import { Bet } from 'common/bet' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { getOutcomeProbability } from 'common/calculate' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y, getDateRange } from '../helpers' +import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +const getMultiChartData = ( + contract: FreeResponseContract | MultipleChoiceContract, + bets: Bet[] +) => { + const { 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 maxProb = Math.max( + ...betsByOutcome[outcome].map((bet) => bet.probAfter) + ) + return ( + (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + maxProb > 0.02 && + totalBets[outcome] > 0.000000001 + ) + }) + + const trackedOutcomes = sortBy( + outcomes, + (outcome) => -1 * getOutcomeProbability(contract, outcome) + ) + .slice(0, 10) + .reverse() + + const points: MultiPoint[] = [] + + const sharesByOutcome = Object.fromEntries( + Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) + ) + + for (const bet of sortedBets) { + const { outcome, shares } = bet + sharesByOutcome[outcome] += shares + + const sharesSquared = sum( + Object.values(sharesByOutcome).map((shares) => shares ** 2) + ) + points.push([ + new Date(bet.createdTime), + trackedOutcomes.map( + (outcome) => sharesByOutcome[outcome] ** 2 / sharesSquared + ), + ]) + } + + const allPoints: MultiPoint[] = [ + [new Date(contract.createdTime), trackedOutcomes.map((_) => 0)], + ...points, + [ + new Date(Date.now()), + trackedOutcomes.map((outcome) => + getOutcomeProbability(contract, outcome) + ), + ], + ] + return { points: allPoints, labels: trackedOutcomes } +} + +export const ChoiceContractChart = (props: { + contract: FreeResponseContract | MultipleChoiceContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const data = useMemo( + () => getMultiChartData(contract, bets), + [contract, bets] + ) + const isMobile = useIsMobile(800) + const containerRef = useRef(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? isMobile ? 150 : 250 + const xScale = scaleTime(getDateRange(contract), [0, width - MARGIN_X]) + const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0]) + return ( +
+ {width && ( + + )} +
+ ) +} diff --git a/web/components/charts/contract/index.tsx b/web/components/charts/contract/index.tsx new file mode 100644 index 00000000..1f580bae --- /dev/null +++ b/web/components/charts/contract/index.tsx @@ -0,0 +1,34 @@ +import { Contract } from 'common/contract' +import { Bet } from 'common/bet' +import { BinaryContractChart } from './binary' +import { PseudoNumericContractChart } from './pseudo-numeric' +import { ChoiceContractChart } from './choice' +import { NumericContractChart } from './numeric' + +export const ContractChart = (props: { + contract: Contract + bets: Bet[] + height?: number +}) => { + const { contract } = props + switch (contract.outcomeType) { + case 'BINARY': + return + case 'PSEUDO_NUMERIC': + return + case 'FREE_RESPONSE': + case 'MULTIPLE_CHOICE': + return + case 'NUMERIC': + return + default: + return null + } +} + +export { + BinaryContractChart, + PseudoNumericContractChart, + ChoiceContractChart, + NumericContractChart, +} diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx new file mode 100644 index 00000000..7ba7e3e9 --- /dev/null +++ b/web/components/charts/contract/numeric.tsx @@ -0,0 +1,53 @@ +import { useMemo, useRef } from 'react' +import { max, range } from 'lodash' +import { scaleLinear } from 'd3' + +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 { useElementWidth } from 'web/hooks/use-element-width' + +const getNumericChartData = (contract: NumericContract) => { + const { totalShares, bucketCount, min, max } = contract + const bucketProbs = getDpmOutcomeProbabilities(totalShares) + const xs = range(bucketCount).map( + (i) => min + ((max - min) * i) / bucketCount + ) + const probs = range(bucketCount).map((i) => bucketProbs[`${i}`]) + return probs.map((prob, i) => [xs[i], prob] as const) +} + +export const NumericContractChart = (props: { + contract: NumericContract + height?: number +}) => { + const { contract } = props + 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 yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0]) + return ( +
+ {width && ( + + )} +
+ ) +} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx new file mode 100644 index 00000000..a5366c82 --- /dev/null +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -0,0 +1,57 @@ +import { useMemo, useRef } from 'react' +import { sortBy } from 'lodash' +import { scaleTime, scaleLog, scaleLinear } from 'd3' + +import { Bet } from 'common/bet' +import { getInitialProbability, getProbability } from 'common/calculate' +import { PseudoNumericContract } from 'common/contract' +import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { MARGIN_X, MARGIN_Y, getDateRange } from '../helpers' +import { SingleValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +const getChartData = (contract: PseudoNumericContract, bets: Bet[]) => { + const { min, max } = contract + const getY = (p: number) => p * (max - min) + min + const sortedBets = sortBy(bets, (b) => b.createdTime) + const startProb = getInitialProbability(contract) + const endProb = getProbability(contract) + return [ + [new Date(contract.createdTime), getY(startProb)] as const, + ...sortedBets.map( + (b) => [new Date(b.createdTime), getY(b.probAfter)] as const + ), + [new Date(Date.now()), getY(endProb)] as const, + ] +} + +export const PseudoNumericContractChart = (props: { + contract: PseudoNumericContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const data = useMemo(() => getChartData(contract, bets), [contract, bets]) + const isMobile = useIsMobile(800) + const containerRef = useRef(null) + const width = useElementWidth(containerRef) ?? 0 + const height = props.height ?? isMobile ? 150 : 250 + const scaleType = contract.isLogScale ? scaleLog : scaleLinear + const xScale = scaleTime(getDateRange(contract), [0, width - MARGIN_X]) + const yScale = scaleType([contract.min, contract.max], [height - MARGIN_Y, 0]) + return ( +
+ {width && ( + + )} +
+ ) +} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx new file mode 100644 index 00000000..a34803b4 --- /dev/null +++ b/web/components/charts/generic-charts.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useCallback } from 'react' +import { + axisBottom, + axisLeft, + bisectCenter, + curveLinear, + curveStepAfter, + format, + pointer, + stack, + ScaleTime, + ScaleContinuousNumeric, + SeriesPoint, +} from 'd3' +import { range } from 'lodash' +import dayjs from 'dayjs' + +import { SVGChart, LinePath, AreaPath } from './helpers' +import { formatLargeNumber } from 'common/util/format' +import { useEvent } from 'web/hooks/use-event' + +export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]] +export type HistoryPoint = readonly [Date, number] // [time, number or percentage] +export type NumericPoint = readonly [number, number] // [number, prob] + +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' + if (d.isSame(now, 'day')) { + return '[Today]' + } else if (d.add(1, 'day').isSame(now, 'day')) { + return '[Yesterday]' + } else { + let format = '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] +} + +export const SingleValueDistributionChart = (props: { + data: NumericPoint[] + w: number + h: number + color: string + xScale: ScaleContinuousNumeric + yScale: ScaleContinuousNumeric +}) => { + const { color, data, xScale, yScale, w, h } = props + const tooltipRef = useRef(null) + const px = useCallback((p: NumericPoint) => xScale(p[0]), [xScale]) + const py0 = yScale(0) + const py1 = useCallback((p: NumericPoint) => yScale(p[1]), [yScale]) + + const formatX = (n: number) => formatLargeNumber(n) + const formatY = (n: number) => format(',.2%')(n) + const xAxis = axisBottom(xScale).tickFormat(formatX) + const yAxis = axisLeft(yScale).tickFormat(formatY) + + return ( +
+
+ + + + +
+ ) +} + +export const MultiValueHistoryChart = (props: { + data: MultiPoint[] + w: number + h: number + labels: readonly string[] + colors: readonly string[] + xScale: ScaleTime + yScale: ScaleContinuousNumeric + pct?: boolean +}) => { + const { colors, data, xScale, yScale, labels, w, h, pct } = props + const tooltipRef = useRef(null) + 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] + ) + const d3Stack = stack() + .keys(range(0, labels.length)) + .value(([_date, probs], o) => probs[o]) + + const [xStart, xEnd] = xScale.domain() + const fmtX = getFormatterForDateRange(xStart, xEnd) + const fmtY = (n: number) => (pct ? format('.0%')(n) : formatLargeNumber(n)) + + 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 ( +
+
+ + {d3Stack(data).map((s, i) => ( + + + + + ))} + +
+ ) +} + +export const SingleValueHistoryChart = (props: { + data: HistoryPoint[] + w: number + h: number + color: string + xScale: d3.ScaleTime + yScale: d3.ScaleContinuousNumeric + pct?: boolean +}) => { + const { color, data, xScale, yScale, pct, w, h } = props + const tooltipRef = useRef(null) + const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) + const py0 = yScale(0) + const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) + + const dates = useMemo(() => data.map(([d]) => d), [data]) + const [startDate, endDate] = xScale.domain().map(dayjs) + const includeYear = !startDate.isSame(endDate, 'year') + const includeHour = startDate.add(8, 'day').isAfter(endDate) + const includeMinute = endDate.diff(startDate, 'hours') < 2 + const formatX = (d: Date) => + formatDate(d, { includeYear, includeHour, includeMinute }) + const formatY = (n: number) => (pct ? format('.0%')(n) : 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 onMouseOver = useEvent((event: React.PointerEvent) => { + const tt = tooltipRef.current + if (tt != null) { + const [mouseX, mouseY] = pointer(event) + const date = xScale.invert(mouseX) + const [_, prob] = data[bisectCenter(dates, date)] + tt.innerHTML = `${formatY(prob)} ${formatX(date)}` + tt.style.display = 'block' + tt.style.top = mouseY - 10 + 'px' + tt.style.left = mouseX + 20 + 'px' + } + }) + + const onMouseLeave = useEvent(() => { + const tt = tooltipRef.current + if (tt != null) { + tt.style.display = 'none' + } + }) + + return ( +
+
+ + + + +
+ ) +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx new file mode 100644 index 00000000..3054b146 --- /dev/null +++ b/web/components/charts/helpers.tsx @@ -0,0 +1,124 @@ +import { ReactNode, SVGProps, memo, useRef, useEffect } from 'react' +import { Axis, AxisDomain, CurveFactory, area, line, select } from 'd3' +import dayjs from 'dayjs' + +import { Contract } from 'common/contract' + +export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } +export const MARGIN_X = MARGIN.right + MARGIN.left +export const MARGIN_Y = MARGIN.top + MARGIN.bottom + +export const XAxis = (props: { + w: number + h: number + axis: Axis +}) => { + const { h, axis } = props + const axisRef = useRef(null) + useEffect(() => { + if (axisRef.current != null) { + select(axisRef.current) + .call(axis) + .call((g) => g.select('.domain').remove()) + } + }, [h, axis]) + return +} + +export const YAxis = (props: { + w: number + h: number + axis: Axis +}) => { + const { w, h, axis } = props + const axisRef = useRef(null) + useEffect(() => { + if (axisRef.current != null) { + select(axisRef.current) + .call(axis) + .call((g) => g.select('.domain').remove()) + .call((g) => + g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) + ) + } + }, [w, h, axis]) + return +} + +const LinePathInternal = ( + props: { + data: P[] + px: number | ((p: P) => number) + py: number | ((p: P) => number) + curve: CurveFactory + } & SVGProps +) => { + const { data, px, py, curve, ...rest } = props + const d3Line = line

(px, py).curve(curve) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return +} +export const LinePath = memo(LinePathInternal) as typeof LinePathInternal + +const AreaPathInternal = ( + props: { + data: P[] + px: number | ((p: P) => number) + py0: number | ((p: P) => number) + py1: number | ((p: P) => number) + curve: CurveFactory + } & SVGProps +) => { + const { data, px, py0, py1, curve, ...rest } = props + const d3Area = area

(px, py0, py1).curve(curve) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return +} +export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal + +export const SVGChart = (props: { + children: ReactNode + w: number + h: number + xAxis: Axis + yAxis: Axis + onMouseOver?: (ev: React.PointerEvent) => void + onMouseLeave?: (ev: React.PointerEvent) => void + pct?: boolean +}) => { + const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave } = props + const innerW = w - MARGIN_X + const innerH = h - MARGIN_Y + return ( + + + + + {children} + + + + ) +} + +export const getDateRange = (contract: Contract) => { + const { createdTime, closeTime, resolutionTime } = contract + const now = Date.now() + const isClosed = !!closeTime && now > closeTime + const endDate = resolutionTime ?? (isClosed ? closeTime : now) + // the graph should be minimum an hour wide + const adjustedEndDate = dayjs(createdTime).add(1, 'hour').isAfter(endDate) + ? dayjs(endDate).add(1, 'hours') + : dayjs(endDate) + return [new Date(createdTime), adjustedEndDate.toDate()] as const +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 905a820f..add9ba48 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -7,7 +7,7 @@ import { NumericContractChart, PseudoNumericContractChart, ChoiceContractChart, -} from './contract-prob-graph' +} from 'web/components/charts/contract' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' import { Linkify } from '../linkify' diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx deleted file mode 100644 index c4b7429f..00000000 --- a/web/components/contract/contract-prob-graph.tsx +++ /dev/null @@ -1,654 +0,0 @@ -import dayjs from 'dayjs' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Bet } from 'common/bet' -import { - getInitialProbability, - getOutcomeProbability, - getProbability, -} from 'common/calculate' -import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' -import { - Contract, - BinaryContract, - PseudoNumericContract, - NumericContract, - FreeResponseContract, - MultipleChoiceContract, -} from 'common/contract' -import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' -import { useIsMobile } from 'web/hooks/use-is-mobile' -import { formatLargeNumber } from 'common/util/format' -import { max, range, sortBy, groupBy, sum } from 'lodash' -import { useEvent } from 'web/hooks/use-event' - -import * as d3 from 'd3' - -const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } -const MARGIN_X = MARGIN.right + MARGIN.left -const MARGIN_Y = MARGIN.top + MARGIN.bottom - -type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]] -type HistoryPoint = readonly [Date, number] // [time, number or percentage] -type NumericPoint = readonly [number, number] // [number, prob] - -const useElementWidth = (ref: React.RefObject) => { - const [width, setWidth] = useState() - useEffect(() => { - const handleResize = () => { - setWidth(ref.current?.clientWidth) - } - handleResize() - window.addEventListener('resize', handleResize) - return () => { - window.removeEventListener('resize', handleResize) - } - }, [ref]) - return width -} - -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' - if (d.isSame(now, 'day')) { - return '[Today]' - } else if (d.add(1, 'day').isSame(now, 'day')) { - return '[Yesterday]' - } else { - let format = 'MMM D' - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - return d.format(format) - } -} - -const getDateRange = (contract: Contract) => { - const { createdTime, closeTime, resolutionTime } = contract - const now = Date.now() - const isClosed = !!closeTime && now > closeTime - const endDate = resolutionTime ?? (isClosed ? closeTime : now) - // the graph should be minimum an hour wide - const adjustedEndDate = dayjs(createdTime).add(1, 'hour').isAfter(endDate) - ? dayjs(endDate).add(1, 'hours') - : dayjs(endDate) - return [new Date(createdTime), adjustedEndDate.toDate()] as const -} - -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] -} - -const getNumericChartData = (contract: NumericContract) => { - const { totalShares, bucketCount, min, max } = contract - const bucketProbs = getDpmOutcomeProbabilities(totalShares) - - const xs = range(bucketCount).map( - (i) => min + ((max - min) * i) / bucketCount - ) - const probs = range(bucketCount).map((i) => bucketProbs[`${i}`]) - return probs.map((prob, i) => [xs[i], prob] as const) -} - -const getMultiChartData = ( - contract: FreeResponseContract | MultipleChoiceContract, - bets: Bet[] -) => { - const { 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 maxProb = Math.max( - ...betsByOutcome[outcome].map((bet) => bet.probAfter) - ) - return ( - (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - maxProb > 0.02 && - totalBets[outcome] > 0.000000001 - ) - }) - - const trackedOutcomes = sortBy( - outcomes, - (outcome) => -1 * getOutcomeProbability(contract, outcome) - ) - .slice(0, 10) - .reverse() - - const points: MultiPoint[] = [] - - const sharesByOutcome = Object.fromEntries( - Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) - ) - - for (const bet of sortedBets) { - const { outcome, shares } = bet - sharesByOutcome[outcome] += shares - - const sharesSquared = sum( - Object.values(sharesByOutcome).map((shares) => shares ** 2) - ) - points.push([ - new Date(bet.createdTime), - trackedOutcomes.map( - (outcome) => sharesByOutcome[outcome] ** 2 / sharesSquared - ), - ]) - } - - const allPoints: MultiPoint[] = [ - [new Date(contract.createdTime), trackedOutcomes.map((_) => 0)], - ...points, - [ - new Date(Date.now()), - trackedOutcomes.map((outcome) => - getOutcomeProbability(contract, outcome) - ), - ], - ] - return { points: allPoints, labels: trackedOutcomes } -} - -const getChartData = ( - contract: BinaryContract | PseudoNumericContract, - bets: Bet[] -): HistoryPoint[] => { - const getY = (p: number) => { - if (contract.outcomeType === 'PSEUDO_NUMERIC') { - const { min, max } = contract - return p * (max - min) + min - } else { - return p - } - } - const sortedBets = sortBy(bets, (b) => b.createdTime) - const startProb = getInitialProbability(contract) - const endProb = getProbability(contract) - return [ - [new Date(contract.createdTime), getY(startProb)] as const, - ...sortedBets.map( - (b) => [new Date(b.createdTime), getY(b.probAfter)] as const - ), - [new Date(Date.now()), getY(endProb)] as const, - ] -} - -const XAxis = (props: { - w: number - h: number - axis: d3.Axis -}) => { - const { h, axis } = props - const axisRef = useRef(null) - useEffect(() => { - if (axisRef.current != null) { - d3.select(axisRef.current) - .call(axis) - .call((g) => g.select('.domain').remove()) - } - }, [h, axis]) - return -} - -const YAxis = (props: { - w: number - h: number - axis: d3.Axis -}) => { - const { w, h, axis } = props - const axisRef = useRef(null) - useEffect(() => { - if (axisRef.current != null) { - d3.select(axisRef.current) - .call(axis) - .call((g) => g.select('.domain').remove()) - .call((g) => - g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) - ) - } - }, [w, h, axis]) - return -} - -const LinePathInternal = ( - props: { - data: P[] - px: number | ((p: P) => number) - py: number | ((p: P) => number) - curve: d3.CurveFactory - } & React.SVGProps -) => { - const { data, px, py, curve, ...rest } = props - const line = d3.line

(px, py).curve(curve) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return -} -const LinePath = memo(LinePathInternal) as typeof LinePathInternal - -const AreaPathInternal = ( - props: { - data: P[] - px: number | ((p: P) => number) - py0: number | ((p: P) => number) - py1: number | ((p: P) => number) - curve: d3.CurveFactory - } & React.SVGProps -) => { - const { data, px, py0, py1, curve, ...rest } = props - const area = d3.area

(px, py0, py1).curve(curve) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return -} -const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal - -const SVGChart = (props: { - children: React.ReactNode - w: number - h: number - xAxis: d3.Axis - yAxis: d3.Axis - onMouseOver?: (ev: React.PointerEvent) => void - onMouseLeave?: (ev: React.PointerEvent) => void - pct?: boolean -}) => { - const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave } = props - const innerW = w - MARGIN_X - const innerH = h - MARGIN_Y - return ( - - - - - {children} - - - - ) -} - -export const SingleValueDistributionChart = (props: { - data: NumericPoint[] - w: number - h: number - color: string - xScale: d3.ScaleContinuousNumeric - yScale: d3.ScaleContinuousNumeric -}) => { - const { color, data, xScale, yScale, w, h } = props - const tooltipRef = useRef(null) - const px = useCallback((p: NumericPoint) => xScale(p[0]), [xScale]) - const py0 = yScale(0) - const py1 = useCallback((p: NumericPoint) => yScale(p[1]), [yScale]) - - const formatX = (n: number) => formatLargeNumber(n) - const formatY = (n: number) => d3.format(',.2%')(n) - const xAxis = d3.axisBottom(xScale).tickFormat(formatX) - const yAxis = d3.axisLeft(yScale).tickFormat(formatY) - - return ( -

-
- - - - -
- ) -} - -export const MultiValueHistoryChart = (props: { - data: MultiPoint[] - w: number - h: number - labels: readonly string[] - colors: readonly string[] - xScale: d3.ScaleTime - yScale: d3.ScaleContinuousNumeric - pct?: boolean -}) => { - const { colors, data, xScale, yScale, labels, w, h, pct } = props - const tooltipRef = useRef(null) - const px = useCallback( - (p: d3.SeriesPoint) => xScale(p.data[0]), - [xScale] - ) - const py0 = useCallback( - (p: d3.SeriesPoint) => yScale(p[0]), - [yScale] - ) - const py1 = useCallback( - (p: d3.SeriesPoint) => yScale(p[1]), - [yScale] - ) - const stack = d3 - .stack() - .keys(range(0, labels.length)) - .value(([_date, probs], o) => probs[o]) - - const [xStart, xEnd] = xScale.domain() - const fmtX = getFormatterForDateRange(xStart, xEnd) - const fmtY = (n: number) => (pct ? d3.format('.0%')(n) : formatLargeNumber(n)) - - const [min, max] = yScale.domain() - const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) - const xAxis = d3.axisBottom(xScale).tickFormat(fmtX) - const yAxis = d3 - .axisLeft(yScale) - .tickValues(tickValues) - .tickFormat(fmtY) - - return ( -
-
- - {stack(data).map((s, i) => ( - - - - - ))} - -
- ) -} - -export const SingleValueHistoryChart = (props: { - data: HistoryPoint[] - w: number - h: number - color: string - xScale: d3.ScaleTime - yScale: d3.ScaleContinuousNumeric - pct?: boolean -}) => { - const { color, data, xScale, yScale, pct, w, h } = props - const tooltipRef = useRef(null) - const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) - const py0 = yScale(0) - const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) - - const dates = useMemo(() => data.map(([d]) => d), [data]) - const [startDate, endDate] = xScale.domain().map(dayjs) - const includeYear = !startDate.isSame(endDate, 'year') - const includeHour = startDate.add(8, 'day').isAfter(endDate) - const includeMinute = endDate.diff(startDate, 'hours') < 2 - const formatX = (d: Date) => - formatDate(d, { includeYear, includeHour, includeMinute }) - const formatY = (n: number) => - pct ? d3.format('.0%')(n) : formatLargeNumber(n) - - const [min, max] = yScale.domain() - const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) - const xAxis = d3.axisBottom(xScale).tickFormat(formatX) - const yAxis = d3 - .axisLeft(yScale) - .tickValues(tickValues) - .tickFormat(formatY) - - const onMouseOver = useEvent((event: React.PointerEvent) => { - const tt = tooltipRef.current - if (tt != null) { - const [mouseX, mouseY] = d3.pointer(event) - const date = xScale.invert(mouseX) - const [_, prob] = data[d3.bisectCenter(dates, date)] - tt.innerHTML = `${formatY(prob)} ${formatX(date)}` - tt.style.display = 'block' - tt.style.top = mouseY - 10 + 'px' - tt.style.left = mouseX + 20 + 'px' - } - }) - - const onMouseLeave = useEvent(() => { - const tt = tooltipRef.current - if (tt != null) { - tt.style.display = 'none' - } - }) - - return ( -
-
- - - - -
- ) -} - -export const ContractChart = (props: { - contract: Contract - bets: Bet[] - height?: number -}) => { - const { contract } = props - switch (contract.outcomeType) { - case 'BINARY': - return - case 'PSEUDO_NUMERIC': - return - case 'FREE_RESPONSE': - case 'MULTIPLE_CHOICE': - return - case 'NUMERIC': - return - default: - return null - } -} - -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) -} - -export const NumericContractChart = (props: { - contract: NumericContract - height?: number -}) => { - const { contract } = props - 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 = d3.scaleLinear( - [contract.min, contract.max], - [0, width - MARGIN_X] - ) - const yScale = d3.scaleLinear([0, maxY], [height - MARGIN_Y, 0]) - return ( -
- {width && ( - - )} -
- ) -} - -export const PseudoNumericContractChart = (props: { - contract: PseudoNumericContract - bets: Bet[] - height?: number -}) => { - const { contract, bets } = props - const data = useMemo(() => getChartData(contract, bets), [contract, bets]) - const isMobile = useIsMobile(800) - const containerRef = useRef(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? isMobile ? 150 : 250 - const scaleType = contract.isLogScale ? d3.scaleLog : d3.scaleLinear - const xScale = d3.scaleTime(getDateRange(contract), [0, width - MARGIN_X]) - const yScale = scaleType([contract.min, contract.max], [height - MARGIN_Y, 0]) - return ( -
- {width && ( - - )} -
- ) -} - -export const BinaryContractChart = (props: { - contract: BinaryContract - bets: Bet[] - height?: number -}) => { - const { contract, bets } = props - const data = useMemo(() => getChartData(contract, bets), [contract, bets]) - const isMobile = useIsMobile(800) - const containerRef = useRef(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? isMobile ? 150 : 250 - const xScale = d3.scaleTime(getDateRange(contract), [0, width - MARGIN_X]) - const yScale = d3.scaleLinear([0, 1], [height - MARGIN_Y, 0]) - return ( -
- {width && ( - - )} -
- ) -} - -export const ChoiceContractChart = (props: { - contract: FreeResponseContract | MultipleChoiceContract - bets: Bet[] - height?: number -}) => { - const { contract, bets } = props - const data = useMemo( - () => getMultiChartData(contract, bets), - [contract, bets] - ) - const isMobile = useIsMobile(800) - const containerRef = useRef(null) - const width = useElementWidth(containerRef) ?? 0 - const height = props.height ?? isMobile ? 150 : 250 - const xScale = d3.scaleTime(getDateRange(contract), [0, width - MARGIN_X]) - const yScale = d3.scaleLinear([0, 1], [height - MARGIN_Y, 0]) - return ( -
- {width && ( - - )} -
- ) -} diff --git a/web/hooks/use-element-width.tsx b/web/hooks/use-element-width.tsx new file mode 100644 index 00000000..1c373839 --- /dev/null +++ b/web/hooks/use-element-width.tsx @@ -0,0 +1,17 @@ +import { RefObject, useState, useEffect } from 'react' + +// todo: consider consolidation with use-measure-size +export const useElementWidth = (ref: RefObject) => { + const [width, setWidth] = useState() + useEffect(() => { + const handleResize = () => { + setWidth(ref.current?.clientWidth) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [ref]) + return width +} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index f804a78e..e925a1f6 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -11,7 +11,7 @@ import { PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { MarketSubheader } from 'web/components/contract/contract-details' -import { ContractChart } from 'web/components/contract/contract-prob-graph' +import { ContractChart } from 'web/components/charts/contract' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Spacer } from 'web/components/layout/spacer'