diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index d2b5f9b2..12f41453 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -17,7 +17,8 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' +import { Group } from '../../common/group' +import { batchedWaitAll } from '../../common/util/promise' const firestore = admin.firestore() @@ -27,28 +28,46 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues<Group>(firestore.collection('groups')), - ]) + console.log('Loading users') + const users = await getValues<User>(firestore.collection('users')) + console.log('Loading contracts') + const contracts = await getValues<Contract>(firestore.collection('contracts')) + + console.log('Loading portfolio history') + const allPortfolioHistories = await getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ) + + console.log('Loading groups') + const groups = await getValues<Group>(firestore.collection('groups')) + + console.log('Loading bets') + const contractBets = await batchedWaitAll( + contracts + .filter((c) => c.id) + .map( + (c) => () => + getValues<Bet>( + firestore.collection('contracts').doc(c.id).collection('bets') + ) + ), + 100 + ) + const bets = contractBets.flat() + + console.log('Loading group contracts') const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( + groups.map((group) => + getValues( firestore .collection('groups') .doc(group.id) .collection('groupContracts') ) - }) + ) ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx deleted file mode 100644 index e4167d11..00000000 --- a/web/components/answers/answers-graph.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { ResponsiveLine } from '@nivo/line' -import dayjs from 'dayjs' -import { groupBy, sortBy, sumBy } from 'lodash' -import { memo } from 'react' - -import { Bet } from 'common/bet' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' -import { getOutcomeProbability } from 'common/calculate' -import { useWindowSize } from 'web/hooks/use-window-size' - -const NUM_LINES = 6 - -export const AnswersGraph = memo(function AnswersGraph(props: { - contract: FreeResponseContract | MultipleChoiceContract - bets: Bet[] - height?: number -}) { - const { contract, bets, height } = props - const { createdTime, resolutionTime, closeTime, answers } = contract - const now = Date.now() - - const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( - bets, - contract - ) - - const isClosed = !!closeTime && now > closeTime - const latestTime = dayjs( - resolutionTime && isClosed - ? Math.min(resolutionTime, closeTime) - : isClosed - ? closeTime - : resolutionTime ?? now - ) - - const { width } = useWindowSize() - - const isLargeWidth = !width || width > 800 - const labelLength = isLargeWidth ? 50 : 20 - - // Add a fake datapoint so the line continues to the right - const endTime = latestTime.valueOf() - - const times = sortBy([ - createdTime, - ...bets.map((bet) => bet.createdTime), - endTime, - ]) - const dateTimes = times.map((time) => new Date(time)) - - const data = sortedOutcomes.map((outcome) => { - const betProbs = probsByOutcome[outcome] - // Add extra point for contract start and end. - const probs = [0, ...betProbs, betProbs[betProbs.length - 1]] - - const points = probs.map((prob, i) => ({ - x: dateTimes[i], - y: Math.round(prob * 100), - })) - - const answer = - answers?.find((answer) => answer.id === outcome)?.text ?? 'None' - const answerText = - answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '') - - return { id: answerText, data: points } - }) - - data.reverse() - - const yTickValues = [0, 25, 50, 75, 100] - - const numXTickValues = isLargeWidth ? 5 : 2 - const startDate = dayjs(contract.createdTime) - const endDate = startDate.add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours') - : latestTime - const includeMinute = endDate.diff(startDate, 'hours') < 2 - - const multiYear = !startDate.isSame(latestTime, 'year') - const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) - - return ( - <div - className="w-full" - style={{ height: height ?? (isLargeWidth ? 350 : 250) }} - > - <ResponsiveLine - data={data} - yScale={{ min: 0, max: 100, type: 'linear', stacked: true }} - yFormat={formatPercent} - gridYValues={yTickValues} - axisLeft={{ - tickValues: yTickValues, - format: formatPercent, - }} - xScale={{ - type: 'time', - min: startDate.toDate(), - max: endDate.toDate(), - }} - xFormat={(d) => - formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) - } - axisBottom={{ - tickValues: numXTickValues, - format: (time) => - formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), - }} - colors={[ - '#fca5a5', // red-300 - '#a5b4fc', // indigo-300 - '#86efac', // green-300 - '#fef08a', // yellow-200 - '#fdba74', // orange-300 - '#c084fc', // purple-400 - ]} - pointSize={0} - curve="stepAfter" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - areaOpacity={1} - margin={{ top: 20, right: 20, bottom: 25, left: 40 }} - legends={[ - { - anchor: 'top-left', - direction: 'column', - justify: false, - translateX: isLargeWidth ? 5 : 2, - translateY: 0, - itemsSpacing: 0, - itemTextColor: 'black', - itemDirection: 'left-to-right', - itemWidth: isLargeWidth ? 288 : 138, - itemHeight: 20, - itemBackground: 'white', - itemOpacity: 0.9, - symbolSize: 12, - effects: [ - { - on: 'hover', - style: { - itemBackground: 'rgba(255, 255, 255, 1)', - itemOpacity: 1, - }, - }, - ], - }, - ]} - /> - </div> - ) -}) - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} - -function formatTime( - now: number, - time: number, - includeYear: boolean, - includeHour: boolean, - includeMinute: boolean -) { - const d = dayjs(time) - if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) - return 'Now' - - let format: string - if (d.isSame(now, 'day')) { - format = '[Today]' - } else if (d.add(1, 'day').isSame(now, 'day')) { - format = '[Yesterday]' - } else { - format = 'MMM D' - } - - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - - return d.format(format) -} - -const computeProbsByOutcome = ( - bets: Bet[], - contract: FreeResponseContract | MultipleChoiceContract -) => { - const { totalBets, outcomeType } = contract - - const betsByOutcome = groupBy(bets, (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, NUM_LINES) - - const probsByOutcome = Object.fromEntries( - trackedOutcomes.map((outcome) => [outcome, [] as number[]]) - ) - const sharesByOutcome = Object.fromEntries( - Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) - ) - - for (const bet of bets) { - const { outcome, shares } = bet - sharesByOutcome[outcome] += shares - - const sharesSquared = sumBy( - Object.values(sharesByOutcome).map((shares) => shares ** 2) - ) - - for (const outcome of trackedOutcomes) { - probsByOutcome[outcome].push( - sharesByOutcome[outcome] ** 2 / sharesSquared - ) - } - } - - return { probsByOutcome, sortedOutcomes: trackedOutcomes } -} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 90918283..5d908937 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -419,7 +419,7 @@ export function BuyPanel(props: { open={seeLimit} setOpen={setSeeLimit} position="center" - className="rounded-lg bg-white px-4 pb-8" + className="rounded-lg bg-white px-4 pb-4" > <Title text="Limit Order" /> <LimitOrderPanel @@ -428,6 +428,11 @@ export function BuyPanel(props: { user={user} unfilledBets={unfilledBets} /> + <LimitBets + contract={contract} + bets={unfilledBets as LimitBet[]} + className="mt-4" + /> </Modal> </Col> </Col> diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx new file mode 100644 index 00000000..6d906998 --- /dev/null +++ b/web/components/charts/contract/binary.tsx @@ -0,0 +1,76 @@ +import { useMemo, useRef } from 'react' +import { last, sortBy } from 'lodash' +import { scaleTime, scaleLinear } from 'd3-scale' + +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, + MAX_DATE, + getDateRange, + getRightmostVisibleDate, +} from '../helpers' +import { SingleValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +const getBetPoints = (bets: Bet[]) => { + return sortBy(bets, (b) => b.createdTime).map( + (b) => [new Date(b.createdTime), b.probAfter] as const + ) +} + +const getStartPoint = (contract: BinaryContract, start: Date) => { + return [start, getInitialProbability(contract)] as const +} + +const getEndPoint = (contract: BinaryContract, end: Date) => { + return [end, getProbability(contract)] as const +} + +export const BinaryContractChart = (props: { + contract: BinaryContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [contractStart, contractEnd] = getDateRange(contract) + const betPoints = useMemo(() => getBetPoints(bets), [bets]) + const data = useMemo( + () => [ + getStartPoint(contract, contractStart), + ...betPoints, + getEndPoint(contract, contractEnd ?? MAX_DATE), + ], + [contract, betPoints, contractStart, contractEnd] + ) + const rightmostDate = getRightmostVisibleDate( + contractEnd, + last(betPoints)?.[0], + new Date(Date.now()) + ) + const visibleRange = [contractStart, rightmostDate] + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(null) + const width = useElementWidth(containerRef) ?? 0 + 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 ( + <div ref={containerRef}> + {width && ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color="#11b981" + pct + /> + )} + </div> + ) +} diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx new file mode 100644 index 00000000..7cf3e5ed --- /dev/null +++ b/web/components/charts/contract/choice.tsx @@ -0,0 +1,180 @@ +import { useMemo, useRef } from 'react' +import { last, sum, sortBy, groupBy } from 'lodash' +import { scaleTime, scaleLinear } from 'd3-scale' + +import { Bet } from 'common/bet' +import { Answer } from 'common/answer' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { getOutcomeProbability } from 'common/calculate' +import { useIsMobile } from 'web/hooks/use-is-mobile' +import { + MARGIN_X, + MARGIN_Y, + MAX_DATE, + getDateRange, + getRightmostVisibleDate, +} from '../helpers' +import { MultiPoint, MultiValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors +const CATEGORY_COLORS = [ + '#00b8dd', + '#eecafe', + '#874c62', + '#6457ca', + '#f773ba', + '#9c6bbc', + '#a87744', + '#af8a04', + '#bff9aa', + '#f3d89d', + '#c9a0f5', + '#ff00e5', + '#9dc6f7', + '#824475', + '#d973cc', + '#bc6808', + '#056e70', + '#677932', + '#00b287', + '#c8ab6c', + '#a2fb7a', + '#f8db68', + '#14675a', + '#8288f4', + '#fe1ca0', + '#ad6aff', + '#786306', + '#9bfbaf', + '#b00cf7', + '#2f7ec5', + '#4b998b', + '#42fa0e', + '#5b80a1', + '#962d9d', + '#3385ff', + '#48c5ab', + '#b2c873', + '#4cf9a4', + '#00ffff', + '#3cca73', + '#99ae17', + '#7af5cf', + '#52af45', + '#fbb80f', + '#29971b', + '#187c9a', + '#00d539', + '#bbfa1a', + '#61f55c', + '#cabc03', + '#ff9000', + '#779100', + '#bcfd6f', + '#70a560', +] + +const getTrackedAnswers = ( + contract: FreeResponseContract | MultipleChoiceContract, + topN: number +) => { + const { answers, outcomeType, totalBets } = contract + const validAnswers = answers.filter((answer) => { + return ( + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + totalBets[answer.id] > 0.000000001 + ) + }) + return sortBy( + validAnswers, + (answer) => -1 * getOutcomeProbability(contract, answer.id) + ).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[] = [] + 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), + answers.map((answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared), + ]) + } + return points +} + +export const ChoiceContractChart = (props: { + contract: FreeResponseContract | MultipleChoiceContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [contractStart, contractEnd] = getDateRange(contract) + const answers = useMemo( + () => getTrackedAnswers(contract, CATEGORY_COLORS.length), + [contract] + ) + const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) + const data = useMemo( + () => [ + getStartPoint(answers, contractStart), + ...betPoints, + getEndPoint(answers, contract, contractEnd ?? MAX_DATE), + ], + [answers, contract, betPoints, contractStart, contractEnd] + ) + const rightmostDate = getRightmostVisibleDate( + contractEnd, + last(betPoints)?.[0], + new Date(Date.now()) + ) + const visibleRange = [contractStart, rightmostDate] + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(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]) + return ( + <div ref={containerRef}> + {width && ( + <MultiValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + colors={CATEGORY_COLORS} + labels={answers.map((answer) => answer.text)} + pct + /> + )} + </div> + ) +} 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 <BinaryContractChart {...{ ...props, contract }} /> + case 'PSEUDO_NUMERIC': + return <PseudoNumericContractChart {...{ ...props, contract }} /> + case 'FREE_RESPONSE': + case 'MULTIPLE_CHOICE': + return <ChoiceContractChart {...{ ...props, contract }} /> + case 'NUMERIC': + return <NumericContractChart {...{ ...props, contract }} /> + 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..6b574f15 --- /dev/null +++ b/web/components/charts/contract/numeric.tsx @@ -0,0 +1,52 @@ +import { useMemo, useRef } from 'react' +import { max, range } from 'lodash' +import { scaleLinear } from 'd3-scale' + +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 step = (max - min) / bucketCount + const bucketProbs = getDpmOutcomeProbabilities(totalShares) + return range(bucketCount).map( + (i) => [min + step * (i + 0.5), bucketProbs[`${i}`]] 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<HTMLDivElement>(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 ( + <div ref={containerRef}> + {width && ( + <SingleValueDistributionChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color={NUMERIC_GRAPH_COLOR} + /> + )} + </div> + ) +} diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx new file mode 100644 index 00000000..0e2aaad0 --- /dev/null +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -0,0 +1,96 @@ +import { useMemo, useRef } from 'react' +import { last, sortBy } from 'lodash' +import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' + +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, + MAX_DATE, + getDateRange, + getRightmostVisibleDate, +} from '../helpers' +import { SingleValueHistoryChart } from '../generic-charts' +import { useElementWidth } from 'web/hooks/use-element-width' + +// mqp: note that we have an idiosyncratic version of 'log scale' +// contracts. the values are stored "linearly" and can include zero. +// as a result, we have to do some weird-looking stuff in this code + +const getY = (p: number, contract: PseudoNumericContract) => { + const { min, max, isLogScale } = contract + return isLogScale + ? 10 ** (p * Math.log10(max - min + 1)) + min - 1 + : p * (max - min) + min +} + +const getBetPoints = (contract: PseudoNumericContract, bets: Bet[]) => { + return sortBy(bets, (b) => b.createdTime).map( + (b) => [new Date(b.createdTime), getY(b.probAfter, contract)] as const + ) +} + +const getStartPoint = (contract: PseudoNumericContract, start: Date) => { + return [start, getY(getInitialProbability(contract), contract)] as const +} + +const getEndPoint = (contract: PseudoNumericContract, end: Date) => { + return [end, getY(getProbability(contract), contract)] as const +} + +export const PseudoNumericContractChart = (props: { + contract: PseudoNumericContract + bets: Bet[] + height?: number +}) => { + const { contract, bets } = props + const [contractStart, contractEnd] = getDateRange(contract) + const betPoints = useMemo( + () => getBetPoints(contract, bets), + [contract, bets] + ) + const data = useMemo( + () => [ + getStartPoint(contract, contractStart), + ...betPoints, + getEndPoint(contract, contractEnd ?? MAX_DATE), + ], + [contract, betPoints, contractStart, contractEnd] + ) + const rightmostDate = getRightmostVisibleDate( + contractEnd, + last(betPoints)?.[0], + new Date(Date.now()) + ) + const visibleRange = [contractStart, rightmostDate] + const isMobile = useIsMobile(800) + const containerRef = useRef<HTMLDivElement>(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 = contract.isLogScale + ? scaleLog( + [Math.max(contract.min, 1), contract.max], + [height - MARGIN_Y, 0] + ).clamp(true) // make sure zeroes go to the bottom + : scaleLinear([contract.min, contract.max], [height - MARGIN_Y, 0]) + + return ( + <div ref={containerRef}> + {width && ( + <SingleValueHistoryChart + w={width} + h={height} + xScale={xScale} + yScale={yScale} + data={data} + color={NUMERIC_GRAPH_COLOR} + /> + )} + </div> + ) +} diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx new file mode 100644 index 00000000..0d262e17 --- /dev/null +++ b/web/components/charts/generic-charts.tsx @@ -0,0 +1,409 @@ +import { useCallback, useMemo, useState } from 'react' +import { bisector } from 'd3-array' +import { axisBottom, axisLeft } from 'd3-axis' +import { D3BrushEvent } from 'd3-brush' +import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale' +import { pointer } from 'd3-selection' +import { + curveLinear, + curveStepAfter, + stack, + stackOrderReverse, + SeriesPoint, +} from 'd3-shape' +import { range, sortBy } from 'lodash' +import dayjs from 'dayjs' + +import { + SVGChart, + AreaPath, + AreaWithTopStroke, + ChartTooltip, + TooltipPosition, +} from './helpers' +import { formatLargeNumber } from 'common/util/format' +import { useEvent } from 'web/hooks/use-event' +import { Row } from 'web/components/layout/row' + +export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]] +export type HistoryPoint = readonly [Date, number] // [time, number or percentage] +export type DistributionPoint = readonly [number, number] // [outcome amount, prob] +export type PositionValue<P> = 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] +} + +type LegendItem = { color: string; label: string; value?: string } + +const Legend = (props: { className?: string; items: LegendItem[] }) => { + const { items, className } = props + return ( + <ol className={className}> + {items.map((item) => ( + <li key={item.label} className="flex flex-row justify-between"> + <Row className="mr-2 items-center overflow-hidden"> + <span + className="mr-2 h-4 w-4 shrink-0" + style={{ backgroundColor: item.color }} + ></span> + <span className="overflow-hidden text-ellipsis">{item.label}</span> + </Row> + {item.value} + </li> + ))} + </ol> + ) +} + +export const SingleValueDistributionChart = (props: { + data: DistributionPoint[] + w: number + h: number + color: string + xScale: ScaleContinuousNumeric<number, number> + yScale: ScaleContinuousNumeric<number, number> +}) => { + const { color, data, yScale, w, h } = props + + // note that we have to type this funkily in order to succesfully store + // a function inside of useState + const [viewXScale, setViewXScale] = + useState<ScaleContinuousNumeric<number, number>>() + const [mouseState, setMouseState] = + useState<PositionValue<DistributionPoint>>() + const xScale = viewXScale ?? props.xScale + + const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) + const py0 = yScale(yScale.domain()[0]) + const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale]) + const xBisector = bisector((p: DistributionPoint) => p[0]) + + const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { + const fmtX = (n: number) => formatLargeNumber(n) + const fmtY = (n: number) => formatPct(n, 2) + const xAxis = axisBottom<number>(xScale).ticks(w / 100) + const yAxis = axisLeft<number>(yScale).tickFormat(fmtY) + return { fmtX, fmtY, xAxis, yAxis } + }, [w, xScale, yScale]) + + const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + setMouseState(undefined) + } else { + setViewXScale(undefined) + setMouseState(undefined) + } + }) + + const onMouseOver = useEvent((ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse') { + const [mouseX, mouseY] = pointer(ev) + const queryX = xScale.invert(mouseX) + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return + } + const [_x, y] = item + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + } + }) + + const onMouseLeave = useEvent(() => { + setMouseState(undefined) + }) + + return ( + <div className="relative"> + {mouseState && ( + <ChartTooltip className="text-sm" {...mouseState}> + <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])} + </ChartTooltip> + )} + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} + > + <AreaWithTopStroke + color={color} + data={data} + px={px} + py0={py0} + py1={py1} + curve={curveLinear} + /> + </SVGChart> + </div> + ) +} + +export const MultiValueHistoryChart = (props: { + data: MultiPoint[] + w: number + h: number + labels: readonly string[] + colors: readonly string[] + xScale: ScaleTime<number, number> + yScale: ScaleContinuousNumeric<number, number> + pct?: boolean +}) => { + const { colors, data, yScale, labels, w, h, pct } = props + + const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() + const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>() + const xScale = viewXScale ?? props.xScale + + type SP = SeriesPoint<MultiPoint> + const px = useCallback((p: SP) => xScale(p.data[0]), [xScale]) + const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) + const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) + const xBisector = bisector((p: MultiPoint) => p[0]) + + const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { + const [start, end] = xScale.domain() + const fmtX = getFormatterForDateRange(start, end) + const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) + + const [min, max] = yScale.domain() + const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const xAxis = axisBottom<Date>(xScale).ticks(w / 100) + const yAxis = pct + ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY) + : axisLeft<number>(yScale) + + return { fmtX, fmtY, xAxis, yAxis } + }, [w, h, pct, xScale, yScale]) + + const series = useMemo(() => { + const d3Stack = stack<MultiPoint, number>() + .keys(range(0, labels.length)) + .value(([_date, probs], o) => probs[o]) + .order(stackOrderReverse) + return d3Stack(data) + }, [data, labels.length]) + + const onSelect = useEvent((ev: D3BrushEvent<MultiPoint>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + setMouseState(undefined) + } else { + setViewXScale(undefined) + setMouseState(undefined) + } + }) + + const onMouseOver = useEvent((ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse') { + const [mouseX, mouseY] = pointer(ev) + const queryX = xScale.invert(mouseX) + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return + } + const [_x, ys] = item + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] }) + } + }) + + const onMouseLeave = useEvent(() => { + 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 ( + <div className="relative"> + {mouseState && ( + <ChartTooltip {...mouseState}> + {fmtX(mouseState.p[0])} + <Legend className="max-w-xs text-sm" items={legendItems} /> + </ChartTooltip> + )} + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} + > + {series.map((s, i) => ( + <AreaPath + key={i} + data={s} + px={px} + py0={py0} + py1={py1} + curve={curveStepAfter} + fill={colors[i]} + /> + ))} + </SVGChart> + </div> + ) +} + +export const SingleValueHistoryChart = (props: { + data: HistoryPoint[] + w: number + h: number + color: string + xScale: ScaleTime<number, number> + yScale: ScaleContinuousNumeric<number, number> + pct?: boolean +}) => { + const { color, data, pct, yScale, w, h } = props + + const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() + const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>() + const xScale = viewXScale ?? props.xScale + + const px = useCallback((p: HistoryPoint) => xScale(p[0]), [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 [min, max] = yScale.domain() + const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) + const xAxis = axisBottom<Date>(xScale).ticks(w / 100) + const yAxis = pct + ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY) + : axisLeft<number>(yScale) + return { fmtX, fmtY, xAxis, yAxis } + }, [w, h, pct, xScale, yScale]) + + const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint>) => { + if (ev.selection) { + const [mouseX0, mouseX1] = ev.selection as [number, number] + setViewXScale(() => + xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) + ) + setMouseState(undefined) + } else { + setViewXScale(undefined) + setMouseState(undefined) + } + }) + + const onMouseOver = useEvent((ev: React.PointerEvent) => { + if (ev.pointerType === 'mouse') { + const [mouseX, mouseY] = pointer(ev) + const queryX = xScale.invert(mouseX) + const item = data[xBisector.left(data, queryX) - 1] + if (item == null) { + // this can happen if you are on the very left or right edge of the chart, + // so your queryX is out of bounds + return + } + const [_x, y] = item + setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) + } + }) + + const onMouseLeave = useEvent(() => { + setMouseState(undefined) + }) + + return ( + <div className="relative"> + {mouseState && ( + <ChartTooltip className="text-sm" {...mouseState}> + <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])} + </ChartTooltip> + )} + <SVGChart + w={w} + h={h} + xAxis={xAxis} + yAxis={yAxis} + onSelect={onSelect} + onMouseOver={onMouseOver} + onMouseLeave={onMouseLeave} + > + <AreaWithTopStroke + color={color} + data={data} + px={px} + py0={py0} + py1={py1} + curve={curveStepAfter} + /> + </SVGChart> + </div> + ) +} diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx new file mode 100644 index 00000000..644a421c --- /dev/null +++ b/web/components/charts/helpers.tsx @@ -0,0 +1,222 @@ +import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' +import { select } from 'd3-selection' +import { Axis } from 'd3-axis' +import { brushX, D3BrushEvent } from 'd3-brush' +import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' +import { nanoid } from 'nanoid' +import clsx from 'clsx' + +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 MAX_TIMESTAMP = 8640000000000000 +export const MAX_DATE = new Date(MAX_TIMESTAMP) + +export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => { + const { h, axis } = props + const axisRef = useRef<SVGGElement>(null) + useEffect(() => { + if (axisRef.current != null) { + select(axisRef.current) + .transition() + .duration(250) + .call(axis) + .select('.domain') + .attr('stroke-width', 0) + } + }, [h, axis]) + return <g ref={axisRef} transform={`translate(0, ${h})`} /> +} + +export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => { + const { w, h, axis } = props + const axisRef = useRef<SVGGElement>(null) + useEffect(() => { + if (axisRef.current != null) { + select(axisRef.current) + .transition() + .duration(250) + .call(axis) + .call((g) => + g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) + ) + .select('.domain') + .attr('stroke-width', 0) + } + }, [w, h, axis]) + return <g ref={axisRef} /> +} + +const LinePathInternal = <P,>( + props: { + data: P[] + px: number | ((p: P) => number) + py: number | ((p: P) => number) + curve?: CurveFactory + } & SVGProps<SVGPathElement> +) => { + const { data, px, py, curve, ...rest } = props + const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return <path {...rest} fill="none" d={d3Line(data)!} /> +} +export const LinePath = memo(LinePathInternal) as typeof LinePathInternal + +const AreaPathInternal = <P,>( + props: { + data: P[] + px: number | ((p: P) => number) + py0: number | ((p: P) => number) + py1: number | ((p: P) => number) + curve?: CurveFactory + } & SVGProps<SVGPathElement> +) => { + const { data, px, py0, py1, curve, ...rest } = props + const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return <path {...rest} d={d3Area(data)!} /> +} +export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal + +export const AreaWithTopStroke = <P,>(props: { + color: string + data: P[] + px: number | ((p: P) => number) + py0: number | ((p: P) => number) + py1: number | ((p: P) => number) + curve?: CurveFactory +}) => { + const { color, data, px, py0, py1, curve } = props + return ( + <g> + <AreaPath + data={data} + px={px} + py0={py0} + py1={py1} + curve={curve} + fill={color} + opacity={0.3} + /> + <LinePath data={data} px={px} py={py1} curve={curve} stroke={color} /> + </g> + ) +} + +export const SVGChart = <X, Y>(props: { + children: ReactNode + w: number + h: number + xAxis: Axis<X> + yAxis: Axis<Y> + onSelect?: (ev: D3BrushEvent<any>) => void + onMouseOver?: (ev: React.PointerEvent) => void + onMouseLeave?: (ev: React.PointerEvent) => void + pct?: boolean +}) => { + const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } = + props + const overlayRef = useRef<SVGGElement>(null) + const innerW = w - MARGIN_X + const innerH = h - MARGIN_Y + const clipPathId = useMemo(() => nanoid(), []) + + const justSelected = useRef(false) + useEffect(() => { + if (onSelect != null && overlayRef.current) { + const brush = brushX().extent([ + [0, 0], + [innerW, innerH], + ]) + brush.on('end', (ev) => { + // when we clear the brush after a selection, that would normally cause + // another 'end' event, so we have to suppress it with this flag + if (!justSelected.current) { + justSelected.current = true + onSelect(ev) + if (overlayRef.current) { + select(overlayRef.current).call(brush.clear) + } + } else { + justSelected.current = false + } + }) + // mqp: shape-rendering null overrides the default d3-brush shape-rendering + // of `crisp-edges`, which seems to cause graphical glitches on Chrome + // (i.e. the bug where the area fill flickers white) + select(overlayRef.current) + .call(brush) + .select('.selection') + .attr('shape-rendering', 'null') + } + }, [innerW, innerH, onSelect]) + + return ( + <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> + <clipPath id={clipPathId}> + <rect x={0} y={0} width={innerW} height={innerH} /> + </clipPath> + <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}> + <XAxis axis={xAxis} w={innerW} h={innerH} /> + <YAxis axis={yAxis} w={innerW} h={innerH} /> + <g clipPath={`url(#${clipPathId})`}>{children}</g> + <g + ref={overlayRef} + x="0" + y="0" + width={innerW} + height={innerH} + fill="none" + pointerEvents="all" + onPointerEnter={onMouseOver} + onPointerMove={onMouseOver} + onPointerLeave={onMouseLeave} + /> + </g> + </svg> + ) +} + +export type TooltipPosition = { top: number; left: number } + +export const ChartTooltip = ( + props: TooltipPosition & { className?: string; children: React.ReactNode } +) => { + const { top, left, className, children } = props + return ( + <div + className={clsx( + className, + 'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2' + )} + style={{ top, left }} + > + {children} + </div> + ) +} + +export const getDateRange = (contract: Contract) => { + const { createdTime, closeTime, resolutionTime } = contract + const isClosed = !!closeTime && Date.now() > closeTime + const endDate = resolutionTime ?? (isClosed ? closeTime : null) + return [new Date(createdTime), endDate ? new Date(endDate) : null] as const +} + +export const getRightmostVisibleDate = ( + contractEnd: Date | null | undefined, + lastActivity: Date | null | undefined, + now: Date +) => { + if (contractEnd != null) { + return contractEnd + } else if (lastActivity != null) { + // client-DB clock divergence may cause last activity to be later than now + return new Date(Math.max(lastActivity.getTime(), now.getTime())) + } else { + return now + } +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 139b30fe..add9ba48 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -2,7 +2,12 @@ import React from 'react' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' -import { ContractProbGraph } from './contract-prob-graph' +import { + BinaryContractChart, + NumericContractChart, + PseudoNumericContractChart, + ChoiceContractChart, +} from 'web/components/charts/contract' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' import { Linkify } from '../linkify' @@ -14,7 +19,6 @@ import { } from './contract-card' import { Bet } from 'common/bet' import BetButton, { BinaryMobileBetting } from '../bet-button' -import { AnswersGraph } from '../answers/answers-graph' import { Contract, CPMMContract, @@ -25,7 +29,6 @@ import { BinaryContract, } from 'common/contract' import { ContractDetails } from './contract-details' -import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> @@ -63,7 +66,7 @@ const NumericOverview = (props: { contract: NumericContract }) => { contract={contract} /> </Col> - <NumericGraph contract={contract} /> + <NumericContractChart contract={contract} /> </Col> ) } @@ -83,7 +86,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { /> </Row> </Col> - <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + <BinaryContractChart contract={contract} bets={bets} /> <Row className="items-center justify-between gap-4 xl:hidden"> {tradingAllowed(contract) && ( <BinaryMobileBetting contract={contract} /> @@ -109,7 +112,7 @@ const ChoiceOverview = (props: { )} </Col> <Col className={'mb-1 gap-y-2'}> - <AnswersGraph contract={contract} bets={[...bets].reverse()} /> + <ChoiceContractChart contract={contract} bets={bets} /> </Col> </Col> ) @@ -136,7 +139,7 @@ const PseudoNumericOverview = (props: { {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> - <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + <PseudoNumericContractChart contract={contract} bets={bets} /> </Col> ) } diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx deleted file mode 100644 index 60ef85b5..00000000 --- a/web/components/contract/contract-prob-graph.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { ResponsiveLine, SliceTooltipProps } from '@nivo/line' -import { BasicTooltip } from '@nivo/tooltip' -import dayjs from 'dayjs' -import { memo } from 'react' -import { Bet } from 'common/bet' -import { getInitialProbability } from 'common/calculate' -import { BinaryContract, PseudoNumericContract } from 'common/contract' -import { useWindowSize } from 'web/hooks/use-window-size' -import { formatLargeNumber } from 'common/util/format' - -export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract | PseudoNumericContract - bets: Bet[] - height?: number -}) { - const { contract, height } = props - const { resolutionTime, closeTime, outcomeType } = contract - const now = Date.now() - const isBinary = outcomeType === 'BINARY' - const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale - - const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) - - const startProb = getInitialProbability(contract) - - const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)] - - const f: (p: number) => number = isBinary - ? (p) => p - : isLogScale - ? (p) => p * Math.log10(contract.max - contract.min + 1) - : (p) => p * (contract.max - contract.min) + contract.min - - const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) - - const isClosed = !!closeTime && now > closeTime - const latestTime = dayjs( - resolutionTime && isClosed - ? Math.min(resolutionTime, closeTime) - : isClosed - ? closeTime - : resolutionTime ?? now - ) - - // Add a fake datapoint so the line continues to the right - times.push(latestTime.valueOf()) - probs.push(probs[probs.length - 1]) - - const { width } = useWindowSize() - - const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100] - - const yTickValues = isBinary - ? quartiles - : quartiles.map((x) => x / 100).map(f) - - const numXTickValues = !width || width < 800 ? 2 : 5 - const startDate = dayjs(times[0]) - const endDate = startDate.add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours') - : latestTime - const includeMinute = endDate.diff(startDate, 'hours') < 2 - - // Minimum number of points for the graph to have. For smooth tooltip movement - // If we aren't actually loading any data yet, skip adding extra points to let page load faster - // This fn runs again once DOM is finished loading - const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1 - - const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints - - const points: { x: Date; y: number }[] = [] - const s = isBinary ? 100 : 1 - - for (let i = 0; i < times.length - 1; i++) { - const p = probs[i] - const d0 = times[i] - const d1 = times[i + 1] - const msDiff = d1 - d0 - const numPoints = Math.floor(msDiff / timeStep) - points.push({ x: new Date(times[i]), y: s * p }) - if (numPoints > 1) { - const thisTimeStep: number = msDiff / numPoints - for (let n = 1; n < numPoints; n++) { - points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p }) - } - } - } - - const data = [ - { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, - ] - - const multiYear = !startDate.isSame(latestTime, 'year') - const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime) - - const formatter = isBinary - ? formatPercent - : isLogScale - ? (x: DatumValue) => - formatLargeNumber(10 ** +x.valueOf() + contract.min - 1) - : (x: DatumValue) => formatLargeNumber(+x.valueOf()) - - return ( - <div - className="w-full overflow-visible" - style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }} - > - <ResponsiveLine - data={data} - yScale={ - isBinary - ? { min: 0, max: 100, type: 'linear' } - : isLogScale - ? { - min: 0, - max: Math.log10(contract.max - contract.min + 1), - type: 'linear', - } - : { min: contract.min, max: contract.max, type: 'linear' } - } - yFormat={formatter} - gridYValues={yTickValues} - axisLeft={{ - tickValues: yTickValues, - format: formatter, - }} - xScale={{ - type: 'time', - min: startDate.toDate(), - max: endDate.toDate(), - }} - xFormat={(d) => - formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) - } - axisBottom={{ - tickValues: numXTickValues, - format: (time) => - formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), - }} - colors={{ datum: 'color' }} - curve="stepAfter" - enablePoints={false} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableGridX={false} - enableArea - areaBaselineValue={isBinary || isLogScale ? 0 : contract.min} - margin={{ top: 20, right: 20, bottom: 25, left: 40 }} - animate={false} - sliceTooltip={SliceTooltip} - /> - </div> - ) -}) - -const SliceTooltip = ({ slice }: SliceTooltipProps) => { - return ( - <BasicTooltip - id={slice.points.map((point) => [ - <span key="date"> - <strong>{point.data[`yFormatted`]}</strong> {point.data['xFormatted']} - </span>, - ])} - /> - ) -} - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} - -function formatTime( - now: number, - time: number, - includeYear: boolean, - includeHour: boolean, - includeMinute: boolean -) { - const d = dayjs(time) - if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) - return 'Now' - - let format: string - if (d.isSame(now, 'day')) { - format = '[Today]' - } else if (d.add(1, 'day').isSame(now, 'day')) { - format = '[Yesterday]' - } else { - format = 'MMM D' - } - - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - - return d.format(format) -} diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index a743bd3c..19350a39 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -23,19 +23,22 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' import { buildArray } from 'common/util/array' +import { ContractComment } from 'common/comment' import { formatMoney } from 'common/util/format' import { Button } from 'web/components/button' import { MINUTE_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { Tooltip } from 'web/components/tooltip' export function ContractTabs(props: { contract: Contract bets: Bet[] userBets: Bet[] + comments: ContractComment[] }) { - const { contract, bets, userBets } = props + const { contract, bets, userBets, comments } = props const { openCommentBounties } = contract const yourTrades = ( @@ -56,7 +59,7 @@ export function ContractTabs(props: { openCommentBounties )} currently available.` : undefined, - content: <CommentsTabContent contract={contract} />, + content: <CommentsTabContent contract={contract} comments={comments} />, inlineTabIcon: <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span>, }, { @@ -76,12 +79,13 @@ export function ContractTabs(props: { const CommentsTabContent = memo(function CommentsTabContent(props: { contract: Contract + comments: ContractComment[] }) { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) + const comments = useComments(contract.id) ?? props.comments const [sort, setSort] = useState<'Newest' | 'Best'>('Best') const me = useUser() - const comments = useComments(contract.id) if (comments == null) { return <LoadingIndicator /> } @@ -133,12 +137,16 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { </> ) } else { + const tipsOrBountiesAwarded = + Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) const commentsByParent = groupBy( sortBy(comments, (c) => sort === 'Newest' ? -c.createdTime - : // Is this too magic? 'Best' shows your own comments made within the last 10 minutes first, then sorts by score - c.createdTime > Date.now() - 10 * MINUTE_MS && c.userId === me?.id + : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + tipsOrBountiesAwarded && + c.createdTime > Date.now() - 10 * MINUTE_MS && + c.userId === me?.id ? -Infinity : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) ), @@ -154,7 +162,15 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { className="mb-4" onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')} > - Sorted by: {sort} + <Tooltip + text={ + sort === 'Best' + ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.' + : '' + } + > + Sorted by: {sort} + </Tooltip> </Button> <ContractCommentInput className="mb-5" contract={contract} /> {topLevelComments.map((parent) => ( 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 ( - <div - className="w-full overflow-hidden" - style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }} - > - <ResponsiveLine - data={data} - yScale={{ min: 0, max: maxProb, type: 'linear' }} - yFormat={formatPercent} - axisLeft={{ - tickValues: yTickValues, - format: formatPercent, - }} - xScale={{ - type: 'linear', - min: min, - max: max, - }} - xFormat={(d) => `${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 <Tooltip point={point} /> - }} - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 50 }} - /> - </div> - ) -}) - -function formatPercent(y: DatumValue) { - const p = Math.round(+y * 100) / 100 - return `${p}%` -} - -function Tooltip(props: { point: Point }) { - const { point } = props - return ( - <Col className="border border-gray-300 bg-white py-2 px-3"> - <div - className="pb-1" - style={{ - color: point.serieColor, - }} - > - <strong>{point.serieId}</strong> {point.data.yFormatted} - </div> - <div>{formatLargeNumber(+point.data.x)}</div> - </Col> - ) -} diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 07b7c659..f54ad915 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -7,6 +7,7 @@ import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' import { LoadingIndicator } from '../loading-indicator' +import { useContractWithPreload } from 'web/hooks/use-contract' export function ProbChangeTable(props: { changes: CPMMContract[] | undefined @@ -59,7 +60,9 @@ export function ProbChangeRow(props: { contract: CPMMContract className?: string }) { - const { contract, className } = props + const { className } = props + const contract = + (useContractWithPreload(props.contract) as CPMMContract) ?? props.contract return ( <Row className={clsx( 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 = <T extends Element>(ref: RefObject<T>) => { + const [width, setWidth] = useState<number>() + useEffect(() => { + const handleResize = () => { + setWidth(ref.current?.clientWidth) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [ref]) + return width +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index db4e8ede..733a1e06 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -131,7 +131,7 @@ function getCommentsOnPostCollection(postId: string) { } export async function listAllComments(contractId: string) { - return await getValues<Comment>( + return await getValues<ContractComment>( query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) ) } diff --git a/web/package.json b/web/package.json index 24650ba9..a5fa8ced 100644 --- a/web/package.json +++ b/web/package.json @@ -39,6 +39,12 @@ "browser-image-compression": "2.0.0", "clsx": "1.1.1", "cors": "2.8.5", + "d3-array": "3.2.0", + "d3-axis": "3.0.0", + "d3-brush": "3.0.0", + "d3-scale": "4.0.2", + "d3-shape": "3.1.0", + "d3-selection": "3.0.0", "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.9.3", @@ -66,6 +72,7 @@ "@tailwindcss/forms": "0.4.0", "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.1", + "@types/d3": "7.4.0", "@types/lodash": "4.14.178", "@types/node": "16.11.11", "@types/react": "17.0.43", diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 1dde2f95..93b53447 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -46,6 +46,8 @@ import { BetSignUpPrompt } from 'web/components/sign-up-prompt' import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer' import BetButton from 'web/components/bet-button' import { BetsSummary } from 'web/components/bet-summary' +import { listAllComments } from 'web/lib/firebase/comments' +import { ContractComment } from 'common/comment' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -55,10 +57,15 @@ export async function getStaticPropz(props: { const contract = (await getContractFromSlug(contractSlug)) || null const contractId = contract?.id const bets = contractId ? await listAllBets(contractId) : [] + const comments = contractId ? await listAllComments(contractId) : [] return { - // Limit the data sent to the client. Client will still load all bets directly. - props: { contract, bets: bets.slice(0, 5000) }, + props: { + contract, + // Limit the data sent to the client. Client will still load all bets/comments directly. + bets: bets.slice(0, 5000), + comments: comments.slice(0, 1000), + }, revalidate: 5, // regenerate after five seconds } } @@ -70,9 +77,14 @@ export async function getStaticPaths() { export default function ContractPage(props: { contract: Contract | null bets: Bet[] + comments: ContractComment[] backToHome?: () => void }) { - props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] } + props = usePropz(props, getStaticPropz) ?? { + contract: null, + bets: [], + comments: [], + } const inIframe = useIsIframe() if (inIframe) { @@ -147,7 +159,7 @@ export function ContractPageContent( contract: Contract } ) { - const { backToHome } = props + const { backToHome, comments } = props const contract = useContractWithPreload(props.contract) ?? props.contract const user = useUser() usePrefetch(user?.id) @@ -258,7 +270,12 @@ export function ContractPageContent( userBets={userBets} /> - <ContractTabs contract={contract} bets={bets} userBets={userBets} /> + <ContractTabs + contract={contract} + bets={bets} + userBets={userBets} + comments={comments} + /> {!user ? ( <Col className="mt-4 max-w-sm items-center xl:hidden"> diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx new file mode 100644 index 00000000..21494c37 --- /dev/null +++ b/web/pages/cowp.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' + +const App = () => { + return ( + <Page className=""> + <SEO + title="COWP" + description="A picture of a cowpy cowp copwer cowp saying 'salutations'" + url="/cowp" + /> + <Link href="https://www.youtube.com/watch?v=FavUpD_IjVY"> + <img src="https://i.imgur.com/Lt54IiU.png" /> + </Link> + </Page> + ) +} + +export default App diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 75a9ad05..e925a1f6 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -2,7 +2,6 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { DOMAIN } from 'common/envs/constants' import { useState } from 'react' -import { AnswersGraph } from 'web/components/answers/answers-graph' import { BetInline } from 'web/components/bet-inline' import { Button } from 'web/components/button' import { @@ -12,8 +11,7 @@ import { PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { MarketSubheader } from 'web/components/contract/contract-details' -import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' -import { NumericGraph } from 'web/components/contract/numeric-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' @@ -134,22 +132,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { )} <div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}> - {(isBinary || isPseudoNumeric) && ( - <ContractProbGraph - contract={contract} - bets={[...bets].reverse()} - height={graphHeight} - /> - )} - - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && ( - <AnswersGraph contract={contract} bets={bets} height={graphHeight} /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericGraph contract={contract} height={graphHeight} /> - )} + <ContractChart contract={contract} bets={bets} height={graphHeight} /> </div> </Col> ) diff --git a/yarn.lock b/yarn.lock index 6eaaf43f..c012b75c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3245,6 +3245,216 @@ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== +"@types/d3-array@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac" + integrity sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ== + +"@types/d3-axis@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.1.tgz#6afc20744fa5cc0cbc3e2bd367b140a79ed3e7a8" + integrity sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.1.tgz#ae5f17ce391935ca88b29000e60ee20452c6357c" + integrity sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.1.tgz#54c8856c19c8e4ab36a53f73ba737de4768ad248" + integrity sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw== + +"@types/d3-color@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4" + integrity sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA== + +"@types/d3-contour@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.1.tgz#9ff4e2fd2a3910de9c5097270a7da8a6ef240017" + integrity sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41" + integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ== + +"@types/d3-dispatch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz#a1b18ae5fa055a6734cb3bd3cbc6260ef19676e3" + integrity sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw== + +"@types/d3-drag@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.1.tgz#fb1e3d5cceeee4d913caa59dedf55c94cb66e80f" + integrity sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.0.tgz#f3c61fb117bd493ec0e814856feb804a14cfc311" + integrity sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A== + +"@types/d3-ease@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.0.tgz#c29926f8b596f9dadaeca062a32a45365681eae0" + integrity sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA== + +"@types/d3-fetch@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.1.tgz#f9fa88b81aa2eea5814f11aec82ecfddbd0b8fe0" + integrity sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.3.tgz#76cb20d04ae798afede1ea6e41750763ff5a9c82" + integrity sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA== + +"@types/d3-format@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d" + integrity sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg== + +"@types/d3-geo@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.0.2.tgz#e7ec5f484c159b2c404c42d260e6d99d99f45d9a" + integrity sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz#4561bb7ace038f247e108295ef77b6a82193ac25" + integrity sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ== + +"@types/d3-interpolate@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc" + integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b" + integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg== + +"@types/d3-polygon@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.0.tgz#5200a3fa793d7736fa104285fa19b0dbc2424b93" + integrity sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw== + +"@types/d3-quadtree@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz#433112a178eb7df123aab2ce11c67f51cafe8ff5" + integrity sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw== + +"@types/d3-random@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.1.tgz#5c8d42b36cd4c80b92e5626a252f994ca6bfc953" + integrity sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ== + +"@types/d3-scale-chromatic@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954" + integrity sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw== + +"@types/d3-scale@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.2.tgz#41be241126af4630524ead9cb1008ab2f0f26e69" + integrity sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.3.tgz#57be7da68e7d9c9b29efefd8ea5a9ef1171e42ba" + integrity sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA== + +"@types/d3-shape@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.0.tgz#1d87a6ddcf28285ef1e5c278ca4bdbc0658f3505" + integrity sha512-jYIYxFFA9vrJ8Hd4Se83YI6XF+gzDL1aC5DCsldai4XYYiVNdhtpGbA/GM6iyQ8ayhSp3a148LY34hy7A4TxZA== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946" + integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw== + +"@types/d3-time@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" + integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg== + +"@types/d3-timer@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.0.tgz#e2505f1c21ec08bda8915238e397fb71d2fc54ce" + integrity sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g== + +"@types/d3-transition@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.2.tgz#393dc3e3d55009a43cc6f252e73fccab6d78a8a4" + integrity sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826" + integrity sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.0.tgz#fc5cac5b1756fc592a3cf1f3dc881bf08225f515" + integrity sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/eslint-scope@^3.7.3": version "3.7.3" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224" @@ -3294,6 +3504,11 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/geojson@*": + version "7946.0.10" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" + integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== + "@types/google.maps@^3.45.3": version "3.49.0" resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.49.0.tgz#26fcf3d86ecbc6545db0e6691a434ec8132df48b" @@ -5237,11 +5452,39 @@ d3-array@2, d3-array@^2.3.0: dependencies: internmap "^1.0.0" +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== + dependencies: + internmap "1 - 2" + +d3-axis@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" + integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== + +d3-brush@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" + integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "3" + d3-transition "3" + "d3-color@1 - 2", d3-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + d3-delaunay@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d" @@ -5249,11 +5492,34 @@ d3-delaunay@^5.3.0: dependencies: delaunator "4" +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + "d3-format@1 - 2": version "2.0.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== +"d3-format@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== + d3-format@^1.4.4: version "1.4.5" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" @@ -5266,11 +5532,23 @@ d3-format@^1.4.4: dependencies: d3-color "1 - 2" +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + d3-path@1: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== +"d3-path@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" + integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== + d3-scale-chromatic@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab" @@ -5279,6 +5557,17 @@ d3-scale-chromatic@^2.0.0: d3-color "1 - 2" d3-interpolate "1 - 2" +d3-scale@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + d3-scale@^3.2.3: version "3.3.0" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3" @@ -5290,6 +5579,18 @@ d3-scale@^3.2.3: d3-time "^2.1.1" d3-time-format "2 - 3" +d3-selection@3, d3-selection@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +d3-shape@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556" + integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ== + dependencies: + d3-path "1 - 3" + d3-shape@^1.3.5: version "1.3.7" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -5304,6 +5605,13 @@ d3-shape@^1.3.5: dependencies: d3-time "1 - 2" +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + "d3-time@1 - 2", d3-time@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" @@ -5311,11 +5619,34 @@ d3-shape@^1.3.5: dependencies: d3-array "2" +"d3-time@1 - 3", "d3-time@2.1.1 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" + integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ== + dependencies: + d3-array "2 - 3" + d3-time@^1.0.11: version "1.1.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== +"d3-timer@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +d3-transition@3: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + daisyui@1.16.4: version "1.16.4" resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-1.16.4.tgz#52773401c0962e37ef40507d29f0e513c7f2856f" @@ -7514,6 +7845,11 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + internmap@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"