59cdc9f776
* Update FR colors, consolidate non-top answers into "Other" * Fix answer panel coloration to not be weird and work on Firefox
197 lines
5.9 KiB
TypeScript
197 lines
5.9 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { last, range, sum, sortBy, groupBy } from 'lodash'
|
|
import { scaleTime, scaleLinear } from 'd3-scale'
|
|
import { curveStepAfter } from 'd3-shape'
|
|
|
|
import { Bet } from 'common/bet'
|
|
import { Answer } from 'common/answer'
|
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
|
import { getOutcomeProbability } from 'common/calculate'
|
|
import { DAY_MS } from 'common/util/time'
|
|
import {
|
|
TooltipProps,
|
|
getDateRange,
|
|
getRightmostVisibleDate,
|
|
formatPct,
|
|
formatDateInRange,
|
|
} from '../helpers'
|
|
import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
|
|
import { Row } from 'web/components/layout/row'
|
|
import { Avatar } from 'web/components/avatar'
|
|
|
|
type ChoiceContract = FreeResponseContract | MultipleChoiceContract
|
|
|
|
export const CHOICE_ANSWER_COLORS = [
|
|
'#97C1EB',
|
|
'#F39F83',
|
|
'#F9EBA5',
|
|
'#FFC7D2',
|
|
'#C7ECFF',
|
|
'#8CDEC7',
|
|
'#DBE96F',
|
|
]
|
|
export const CHOICE_OTHER_COLOR = '#CCC'
|
|
export const CHOICE_ALL_COLORS = [...CHOICE_ANSWER_COLORS, CHOICE_OTHER_COLOR]
|
|
|
|
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
|
|
const MARGIN_X = MARGIN.left + MARGIN.right
|
|
const MARGIN_Y = MARGIN.top + MARGIN.bottom
|
|
|
|
const getAnswers = (contract: ChoiceContract) => {
|
|
const { answers, outcomeType } = contract
|
|
const validAnswers = answers.filter(
|
|
(answer) => answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE'
|
|
)
|
|
return sortBy(
|
|
validAnswers,
|
|
(answer) => -1 * getOutcomeProbability(contract, answer.id)
|
|
)
|
|
}
|
|
|
|
const getBetPoints = (answers: Answer[], bets: Bet[], topN?: number) => {
|
|
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<Bet>[] = []
|
|
for (const bet of sortedBets) {
|
|
const { outcome, shares } = bet
|
|
sharesByOutcome[outcome] += shares
|
|
|
|
const sharesSquared = sum(
|
|
Object.values(sharesByOutcome).map((shares) => shares ** 2)
|
|
)
|
|
const probs = answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared)
|
|
|
|
if (topN != null && answers.length > topN) {
|
|
const y = [...probs.slice(0, topN), sum(probs.slice(topN))]
|
|
points.push({ x: new Date(bet.createdTime), y, obj: bet })
|
|
} else {
|
|
points.push({ x: new Date(bet.createdTime), y: probs, obj: bet })
|
|
}
|
|
}
|
|
return points
|
|
}
|
|
|
|
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 gap-4">
|
|
<Row className="items-center gap-2 overflow-hidden">
|
|
<span
|
|
className="h-4 w-4 shrink-0"
|
|
style={{ backgroundColor: item.color }}
|
|
></span>
|
|
<span className="text-semibold overflow-hidden text-ellipsis">
|
|
{item.label}
|
|
</span>
|
|
</Row>
|
|
<span className="text-greyscale-6">{item.value}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)
|
|
}
|
|
|
|
export function useChartAnswers(contract: ChoiceContract) {
|
|
return useMemo(() => getAnswers(contract), [contract])
|
|
}
|
|
|
|
export const ChoiceContractChart = (props: {
|
|
contract: ChoiceContract
|
|
bets: Bet[]
|
|
width: number
|
|
height: number
|
|
onMouseOver?: (p: MultiPoint<Bet> | undefined) => void
|
|
}) => {
|
|
const { contract, bets, width, height, onMouseOver } = props
|
|
const [start, end] = getDateRange(contract)
|
|
const answers = useChartAnswers(contract)
|
|
const topN = Math.min(CHOICE_ANSWER_COLORS.length, answers.length)
|
|
const betPoints = useMemo(
|
|
() => getBetPoints(answers, bets, topN),
|
|
[answers, bets, topN]
|
|
)
|
|
const endProbs = useMemo(
|
|
() => answers.map((a) => getOutcomeProbability(contract, a.id)),
|
|
[answers, contract]
|
|
)
|
|
|
|
const data = useMemo(() => {
|
|
const yCount = answers.length > topN ? topN + 1 : topN
|
|
const startY = range(0, yCount).map((_) => 0)
|
|
const endY =
|
|
answers.length > topN
|
|
? [...endProbs.slice(0, topN), sum(endProbs.slice(topN))]
|
|
: endProbs
|
|
return [
|
|
{ x: new Date(start), y: startY },
|
|
...betPoints,
|
|
{
|
|
x: new Date(end ?? Date.now() + DAY_MS),
|
|
y: endY,
|
|
},
|
|
]
|
|
}, [answers.length, topN, betPoints, endProbs, start, end])
|
|
|
|
const rightmostDate = getRightmostVisibleDate(
|
|
end,
|
|
last(betPoints)?.x?.getTime(),
|
|
Date.now()
|
|
)
|
|
const visibleRange = [start, rightmostDate]
|
|
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X])
|
|
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
|
|
|
const ChoiceTooltip = useMemo(
|
|
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
|
|
const { data, x, xScale } = props
|
|
const [start, end] = xScale.domain()
|
|
const d = xScale.invert(x)
|
|
const legendItems = sortBy(
|
|
data.y.map((p, i) => ({
|
|
color: CHOICE_ALL_COLORS[i],
|
|
label: i === CHOICE_ANSWER_COLORS.length ? 'Other' : answers[i].text,
|
|
value: formatPct(p),
|
|
p,
|
|
})),
|
|
(item) => -item.p
|
|
).slice(0, 10)
|
|
return (
|
|
<>
|
|
<Row className="items-center gap-2">
|
|
{data.obj && (
|
|
<Avatar size="xxs" avatarUrl={data.obj.userAvatarUrl} />
|
|
)}
|
|
<span className="text-semibold text-base">
|
|
{formatDateInRange(d, start, end)}
|
|
</span>
|
|
</Row>
|
|
<Legend className="max-w-xs" items={legendItems} />
|
|
</>
|
|
)
|
|
},
|
|
[answers]
|
|
)
|
|
|
|
return (
|
|
<MultiValueHistoryChart
|
|
w={width}
|
|
h={height}
|
|
margin={MARGIN}
|
|
xScale={xScale}
|
|
yScale={yScale}
|
|
yKind="percent"
|
|
data={data}
|
|
colors={CHOICE_ALL_COLORS}
|
|
curve={curveStepAfter}
|
|
onMouseOver={onMouseOver}
|
|
Tooltip={ChoiceTooltip}
|
|
/>
|
|
)
|
|
}
|