269 lines
7.7 KiB
TypeScript
269 lines
7.7 KiB
TypeScript
import { useMemo, useRef, useCallback } from 'react'
|
|
import {
|
|
axisBottom,
|
|
axisLeft,
|
|
bisectCenter,
|
|
curveLinear,
|
|
curveStepAfter,
|
|
format,
|
|
pointer,
|
|
stack,
|
|
ScaleTime,
|
|
ScaleContinuousNumeric,
|
|
SeriesPoint,
|
|
} from 'd3'
|
|
import { range } from 'lodash'
|
|
import dayjs from 'dayjs'
|
|
|
|
import { SVGChart, LinePath, AreaPath } from './helpers'
|
|
import { formatLargeNumber } from 'common/util/format'
|
|
import { useEvent } from 'web/hooks/use-event'
|
|
|
|
export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]]
|
|
export type HistoryPoint = readonly [Date, number] // [time, number or percentage]
|
|
export type NumericPoint = readonly [number, number] // [number, prob]
|
|
|
|
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'
|
|
if (d.isSame(now, 'day')) {
|
|
return '[Today]'
|
|
} else if (d.add(1, 'day').isSame(now, 'day')) {
|
|
return '[Yesterday]'
|
|
} else {
|
|
let format = '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]
|
|
}
|
|
|
|
export const SingleValueDistributionChart = (props: {
|
|
data: NumericPoint[]
|
|
w: number
|
|
h: number
|
|
color: string
|
|
xScale: ScaleContinuousNumeric<number, number>
|
|
yScale: ScaleContinuousNumeric<number, number>
|
|
}) => {
|
|
const { color, data, xScale, yScale, w, h } = props
|
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
const px = useCallback((p: NumericPoint) => xScale(p[0]), [xScale])
|
|
const py0 = yScale(0)
|
|
const py1 = useCallback((p: NumericPoint) => yScale(p[1]), [yScale])
|
|
|
|
const formatX = (n: number) => formatLargeNumber(n)
|
|
const formatY = (n: number) => format(',.2%')(n)
|
|
const xAxis = axisBottom<number>(xScale).tickFormat(formatX)
|
|
const yAxis = axisLeft<number>(yScale).tickFormat(formatY)
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
ref={tooltipRef}
|
|
style={{ display: 'none' }}
|
|
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white"
|
|
/>
|
|
<SVGChart w={w} h={h} xAxis={xAxis} yAxis={yAxis}>
|
|
<LinePath
|
|
data={data}
|
|
curve={curveLinear}
|
|
px={px}
|
|
py={py1}
|
|
stroke={color}
|
|
/>
|
|
<AreaPath
|
|
data={data}
|
|
curve={curveLinear}
|
|
px={px}
|
|
py0={py0}
|
|
py1={py1}
|
|
fill={color}
|
|
opacity={0.3}
|
|
/>
|
|
</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, xScale, yScale, labels, w, h, pct } = props
|
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
const px = useCallback(
|
|
(p: SeriesPoint<MultiPoint>) => xScale(p.data[0]),
|
|
[xScale]
|
|
)
|
|
const py0 = useCallback(
|
|
(p: SeriesPoint<MultiPoint>) => yScale(p[0]),
|
|
[yScale]
|
|
)
|
|
const py1 = useCallback(
|
|
(p: SeriesPoint<MultiPoint>) => yScale(p[1]),
|
|
[yScale]
|
|
)
|
|
const d3Stack = stack<MultiPoint, number>()
|
|
.keys(range(0, labels.length))
|
|
.value(([_date, probs], o) => probs[o])
|
|
|
|
const [xStart, xEnd] = xScale.domain()
|
|
const fmtX = getFormatterForDateRange(xStart, xEnd)
|
|
const fmtY = (n: number) => (pct ? format('.0%')(n) : formatLargeNumber(n))
|
|
|
|
const [min, max] = yScale.domain()
|
|
const tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
|
|
const xAxis = axisBottom<Date>(xScale).tickFormat(fmtX)
|
|
const yAxis = axisLeft<number>(yScale).tickValues(tickValues).tickFormat(fmtY)
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
ref={tooltipRef}
|
|
style={{ display: 'none' }}
|
|
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white"
|
|
/>
|
|
<SVGChart w={w} h={h} xAxis={xAxis} yAxis={yAxis}>
|
|
{d3Stack(data).map((s, i) => (
|
|
<g key={s.key}>
|
|
<LinePath
|
|
data={s}
|
|
px={px}
|
|
py={py1}
|
|
curve={curveStepAfter}
|
|
stroke={colors[i]}
|
|
/>
|
|
<AreaPath
|
|
data={s}
|
|
px={px}
|
|
py0={py0}
|
|
py1={py1}
|
|
curve={curveStepAfter}
|
|
fill={colors[i]}
|
|
/>
|
|
</g>
|
|
))}
|
|
</SVGChart>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const SingleValueHistoryChart = (props: {
|
|
data: HistoryPoint[]
|
|
w: number
|
|
h: number
|
|
color: string
|
|
xScale: d3.ScaleTime<number, number>
|
|
yScale: d3.ScaleContinuousNumeric<number, number>
|
|
pct?: boolean
|
|
}) => {
|
|
const { color, data, xScale, yScale, pct, w, h } = props
|
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale])
|
|
const py0 = yScale(0)
|
|
const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale])
|
|
|
|
const dates = useMemo(() => data.map(([d]) => d), [data])
|
|
const [startDate, endDate] = xScale.domain().map(dayjs)
|
|
const includeYear = !startDate.isSame(endDate, 'year')
|
|
const includeHour = startDate.add(8, 'day').isAfter(endDate)
|
|
const includeMinute = endDate.diff(startDate, 'hours') < 2
|
|
const formatX = (d: Date) =>
|
|
formatDate(d, { includeYear, includeHour, includeMinute })
|
|
const formatY = (n: number) => (pct ? format('.0%')(n) : formatLargeNumber(n))
|
|
|
|
const [min, max] = yScale.domain()
|
|
const tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
|
|
const xAxis = axisBottom<Date>(xScale).tickFormat(formatX)
|
|
const yAxis = axisLeft<number>(yScale)
|
|
.tickValues(tickValues)
|
|
.tickFormat(formatY)
|
|
|
|
const onMouseOver = useEvent((event: React.PointerEvent) => {
|
|
const tt = tooltipRef.current
|
|
if (tt != null) {
|
|
const [mouseX, mouseY] = pointer(event)
|
|
const date = xScale.invert(mouseX)
|
|
const [_, prob] = data[bisectCenter(dates, date)]
|
|
tt.innerHTML = `<strong>${formatY(prob)}</strong> ${formatX(date)}`
|
|
tt.style.display = 'block'
|
|
tt.style.top = mouseY - 10 + 'px'
|
|
tt.style.left = mouseX + 20 + 'px'
|
|
}
|
|
})
|
|
|
|
const onMouseLeave = useEvent(() => {
|
|
const tt = tooltipRef.current
|
|
if (tt != null) {
|
|
tt.style.display = 'none'
|
|
}
|
|
})
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
ref={tooltipRef}
|
|
style={{ display: 'none' }}
|
|
className="pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-slate-600/75 p-2 text-white"
|
|
/>
|
|
<SVGChart
|
|
w={w}
|
|
h={h}
|
|
xAxis={xAxis}
|
|
yAxis={yAxis}
|
|
onMouseOver={onMouseOver}
|
|
onMouseLeave={onMouseLeave}
|
|
>
|
|
<LinePath
|
|
data={data}
|
|
px={px}
|
|
py={py1}
|
|
curve={curveStepAfter}
|
|
stroke={color}
|
|
/>
|
|
<AreaPath
|
|
data={data}
|
|
px={px}
|
|
py0={py0}
|
|
py1={py1}
|
|
curve={curveStepAfter}
|
|
fill={color}
|
|
opacity={0.3}
|
|
/>
|
|
</SVGChart>
|
|
</div>
|
|
)
|
|
}
|