From 221d2208dfa7b9be835a897e02865d3675e107a2 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Mon, 26 Sep 2022 00:38:32 -0700 Subject: [PATCH] Implement numeric chart --- web/components/contract/contract-overview.tsx | 4 +- .../contract/contract-prob-graph.tsx | 243 ++++++++++++++---- web/components/contract/numeric-graph.tsx | 99 ------- 3 files changed, 190 insertions(+), 156 deletions(-) delete mode 100644 web/components/contract/numeric-graph.tsx diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a29679bd..905a820f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -4,6 +4,7 @@ import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { BinaryContractChart, + NumericContractChart, PseudoNumericContractChart, ChoiceContractChart, } from './contract-prob-graph' @@ -28,7 +29,6 @@ import { BinaryContract, } from 'common/contract' import { ContractDetails } from './contract-details' -import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( @@ -66,7 +66,7 @@ const NumericOverview = (props: { contract: NumericContract }) => { contract={contract} /> - + ) } diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 9f942d00..c4b7429f 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -1,21 +1,24 @@ import dayjs from 'dayjs' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +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 { range, sortBy, groupBy, sumBy } from 'lodash' +import { max, range, sortBy, groupBy, sum } from 'lodash' import { useEvent } from 'web/hooks/use-event' import * as d3 from 'd3' @@ -24,8 +27,9 @@ 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[]] -type Point = readonly [Date, number] +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() @@ -85,6 +89,17 @@ const getTickValues = (min: number, max: number, n: number) => { 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[] @@ -121,7 +136,7 @@ const getMultiChartData = ( const { outcome, shares } = bet sharesByOutcome[outcome] += shares - const sharesSquared = sumBy( + const sharesSquared = sum( Object.values(sharesByOutcome).map((shares) => shares ** 2) ) points.push([ @@ -148,7 +163,7 @@ const getMultiChartData = ( const getChartData = ( contract: BinaryContract | PseudoNumericContract, bets: Bet[] -): Point[] => { +): HistoryPoint[] => { const getY = (p: number) => { if (contract.outcomeType === 'PSEUDO_NUMERIC') { const { min, max } = contract @@ -169,36 +184,32 @@ const getChartData = ( ] } -const XAxis = (props: { w: number; h: number; scale: d3.AxisScale }) => { - const { h, scale } = props +const XAxis = (props: { + w: number + h: number + axis: d3.Axis +}) => { + const { h, axis } = props const axisRef = useRef(null) - const [start, end] = scale.domain() - const fmt = getFormatterForDateRange(start, end) useEffect(() => { if (axisRef.current != null) { - const axis = d3.axisBottom(scale).tickFormat(fmt) d3.select(axisRef.current) .call(axis) .call((g) => g.select('.domain').remove()) } - }) + }, [h, axis]) return } -const YAxis = (props: { +const YAxis = (props: { w: number h: number - scale: d3.AxisScale - pct?: boolean + axis: d3.Axis }) => { - const { w, h, scale, pct } = props + const { w, h, axis } = props const axisRef = useRef(null) - const [min, max] = scale.domain() - const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) - const fmt = (n: number) => (pct ? d3.format('.0%')(n) : formatLargeNumber(n)) useEffect(() => { if (axisRef.current != null) { - const axis = d3.axisLeft(scale).tickValues(tickValues).tickFormat(fmt) d3.select(axisRef.current) .call(axis) .call((g) => g.select('.domain').remove()) @@ -206,7 +217,7 @@ const YAxis = (props: { g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) ) } - }) + }, [w, h, axis]) return } @@ -215,10 +226,11 @@ const LinePathInternal = ( data: P[] px: number | ((p: P) => number) py: number | ((p: P) => number) + curve: d3.CurveFactory } & React.SVGProps ) => { - const { data, px, py, ...rest } = props - const line = d3.line

(px, py).curve(d3.curveStepAfter) + 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 } @@ -230,40 +242,40 @@ const AreaPathInternal = ( 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, ...rest } = props - const area = d3.area

(px, py0, py1).curve(d3.curveStepAfter) + 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 TwoAxisChart = (props: { +const SVGChart = (props: { children: React.ReactNode w: number h: number - xScale: d3.ScaleTime - yScale: d3.ScaleContinuousNumeric + xAxis: d3.Axis + yAxis: d3.Axis onMouseOver?: (ev: React.PointerEvent) => void onMouseLeave?: (ev: React.PointerEvent) => void pct?: boolean }) => { - const { children, w, h, xScale, yScale, onMouseOver, onMouseLeave, pct } = - props + const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave } = props const innerW = w - MARGIN_X const innerH = h - MARGIN_Y return ( - - + + {children} + 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 @@ -287,10 +347,34 @@ export const MultiValueHistoryChart = (props: { }) => { 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((p, o) => p[1][o]) + .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 (
@@ -299,31 +383,33 @@ export const MultiValueHistoryChart = (props: { style={{ display: 'none' }} className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white" /> - + {stack(data).map((s, i) => ( xScale(p.data[0])} - py={(p) => yScale(p[1])} + px={px} + py={py1} + curve={d3.curveStepAfter} stroke={colors[i]} /> xScale(p.data[0])} - py0={(p) => yScale(p[0])} - py1={(p) => yScale(p[1])} + px={px} + py0={py0} + py1={py1} + curve={d3.curveStepAfter} fill={colors[i]} /> ))} - +
) } export const SingleValueHistoryChart = (props: { - data: Point[] + data: HistoryPoint[] w: number h: number color: string @@ -333,6 +419,10 @@ export const SingleValueHistoryChart = (props: { }) => { 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') @@ -343,6 +433,14 @@ export const SingleValueHistoryChart = (props: { 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) { @@ -370,30 +468,31 @@ export const SingleValueHistoryChart = (props: { style={{ display: 'none' }} className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white" /> - xScale(p[0])} - py={(p) => yScale(p[1])} + px={px} + py={py1} + curve={d3.curveStepAfter} stroke={color} /> xScale(p[0])} - py0={yScale(0)} - py1={(p) => yScale(p[1])} + px={px} + py0={py0} + py1={py1} + curve={d3.curveStepAfter} fill={color} - fillOpacity={0.3} + opacity={0.3} /> - +
) } @@ -412,6 +511,8 @@ export const ContractChart = (props: { case 'FREE_RESPONSE': case 'MULTIPLE_CHOICE': return + case 'NUMERIC': + return default: return null } @@ -426,6 +527,38 @@ const getFormatterForDateRange = (start: Date, end: Date) => { 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[] @@ -449,7 +582,7 @@ export const PseudoNumericContractChart = (props: { xScale={xScale} yScale={yScale} data={data} - color="#5fa5f9" + color={NUMERIC_GRAPH_COLOR} /> )} diff --git a/web/components/contract/numeric-graph.tsx b/web/components/contract/numeric-graph.tsx deleted file mode 100644 index f6532b9b..00000000 --- a/web/components/contract/numeric-graph.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { Point, ResponsiveLine } from '@nivo/line' -import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' -import { memo } from 'react' -import { range } from 'lodash' -import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm' -import { NumericContract } from '../../../common/contract' -import { useWindowSize } from '../../hooks/use-window-size' -import { Col } from '../layout/col' -import { formatLargeNumber } from 'common/util/format' - -export const NumericGraph = memo(function NumericGraph(props: { - contract: NumericContract - height?: number -}) { - const { contract, height } = props - 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}`] * 100) - const points = probs.map((prob, i) => ({ x: xs[i], y: prob })) - const maxProb = Math.max(...probs) - const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }] - - const yTickValues = [ - 0, - 0.25 * maxProb, - 0.5 & maxProb, - 0.75 * maxProb, - maxProb, - ] - - const { width } = useWindowSize() - - const numXTickValues = !width || width < 800 ? 2 : 5 - - return ( -
= 800 ? 350 : 250) }} - > - `${formatLargeNumber(+d, 3)}`} - axisBottom={{ - tickValues: numXTickValues, - format: (d) => `${formatLargeNumber(+d, 3)}`, - }} - colors={{ datum: 'color' }} - pointSize={0} - enableSlices="x" - sliceTooltip={({ slice }) => { - const point = slice.points[0] - return - }} - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 50 }} - /> -
- ) -}) - -function formatPercent(y: DatumValue) { - const p = Math.round(+y * 100) / 100 - return `${p}%` -} - -function Tooltip(props: { point: Point }) { - const { point } = props - return ( - -
- {point.serieId} {point.data.yFormatted} -
-
{formatLargeNumber(+point.data.x)}
- - ) -}