Refactor chart tooltip stuff, add bet avatar to tooltips (#958)
* Use objects instead of tuples for chart data * Carry bet data down into charts * Refactor to invert control of chart tooltip display * Jazz up the chart tooltips with avatars * Tidying
This commit is contained in:
parent
7f7e7acd61
commit
be010da9f5
|
@ -8,13 +8,14 @@ export function Avatar(props: {
|
||||||
username?: string
|
username?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
noLink?: boolean
|
noLink?: boolean
|
||||||
size?: number | 'xs' | 'sm'
|
size?: number | 'xxs' | 'xs' | 'sm'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { username, noLink, size, className } = props
|
const { username, noLink, size, className } = props
|
||||||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
const s =
|
||||||
|
size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||||
const sizeInPx = s * 4
|
const sizeInPx = s * 4
|
||||||
|
|
||||||
const onClick =
|
const onClick =
|
||||||
|
|
|
@ -12,13 +12,34 @@ import {
|
||||||
MAX_DATE,
|
MAX_DATE,
|
||||||
getDateRange,
|
getDateRange,
|
||||||
getRightmostVisibleDate,
|
getRightmostVisibleDate,
|
||||||
|
formatDateInRange,
|
||||||
|
formatPct,
|
||||||
} from '../helpers'
|
} from '../helpers'
|
||||||
import { SingleValueHistoryChart } from '../generic-charts'
|
import {
|
||||||
|
SingleValueHistoryTooltipProps,
|
||||||
|
SingleValueHistoryChart,
|
||||||
|
} from '../generic-charts'
|
||||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Avatar } from 'web/components/avatar'
|
||||||
|
|
||||||
const getBetPoints = (bets: Bet[]) => {
|
const getBetPoints = (bets: Bet[]) => {
|
||||||
return sortBy(bets, (b) => b.createdTime).map(
|
return sortBy(bets, (b) => b.createdTime).map((b) => ({
|
||||||
(b) => [new Date(b.createdTime), b.probAfter] as const
|
x: new Date(b.createdTime),
|
||||||
|
y: b.probAfter,
|
||||||
|
datum: b,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const BinaryChartTooltip = (props: SingleValueHistoryTooltipProps<Bet>) => {
|
||||||
|
const { x, y, xScale, datum } = props
|
||||||
|
const [start, end] = xScale.domain()
|
||||||
|
return (
|
||||||
|
<Row className="items-center gap-2 text-sm">
|
||||||
|
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />}
|
||||||
|
<strong>{formatPct(y)}</strong>
|
||||||
|
<span>{formatDateInRange(x, start, end)}</span>
|
||||||
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,16 +55,16 @@ export const BinaryContractChart = (props: {
|
||||||
const betPoints = useMemo(() => getBetPoints(bets), [bets])
|
const betPoints = useMemo(() => getBetPoints(bets), [bets])
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => [
|
() => [
|
||||||
[startDate, startP] as const,
|
{ x: startDate, y: startP },
|
||||||
...betPoints,
|
...betPoints,
|
||||||
[endDate ?? MAX_DATE, endP] as const,
|
{ x: endDate ?? MAX_DATE, y: endP },
|
||||||
],
|
],
|
||||||
[startDate, startP, endDate, endP, betPoints]
|
[startDate, startP, endDate, endP, betPoints]
|
||||||
)
|
)
|
||||||
|
|
||||||
const rightmostDate = getRightmostVisibleDate(
|
const rightmostDate = getRightmostVisibleDate(
|
||||||
endDate,
|
endDate,
|
||||||
last(betPoints)?.[0],
|
last(betPoints)?.x,
|
||||||
new Date(Date.now())
|
new Date(Date.now())
|
||||||
)
|
)
|
||||||
const visibleRange = [startDate, rightmostDate]
|
const visibleRange = [startDate, rightmostDate]
|
||||||
|
@ -53,6 +74,7 @@ export const BinaryContractChart = (props: {
|
||||||
const height = props.height ?? (isMobile ? 250 : 350)
|
const height = props.height ?? (isMobile ? 250 : 350)
|
||||||
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
|
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
|
||||||
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
{width > 0 && (
|
{width > 0 && (
|
||||||
|
@ -63,6 +85,7 @@ export const BinaryContractChart = (props: {
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
data={data}
|
data={data}
|
||||||
color="#11b981"
|
color="#11b981"
|
||||||
|
Tooltip={BinaryChartTooltip}
|
||||||
pct
|
pct
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -8,14 +8,23 @@ import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { getOutcomeProbability } from 'common/calculate'
|
import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||||
import {
|
import {
|
||||||
|
Legend,
|
||||||
MARGIN_X,
|
MARGIN_X,
|
||||||
MARGIN_Y,
|
MARGIN_Y,
|
||||||
MAX_DATE,
|
MAX_DATE,
|
||||||
getDateRange,
|
getDateRange,
|
||||||
getRightmostVisibleDate,
|
getRightmostVisibleDate,
|
||||||
|
formatPct,
|
||||||
|
formatDateInRange,
|
||||||
} from '../helpers'
|
} from '../helpers'
|
||||||
import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
|
import {
|
||||||
|
MultiPoint,
|
||||||
|
MultiValueHistoryChart,
|
||||||
|
MultiValueHistoryTooltipProps,
|
||||||
|
} from '../generic-charts'
|
||||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Avatar } from 'web/components/avatar'
|
||||||
|
|
||||||
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
|
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
|
||||||
const CATEGORY_COLORS = [
|
const CATEGORY_COLORS = [
|
||||||
|
@ -92,28 +101,13 @@ const getTrackedAnswers = (
|
||||||
).slice(0, topN)
|
).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 getBetPoints = (answers: Answer[], bets: Bet[]) => {
|
||||||
const sortedBets = sortBy(bets, (b) => b.createdTime)
|
const sortedBets = sortBy(bets, (b) => b.createdTime)
|
||||||
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
|
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
|
||||||
const sharesByOutcome = Object.fromEntries(
|
const sharesByOutcome = Object.fromEntries(
|
||||||
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
|
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
|
||||||
)
|
)
|
||||||
const points: MultiPoint[] = []
|
const points: MultiPoint<Bet>[] = []
|
||||||
for (const bet of sortedBets) {
|
for (const bet of sortedBets) {
|
||||||
const { outcome, shares } = bet
|
const { outcome, shares } = bet
|
||||||
sharesByOutcome[outcome] += shares
|
sharesByOutcome[outcome] += shares
|
||||||
|
@ -121,10 +115,11 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
|
||||||
const sharesSquared = sum(
|
const sharesSquared = sum(
|
||||||
Object.values(sharesByOutcome).map((shares) => shares ** 2)
|
Object.values(sharesByOutcome).map((shares) => shares ** 2)
|
||||||
)
|
)
|
||||||
points.push([
|
points.push({
|
||||||
new Date(bet.createdTime),
|
x: new Date(bet.createdTime),
|
||||||
answers.map((answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared),
|
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
|
||||||
])
|
datum: bet,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return points
|
return points
|
||||||
}
|
}
|
||||||
|
@ -135,7 +130,7 @@ export const ChoiceContractChart = (props: {
|
||||||
height?: number
|
height?: number
|
||||||
}) => {
|
}) => {
|
||||||
const { contract, bets } = props
|
const { contract, bets } = props
|
||||||
const [contractStart, contractEnd] = getDateRange(contract)
|
const [start, end] = getDateRange(contract)
|
||||||
const answers = useMemo(
|
const answers = useMemo(
|
||||||
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
|
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
|
||||||
[contract]
|
[contract]
|
||||||
|
@ -143,24 +138,54 @@ export const ChoiceContractChart = (props: {
|
||||||
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
|
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => [
|
() => [
|
||||||
getStartPoint(answers, contractStart),
|
{ x: start, y: answers.map((_) => 0) },
|
||||||
...betPoints,
|
...betPoints,
|
||||||
getEndPoint(answers, contract, contractEnd ?? MAX_DATE),
|
{
|
||||||
|
x: end ?? MAX_DATE,
|
||||||
|
y: answers.map((a) => getOutcomeProbability(contract, a.id)),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[answers, contract, betPoints, contractStart, contractEnd]
|
[answers, contract, betPoints, start, end]
|
||||||
)
|
)
|
||||||
const rightmostDate = getRightmostVisibleDate(
|
const rightmostDate = getRightmostVisibleDate(
|
||||||
contractEnd,
|
end,
|
||||||
last(betPoints)?.[0],
|
last(betPoints)?.x,
|
||||||
new Date(Date.now())
|
new Date(Date.now())
|
||||||
)
|
)
|
||||||
const visibleRange = [contractStart, rightmostDate]
|
const visibleRange = [start, rightmostDate]
|
||||||
const isMobile = useIsMobile(800)
|
const isMobile = useIsMobile(800)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const width = useElementWidth(containerRef) ?? 0
|
const width = useElementWidth(containerRef) ?? 0
|
||||||
const height = props.height ?? (isMobile ? 150 : 250)
|
const height = props.height ?? (isMobile ? 150 : 250)
|
||||||
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
|
const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
|
||||||
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
|
||||||
|
|
||||||
|
const ChoiceTooltip = useMemo(
|
||||||
|
() => (props: MultiValueHistoryTooltipProps<Bet>) => {
|
||||||
|
const { x, y, xScale, datum } = props
|
||||||
|
const [start, end] = xScale.domain()
|
||||||
|
const legendItems = sortBy(
|
||||||
|
y.map((p, i) => ({
|
||||||
|
color: CATEGORY_COLORS[i],
|
||||||
|
label: answers[i].text,
|
||||||
|
value: formatPct(p),
|
||||||
|
p,
|
||||||
|
})),
|
||||||
|
(item) => -item.p
|
||||||
|
).slice(0, 10)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row className="items-center gap-2">
|
||||||
|
{datum && <Avatar size="xxs" avatarUrl={datum.userAvatarUrl} />}
|
||||||
|
<span>{formatDateInRange(x, start, end)}</span>
|
||||||
|
</Row>
|
||||||
|
<Legend className="max-w-xs text-sm" items={legendItems} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[answers]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
{width > 0 && (
|
{width > 0 && (
|
||||||
|
@ -171,7 +196,7 @@ export const ChoiceContractChart = (props: {
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
data={data}
|
data={data}
|
||||||
colors={CATEGORY_COLORS}
|
colors={CATEGORY_COLORS}
|
||||||
labels={answers.map((answer) => answer.text)}
|
Tooltip={ChoiceTooltip}
|
||||||
pct
|
pct
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,21 +1,35 @@
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { max, range } from 'lodash'
|
import { range } from 'lodash'
|
||||||
import { scaleLinear } from 'd3-scale'
|
import { scaleLinear } from 'd3-scale'
|
||||||
|
|
||||||
|
import { formatLargeNumber } from 'common/util/format'
|
||||||
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
|
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
|
||||||
import { NumericContract } from 'common/contract'
|
import { NumericContract } from 'common/contract'
|
||||||
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
||||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||||
import { MARGIN_X, MARGIN_Y } from '../helpers'
|
import { MARGIN_X, MARGIN_Y, formatPct } from '../helpers'
|
||||||
import { SingleValueDistributionChart } from '../generic-charts'
|
import {
|
||||||
|
SingleValueDistributionChart,
|
||||||
|
SingleValueDistributionTooltipProps,
|
||||||
|
} from '../generic-charts'
|
||||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||||
|
|
||||||
const getNumericChartData = (contract: NumericContract) => {
|
const getNumericChartData = (contract: NumericContract) => {
|
||||||
const { totalShares, bucketCount, min, max } = contract
|
const { totalShares, bucketCount, min, max } = contract
|
||||||
const step = (max - min) / bucketCount
|
const step = (max - min) / bucketCount
|
||||||
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
|
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
|
||||||
return range(bucketCount).map(
|
return range(bucketCount).map((i) => ({
|
||||||
(i) => [min + step * (i + 0.5), bucketProbs[`${i}`]] as const
|
x: min + step * (i + 0.5),
|
||||||
|
y: bucketProbs[`${i}`],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const NumericChartTooltip = (props: SingleValueDistributionTooltipProps) => {
|
||||||
|
const { x, y } = props
|
||||||
|
return (
|
||||||
|
<span className="text-sm">
|
||||||
|
<strong>{formatPct(y, 2)}</strong> {formatLargeNumber(x)}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,16 +38,14 @@ export const NumericContractChart = (props: {
|
||||||
height?: number
|
height?: number
|
||||||
}) => {
|
}) => {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
|
const { min, max } = contract
|
||||||
const data = useMemo(() => getNumericChartData(contract), [contract])
|
const data = useMemo(() => getNumericChartData(contract), [contract])
|
||||||
const isMobile = useIsMobile(800)
|
const isMobile = useIsMobile(800)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const width = useElementWidth(containerRef) ?? 0
|
const width = useElementWidth(containerRef) ?? 0
|
||||||
const height = props.height ?? (isMobile ? 150 : 250)
|
const height = props.height ?? (isMobile ? 150 : 250)
|
||||||
const maxY = max(data.map((d) => d[1])) as number
|
const maxY = Math.max(...data.map((d) => d.y))
|
||||||
const xScale = scaleLinear(
|
const xScale = scaleLinear([min, max], [0, width - MARGIN_X])
|
||||||
[contract.min, contract.max],
|
|
||||||
[0, width - MARGIN_X]
|
|
||||||
)
|
|
||||||
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
|
const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
|
@ -45,6 +57,7 @@ export const NumericContractChart = (props: {
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
data={data}
|
data={data}
|
||||||
color={NUMERIC_GRAPH_COLOR}
|
color={NUMERIC_GRAPH_COLOR}
|
||||||
|
Tooltip={NumericChartTooltip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
|
||||||
|
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { getInitialProbability, getProbability } from 'common/calculate'
|
import { getInitialProbability, getProbability } from 'common/calculate'
|
||||||
|
import { formatLargeNumber } from 'common/util/format'
|
||||||
import { PseudoNumericContract } from 'common/contract'
|
import { PseudoNumericContract } from 'common/contract'
|
||||||
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
|
||||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||||
|
@ -13,9 +14,15 @@ import {
|
||||||
MAX_DATE,
|
MAX_DATE,
|
||||||
getDateRange,
|
getDateRange,
|
||||||
getRightmostVisibleDate,
|
getRightmostVisibleDate,
|
||||||
|
formatDateInRange,
|
||||||
} from '../helpers'
|
} from '../helpers'
|
||||||
import { SingleValueHistoryChart } from '../generic-charts'
|
import {
|
||||||
|
SingleValueHistoryChart,
|
||||||
|
SingleValueHistoryTooltipProps,
|
||||||
|
} from '../generic-charts'
|
||||||
import { useElementWidth } from 'web/hooks/use-element-width'
|
import { useElementWidth } from 'web/hooks/use-element-width'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Avatar } from 'web/components/avatar'
|
||||||
|
|
||||||
// mqp: note that we have an idiosyncratic version of 'log scale'
|
// mqp: note that we have an idiosyncratic version of 'log scale'
|
||||||
// contracts. the values are stored "linearly" and can include zero.
|
// contracts. the values are stored "linearly" and can include zero.
|
||||||
|
@ -29,8 +36,24 @@ const getScaleP = (min: number, max: number, isLogScale: boolean) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
|
const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
|
||||||
return sortBy(bets, (b) => b.createdTime).map(
|
return sortBy(bets, (b) => b.createdTime).map((b) => ({
|
||||||
(b) => [new Date(b.createdTime), scaleP(b.probAfter)] as const
|
x: new Date(b.createdTime),
|
||||||
|
y: scaleP(b.probAfter),
|
||||||
|
datum: b,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PseudoNumericChartTooltip = (
|
||||||
|
props: SingleValueHistoryTooltipProps<Bet>
|
||||||
|
) => {
|
||||||
|
const { x, y, xScale, datum } = props
|
||||||
|
const [start, end] = xScale.domain()
|
||||||
|
return (
|
||||||
|
<Row className="items-center gap-2 text-sm">
|
||||||
|
{datum && <Avatar size="xs" avatarUrl={datum.userAvatarUrl} />}
|
||||||
|
<strong>{formatLargeNumber(y)}</strong>
|
||||||
|
<span>{formatDateInRange(x, start, end)}</span>
|
||||||
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,15 +74,15 @@ export const PseudoNumericContractChart = (props: {
|
||||||
const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP])
|
const betPoints = useMemo(() => getBetPoints(bets, scaleP), [bets, scaleP])
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => [
|
() => [
|
||||||
[startDate, startP] as const,
|
{ x: startDate, y: startP },
|
||||||
...betPoints,
|
...betPoints,
|
||||||
[endDate ?? MAX_DATE, endP] as const,
|
{ x: endDate ?? MAX_DATE, y: endP },
|
||||||
],
|
],
|
||||||
[betPoints, startDate, startP, endDate, endP]
|
[betPoints, startDate, startP, endDate, endP]
|
||||||
)
|
)
|
||||||
const rightmostDate = getRightmostVisibleDate(
|
const rightmostDate = getRightmostVisibleDate(
|
||||||
endDate,
|
endDate,
|
||||||
last(betPoints)?.[0],
|
last(betPoints)?.x,
|
||||||
new Date(Date.now())
|
new Date(Date.now())
|
||||||
)
|
)
|
||||||
const visibleRange = [startDate, rightmostDate]
|
const visibleRange = [startDate, rightmostDate]
|
||||||
|
@ -82,6 +105,7 @@ export const PseudoNumericContractChart = (props: {
|
||||||
xScale={xScale}
|
xScale={xScale}
|
||||||
yScale={yScale}
|
yScale={yScale}
|
||||||
data={data}
|
data={data}
|
||||||
|
Tooltip={PseudoNumericChartTooltip}
|
||||||
color={NUMERIC_GRAPH_COLOR}
|
color={NUMERIC_GRAPH_COLOR}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -11,127 +11,59 @@ import {
|
||||||
stackOrderReverse,
|
stackOrderReverse,
|
||||||
SeriesPoint,
|
SeriesPoint,
|
||||||
} from 'd3-shape'
|
} from 'd3-shape'
|
||||||
import { range, sortBy } from 'lodash'
|
import { range } from 'lodash'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SVGChart,
|
SVGChart,
|
||||||
AreaPath,
|
AreaPath,
|
||||||
AreaWithTopStroke,
|
AreaWithTopStroke,
|
||||||
ChartTooltip,
|
TooltipContent,
|
||||||
|
TooltipContainer,
|
||||||
TooltipPosition,
|
TooltipPosition,
|
||||||
|
formatPct,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
import { formatLargeNumber } from 'common/util/format'
|
|
||||||
import { useEvent } from 'web/hooks/use-event'
|
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 MultiPoint<T = never> = { x: Date; y: number[]; datum?: T }
|
||||||
export type HistoryPoint = readonly [Date, number] // [time, number or percentage]
|
export type HistoryPoint<T = never> = { x: Date; y: number; datum?: T }
|
||||||
export type DistributionPoint = readonly [number, number] // [outcome amount, prob]
|
export type DistributionPoint<T = never> = { x: number; y: number; datum?: T }
|
||||||
export type PositionValue<P> = TooltipPosition & { p: P }
|
|
||||||
|
|
||||||
const formatPct = (n: number, digits?: number) => {
|
type PositionValue<P> = TooltipPosition & { p: P }
|
||||||
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 getTickValues = (min: number, max: number, n: number) => {
|
||||||
const step = (max - min) / (n - 1)
|
const step = (max - min) / (n - 1)
|
||||||
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
|
return [min, ...range(1, n - 1).map((i) => min + step * i), max]
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegendItem = { color: string; label: string; value?: string }
|
export const SingleValueDistributionChart = <T,>(props: {
|
||||||
|
data: DistributionPoint<T>[]
|
||||||
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
|
w: number
|
||||||
h: number
|
h: number
|
||||||
color: string
|
color: string
|
||||||
xScale: ScaleContinuousNumeric<number, number>
|
xScale: ScaleContinuousNumeric<number, number>
|
||||||
yScale: ScaleContinuousNumeric<number, number>
|
yScale: ScaleContinuousNumeric<number, number>
|
||||||
|
Tooltip?: TooltipContent<SingleValueDistributionTooltipProps<T>>
|
||||||
}) => {
|
}) => {
|
||||||
const { color, data, yScale, w, h } = props
|
const { color, data, yScale, w, h, Tooltip } = props
|
||||||
|
|
||||||
// note that we have to type this funkily in order to succesfully store
|
|
||||||
// a function inside of useState
|
|
||||||
const [viewXScale, setViewXScale] =
|
const [viewXScale, setViewXScale] =
|
||||||
useState<ScaleContinuousNumeric<number, number>>()
|
useState<ScaleContinuousNumeric<number, number>>()
|
||||||
const [mouseState, setMouseState] =
|
const [mouseState, setMouseState] =
|
||||||
useState<PositionValue<DistributionPoint>>()
|
useState<PositionValue<DistributionPoint<T>>>()
|
||||||
const xScale = viewXScale ?? props.xScale
|
const xScale = viewXScale ?? props.xScale
|
||||||
|
|
||||||
const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale])
|
const px = useCallback((p: DistributionPoint<T>) => xScale(p.x), [xScale])
|
||||||
const py0 = yScale(yScale.domain()[0])
|
const py0 = yScale(yScale.domain()[0])
|
||||||
const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale])
|
const py1 = useCallback((p: DistributionPoint<T>) => yScale(p.y), [yScale])
|
||||||
const xBisector = bisector((p: DistributionPoint) => p[0])
|
const xBisector = bisector((p: DistributionPoint<T>) => p.x)
|
||||||
|
|
||||||
const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
|
const { 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 xAxis = axisBottom<number>(xScale).ticks(w / 100)
|
||||||
const yAxis = axisLeft<number>(yScale).tickFormat(fmtY)
|
const yAxis = axisLeft<number>(yScale).tickFormat((n) => formatPct(n, 2))
|
||||||
return { fmtX, fmtY, xAxis, yAxis }
|
return { xAxis, yAxis }
|
||||||
}, [w, xScale, yScale])
|
}, [w, xScale, yScale])
|
||||||
|
|
||||||
const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint>) => {
|
const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint<T>>) => {
|
||||||
if (ev.selection) {
|
if (ev.selection) {
|
||||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||||
setViewXScale(() =>
|
setViewXScale(() =>
|
||||||
|
@ -154,8 +86,8 @@ export const SingleValueDistributionChart = (props: {
|
||||||
// so your queryX is out of bounds
|
// so your queryX is out of bounds
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [_x, y] = item
|
const p = { x: queryX, y: item.y, datum: item.datum }
|
||||||
setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
|
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -165,10 +97,10 @@ export const SingleValueDistributionChart = (props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{mouseState && (
|
{mouseState && Tooltip && (
|
||||||
<ChartTooltip className="text-sm" {...mouseState}>
|
<TooltipContainer className="text-sm" {...mouseState}>
|
||||||
<strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])}
|
<Tooltip xScale={xScale} {...mouseState.p} />
|
||||||
</ChartTooltip>
|
</TooltipContainer>
|
||||||
)}
|
)}
|
||||||
<SVGChart
|
<SVGChart
|
||||||
w={w}
|
w={w}
|
||||||
|
@ -192,52 +124,54 @@ export const SingleValueDistributionChart = (props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MultiValueHistoryChart = (props: {
|
export type SingleValueDistributionTooltipProps<T = unknown> =
|
||||||
data: MultiPoint[]
|
DistributionPoint<T> & {
|
||||||
|
xScale: React.ComponentProps<
|
||||||
|
typeof SingleValueDistributionChart<T>
|
||||||
|
>['xScale']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultiValueHistoryChart = <T,>(props: {
|
||||||
|
data: MultiPoint<T>[]
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
labels: readonly string[]
|
|
||||||
colors: readonly string[]
|
colors: readonly string[]
|
||||||
xScale: ScaleTime<number, number>
|
xScale: ScaleTime<number, number>
|
||||||
yScale: ScaleContinuousNumeric<number, number>
|
yScale: ScaleContinuousNumeric<number, number>
|
||||||
|
Tooltip?: TooltipContent<MultiValueHistoryTooltipProps<T>>
|
||||||
pct?: boolean
|
pct?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { colors, data, yScale, labels, w, h, pct } = props
|
const { colors, data, yScale, w, h, Tooltip, pct } = props
|
||||||
|
|
||||||
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
||||||
const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>()
|
const [mouseState, setMouseState] = useState<PositionValue<MultiPoint<T>>>()
|
||||||
const xScale = viewXScale ?? props.xScale
|
const xScale = viewXScale ?? props.xScale
|
||||||
|
|
||||||
type SP = SeriesPoint<MultiPoint>
|
type SP = SeriesPoint<MultiPoint<T>>
|
||||||
const px = useCallback((p: SP) => xScale(p.data[0]), [xScale])
|
const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
|
||||||
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
|
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
|
||||||
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
|
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
|
||||||
const xBisector = bisector((p: MultiPoint) => p[0])
|
const xBisector = bisector((p: MultiPoint<T>) => p.x)
|
||||||
|
|
||||||
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 { xAxis, yAxis } = useMemo(() => {
|
||||||
const [min, max] = yScale.domain()
|
const [min, max] = yScale.domain()
|
||||||
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
|
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
|
||||||
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
|
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
|
||||||
const yAxis = pct
|
const yAxis = pct
|
||||||
? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY)
|
? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(formatPct)
|
||||||
: axisLeft<number>(yScale)
|
: axisLeft<number>(yScale)
|
||||||
|
return { xAxis, yAxis }
|
||||||
return { fmtX, fmtY, xAxis, yAxis }
|
|
||||||
}, [w, h, pct, xScale, yScale])
|
}, [w, h, pct, xScale, yScale])
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
const d3Stack = stack<MultiPoint, number>()
|
const d3Stack = stack<MultiPoint<T>, number>()
|
||||||
.keys(range(0, labels.length))
|
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
|
||||||
.value(([_date, probs], o) => probs[o])
|
.value(({ y }, o) => y[o])
|
||||||
.order(stackOrderReverse)
|
.order(stackOrderReverse)
|
||||||
return d3Stack(data)
|
return d3Stack(data)
|
||||||
}, [data, labels.length])
|
}, [data])
|
||||||
|
|
||||||
const onSelect = useEvent((ev: D3BrushEvent<MultiPoint>) => {
|
const onSelect = useEvent((ev: D3BrushEvent<MultiPoint<T>>) => {
|
||||||
if (ev.selection) {
|
if (ev.selection) {
|
||||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||||
setViewXScale(() =>
|
setViewXScale(() =>
|
||||||
|
@ -260,8 +194,8 @@ export const MultiValueHistoryChart = (props: {
|
||||||
// so your queryX is out of bounds
|
// so your queryX is out of bounds
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [_x, ys] = item
|
const p = { x: queryX, y: item.y, datum: item.datum }
|
||||||
setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] })
|
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -269,24 +203,12 @@ export const MultiValueHistoryChart = (props: {
|
||||||
setMouseState(undefined)
|
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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{mouseState && (
|
{mouseState && Tooltip && (
|
||||||
<ChartTooltip {...mouseState}>
|
<TooltipContainer top={mouseState.top} left={mouseState.left}>
|
||||||
{fmtX(mouseState.p[0])}
|
<Tooltip xScale={xScale} {...mouseState.p} />
|
||||||
<Legend className="max-w-xs text-sm" items={legendItems} />
|
</TooltipContainer>
|
||||||
</ChartTooltip>
|
|
||||||
)}
|
)}
|
||||||
<SVGChart
|
<SVGChart
|
||||||
w={w}
|
w={w}
|
||||||
|
@ -313,41 +235,42 @@ export const MultiValueHistoryChart = (props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SingleValueHistoryChart = (props: {
|
export type MultiValueHistoryTooltipProps<T = unknown> = MultiPoint<T> & {
|
||||||
data: HistoryPoint[]
|
xScale: React.ComponentProps<typeof MultiValueHistoryChart<T>>['xScale']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SingleValueHistoryChart = <T,>(props: {
|
||||||
|
data: HistoryPoint<T>[]
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
color: string
|
color: string
|
||||||
xScale: ScaleTime<number, number>
|
xScale: ScaleTime<number, number>
|
||||||
yScale: ScaleContinuousNumeric<number, number>
|
yScale: ScaleContinuousNumeric<number, number>
|
||||||
|
Tooltip?: TooltipContent<SingleValueHistoryTooltipProps<T>>
|
||||||
pct?: boolean
|
pct?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { color, data, pct, yScale, w, h } = props
|
const { color, data, pct, yScale, w, h, Tooltip } = props
|
||||||
|
|
||||||
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
|
||||||
const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>()
|
const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint<T>>>()
|
||||||
const xScale = viewXScale ?? props.xScale
|
const xScale = viewXScale ?? props.xScale
|
||||||
|
|
||||||
const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale])
|
const px = useCallback((p: HistoryPoint<T>) => xScale(p.x), [xScale])
|
||||||
const py0 = yScale(yScale.domain()[0])
|
const py0 = yScale(yScale.domain()[0])
|
||||||
const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale])
|
const py1 = useCallback((p: HistoryPoint<T>) => yScale(p.y), [yScale])
|
||||||
const xBisector = bisector((p: HistoryPoint) => p[0])
|
const xBisector = bisector((p: HistoryPoint<T>) => p.x)
|
||||||
|
|
||||||
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 { xAxis, yAxis } = useMemo(() => {
|
||||||
const [min, max] = yScale.domain()
|
const [min, max] = yScale.domain()
|
||||||
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
|
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
|
||||||
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
|
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
|
||||||
const yAxis = pct
|
const yAxis = pct
|
||||||
? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY)
|
? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(formatPct)
|
||||||
: axisLeft<number>(yScale)
|
: axisLeft<number>(yScale)
|
||||||
return { fmtX, fmtY, xAxis, yAxis }
|
return { xAxis, yAxis }
|
||||||
}, [w, h, pct, xScale, yScale])
|
}, [w, h, pct, xScale, yScale])
|
||||||
|
|
||||||
const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint>) => {
|
const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint<T>>) => {
|
||||||
if (ev.selection) {
|
if (ev.selection) {
|
||||||
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
const [mouseX0, mouseX1] = ev.selection as [number, number]
|
||||||
setViewXScale(() =>
|
setViewXScale(() =>
|
||||||
|
@ -370,8 +293,8 @@ export const SingleValueHistoryChart = (props: {
|
||||||
// so your queryX is out of bounds
|
// so your queryX is out of bounds
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [_x, y] = item
|
const p = { x: queryX, y: item.y, datum: item.datum }
|
||||||
setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
|
setMouseState({ top: mouseY - 10, left: mouseX + 60, p })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -381,10 +304,10 @@ export const SingleValueHistoryChart = (props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{mouseState && (
|
{mouseState && Tooltip && (
|
||||||
<ChartTooltip className="text-sm" {...mouseState}>
|
<TooltipContainer top={mouseState.top} left={mouseState.left}>
|
||||||
<strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])}
|
<Tooltip xScale={xScale} {...mouseState.p} />
|
||||||
</ChartTooltip>
|
</TooltipContainer>
|
||||||
)}
|
)}
|
||||||
<SVGChart
|
<SVGChart
|
||||||
w={w}
|
w={w}
|
||||||
|
@ -407,3 +330,7 @@ export const SingleValueHistoryChart = (props: {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SingleValueHistoryTooltipProps<T = unknown> = HistoryPoint<T> & {
|
||||||
|
xScale: React.ComponentProps<typeof SingleValueHistoryChart<T>>['xScale']
|
||||||
|
}
|
||||||
|
|
|
@ -4,9 +4,11 @@ import { Axis } from 'd3-axis'
|
||||||
import { brushX, D3BrushEvent } from 'd3-brush'
|
import { brushX, D3BrushEvent } from 'd3-brush'
|
||||||
import { area, line, curveStepAfter, CurveFactory } from 'd3-shape'
|
import { area, line, curveStepAfter, CurveFactory } from 'd3-shape'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
|
||||||
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
|
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
|
||||||
export const MARGIN_X = MARGIN.right + MARGIN.left
|
export const MARGIN_X = MARGIN.right + MARGIN.left
|
||||||
|
@ -180,9 +182,9 @@ export const SVGChart = <X, Y>(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TooltipContent<P> = React.ComponentType<P>
|
||||||
export type TooltipPosition = { top: number; left: number }
|
export type TooltipPosition = { top: number; left: number }
|
||||||
|
export const TooltipContainer = (
|
||||||
export const ChartTooltip = (
|
|
||||||
props: TooltipPosition & { className?: string; children: React.ReactNode }
|
props: TooltipPosition & { className?: string; children: React.ReactNode }
|
||||||
) => {
|
) => {
|
||||||
const { top, left, className, children } = props
|
const { top, left, className, children } = props
|
||||||
|
@ -199,6 +201,27 @@ export const ChartTooltip = (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LegendItem = { color: string; label: string; value?: string }
|
||||||
|
export 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 getDateRange = (contract: Contract) => {
|
export const getDateRange = (contract: Contract) => {
|
||||||
const { createdTime, closeTime, resolutionTime } = contract
|
const { createdTime, closeTime, resolutionTime } = contract
|
||||||
const isClosed = !!closeTime && Date.now() > closeTime
|
const isClosed = !!closeTime && Date.now() > closeTime
|
||||||
|
@ -220,3 +243,46 @@ export const getRightmostVisibleDate = (
|
||||||
return now
|
return now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatPct = (n: number, digits?: number) => {
|
||||||
|
return `${(n * 100).toFixed(digits ?? 0)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateInRange = (d: Date, 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 formatDate(d, opts)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user