import { useMemo, useRef, useCallback } from 'react' import { axisBottom, axisLeft, bisectCenter, curveLinear, curveStepAfter, pointer, stack, ScaleTime, ScaleContinuousNumeric, SeriesPoint, } from 'd3' import { range } from 'lodash' import dayjs from 'dayjs' import { SVGChart, AreaPath, AreaWithTopStroke } 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 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' 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 yScale: ScaleContinuousNumeric }) => { const { color, data, xScale, yScale, w, h } = props const tooltipRef = useRef(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) => formatPct(n, 2) const xAxis = axisBottom(xScale).tickFormat(formatX) const yAxis = axisLeft(yScale).tickFormat(formatY) return (
) } export const MultiValueHistoryChart = (props: { data: MultiPoint[] w: number h: number labels: readonly string[] colors: readonly string[] xScale: ScaleTime yScale: ScaleContinuousNumeric pct?: boolean }) => { const { colors, data, xScale, yScale, labels, w, h, pct } = props const tooltipRef = useRef(null) const px = useCallback( (p: SeriesPoint) => xScale(p.data[0]), [xScale] ) const py0 = useCallback( (p: SeriesPoint) => yScale(p[0]), [yScale] ) const py1 = useCallback( (p: SeriesPoint) => yScale(p[1]), [yScale] ) const [xStart, xEnd] = xScale.domain() const fmtX = getFormatterForDateRange(xStart, xEnd) const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n)) const [min, max] = yScale.domain() const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).tickFormat(fmtX) const yAxis = axisLeft(yScale).tickValues(tickValues).tickFormat(fmtY) const d3Stack = stack() .keys(range(0, labels.length)) .value(([_date, probs], o) => probs[o]) return (
{d3Stack(data).map((s, i) => ( ))}
) } export const SingleValueHistoryChart = (props: { data: HistoryPoint[] w: number h: number color: string xScale: d3.ScaleTime yScale: d3.ScaleContinuousNumeric pct?: boolean }) => { const { color, data, xScale, yScale, pct, w, h } = props const tooltipRef = useRef(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 ? formatPct(n, 0) : formatLargeNumber(n)) const [min, max] = yScale.domain() const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).tickFormat(formatX) const yAxis = axisLeft(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 = `${formatY(prob)} ${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 (
) }