diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx
new file mode 100644
index 00000000..27b8846d
--- /dev/null
+++ b/web/components/answers/answers-graph.tsx
@@ -0,0 +1,177 @@
+import { DatumValue } from '@nivo/core'
+import { ResponsiveLine } from '@nivo/line'
+import dayjs from 'dayjs'
+import _ from 'lodash'
+
+import { Bet } from '../../../common/bet'
+import { Contract } from '../../../common/contract'
+import { getOutcomeProbability } from '../../../common/calculate'
+import { useBets } from '../../hooks/use-bets'
+import { useWindowSize } from '../../hooks/use-window-size'
+
+export function AnswersGraph(props: { contract: Contract; bets: Bet[] }) {
+ const { contract } = props
+ const { resolutionTime, closeTime, answers, totalShares } = contract
+
+ const bets = (useBets(contract.id) ?? props.bets).filter((bet) => !bet.sale)
+
+ const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
+ bets,
+ totalShares
+ )
+
+ const isClosed = !!closeTime && Date.now() > closeTime
+ const latestTime = dayjs(
+ resolutionTime && isClosed
+ ? Math.min(resolutionTime, closeTime)
+ : isClosed
+ ? closeTime
+ : resolutionTime ?? Date.now()
+ )
+
+ const { width } = useWindowSize()
+
+ const labelLength = !width || width > 800 ? 50 : 20
+
+ const colors = ['#2a81e3', '#c72ae3', '#b91111', '#f3ad28', '#11b981']
+
+ const maxProb = _.max(
+ Object.values(probsByOutcome).map((probs) => Math.max(...probs))
+ )
+ const yMax = Math.min(100, Math.max(50, (maxProb ?? 0) * 100 * 1.2))
+
+ const times = _.sortBy([
+ contract.createdTime,
+ ...bets.map((bet) => bet.createdTime),
+ ])
+ const dateTimes = times.map((time) => new Date(time))
+
+ const data = sortedOutcomes.map((outcome, i) => {
+ const probs = probsByOutcome[outcome]
+
+ if (resolutionTime || isClosed) {
+ dateTimes.push(latestTime.toDate())
+ probs.push(probs[probs.length - 1])
+ } else {
+ // Add a fake datapoint in future so the line continues horizontally
+ // to the right.
+ dateTimes.push(latestTime.add(1, 'month').toDate())
+ probs.push(probs[probs.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 ? '...' : '')
+ const id = `#${outcome}: ${answerText}`
+
+ return { id, data: points, color: colors[i] }
+ })
+
+ data.reverse()
+
+ const yTickValues = [0, 25, 50, 75, 100]
+
+ const numXTickValues = !width || width < 800 ? 2 : 5
+ const hoursAgo = latestTime.subtract(5, 'hours')
+ const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
+ ? new Date(contract.createdTime)
+ : hoursAgo.toDate()
+
+ const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
+
+ return (
+
= 800 ? 350 : 225 }}
+ >
+ formatTime(+d.valueOf(), lessThanAWeek)}
+ axisBottom={{
+ tickValues: numXTickValues,
+ format: (time) => formatTime(+time, lessThanAWeek),
+ }}
+ colors={{ datum: 'color' }}
+ pointSize={0}
+ enableSlices="x"
+ enableGridX={!!width && width >= 800}
+ enableArea
+ margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
+ />
+
+ )
+}
+
+function formatPercent(y: DatumValue) {
+ return `${Math.round(+y.toString())}%`
+}
+
+function formatTime(time: number, includeTime: boolean) {
+ const d = dayjs(time)
+
+ if (d.isSame(Date.now(), 'day')) return d.format('ha')
+
+ if (includeTime) return dayjs(time).format('MMM D, ha')
+
+ return dayjs(time).format('MMM D')
+}
+
+const computeProbsByOutcome = (
+ bets: Bet[],
+ totalShares: { [outcome: string]: number }
+) => {
+ 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' && maxProb > 0.05
+ })
+
+ const trackedOutcomes = _.sortBy(
+ outcomes,
+ (outcome) => -1 * getOutcomeProbability(totalShares, outcome)
+ ).slice(0, 5)
+
+ const probsByOutcome = _.fromPairs(
+ trackedOutcomes.map((outcome) => [outcome, [0]])
+ )
+ const sharesByOutcome = _.fromPairs(
+ 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/contract-overview.tsx b/web/components/contract-overview.tsx
index d5a14573..f92f2ade 100644
--- a/web/components/contract-overview.tsx
+++ b/web/components/contract-overview.tsx
@@ -19,6 +19,7 @@ import BetRow from './bet-row'
import { Fold } from '../../common/fold'
import { FoldTagList } from './tags-list'
import { ContractActivity } from './feed/contract-activity'
+import { AnswersGraph } from './answers/answers-graph'
export const ContractOverview = (props: {
contract: Contract
@@ -77,7 +78,11 @@ export const ContractOverview = (props: {
- {isBinary && }
+ {isBinary ? (
+
+ ) : (
+
+ )}
{children}
diff --git a/web/components/contract-prob-graph.tsx b/web/components/contract-prob-graph.tsx
index 56b03a0b..4513c339 100644
--- a/web/components/contract-prob-graph.tsx
+++ b/web/components/contract-prob-graph.tsx
@@ -62,7 +62,7 @@ export function ContractProbGraph(props: {
return (
= 800 ? 400 : 250 }}
>
- See more...
+ See more...
)}
@@ -675,11 +675,11 @@ function FeedAnswerGroup(props: {
items: ActivityItem[]
}) {
const { answer, items } = props
- const { username, avatarUrl, userId, name, text } = answer
+ const { username, avatarUrl, name, text } = answer
return (
-
-
+
+
@@ -687,9 +687,11 @@ function FeedAnswerGroup(props: {
- answered
+ answered
-
+
+
+