import { useCallback, useMemo, useState } from 'react' import { axisBottom, axisLeft, bisector, curveLinear, curveStepAfter, pointer, stack, stackOrderReverse, D3BrushEvent, ScaleTime, ScaleContinuousNumeric, SeriesPoint, } from 'd3' import { range, sortBy } from 'lodash' import dayjs from 'dayjs' import { SVGChart, AreaPath, AreaWithTopStroke, ChartTooltip, TooltipPosition, } from './helpers' import { formatLargeNumber } from 'common/util/format' 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 HistoryPoint = readonly [Date, number] // [time, number or percentage] export type DistributionPoint = readonly [number, number] // [outcome amount, prob] export type PositionValue

= TooltipPosition & { p: P } 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' } 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 step = (max - min) / (n - 1) return [min, ...range(1, n - 1).map((i) => min + step * i), max] } type LegendItem = { color: string; label: string; value?: string } const Legend = (props: { className?: string; items: LegendItem[] }) => { const { items, className } = props return (

    {items.map((item) => (
  1. {item.label} {item.value}
  2. ))}
) } export const SingleValueDistributionChart = (props: { data: DistributionPoint[] w: number h: number color: string xScale: ScaleContinuousNumeric yScale: ScaleContinuousNumeric }) => { const { color, data, yScale, w, h } = props // note that we have to type this funkily in order to succesfully store // a function inside of useState const [viewXScale, setViewXScale] = useState>() const [mouseState, setMouseState] = useState>() const xScale = viewXScale ?? props.xScale const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale]) const py0 = yScale(yScale.domain()[0]) const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale]) const xBisector = bisector((p: DistributionPoint) => p[0]) const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { const fmtX = (n: number) => formatLargeNumber(n) const fmtY = (n: number) => formatPct(n, 2) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = axisLeft(yScale).tickFormat(fmtY) return { fmtX, fmtY, xAxis, yAxis } }, [w, xScale, yScale]) const onSelect = useEvent((ev: D3BrushEvent) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) setMouseState(undefined) } else { setViewXScale(undefined) setMouseState(undefined) } }) const onMouseOver = useEvent((ev: React.PointerEvent) => { if (ev.pointerType === 'mouse') { const [mouseX, mouseY] = pointer(ev) const queryX = xScale.invert(mouseX) const [_x, y] = data[xBisector.center(data, queryX)] setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) } }) const onMouseLeave = useEvent(() => { setMouseState(undefined) }) return (
{mouseState && ( {fmtY(mouseState.p[1])} {fmtX(mouseState.p[0])} )}
) } 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, yScale, labels, w, h, pct } = props const [viewXScale, setViewXScale] = useState>() const [mouseState, setMouseState] = useState>() const xScale = viewXScale ?? props.xScale type SP = SeriesPoint const px = useCallback((p: SP) => xScale(p.data[0]), [xScale]) const py0 = useCallback((p: SP) => yScale(p[0]), [yScale]) const py1 = useCallback((p: SP) => yScale(p[1]), [yScale]) const xBisector = bisector((p: MultiPoint) => p[0]) 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 [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = pct ? axisLeft(yScale).tickValues(pctTickValues).tickFormat(fmtY) : axisLeft(yScale) return { fmtX, fmtY, xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const series = useMemo(() => { const d3Stack = stack() .keys(range(0, labels.length)) .value(([_date, probs], o) => probs[o]) .order(stackOrderReverse) return d3Stack(data) }, [data, labels.length]) const onSelect = useEvent((ev: D3BrushEvent) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) setMouseState(undefined) } else { setViewXScale(undefined) setMouseState(undefined) } }) const onMouseOver = useEvent((ev: React.PointerEvent) => { if (ev.pointerType === 'mouse') { const [mouseX, mouseY] = pointer(ev) const queryX = xScale.invert(mouseX) const [_x, ys] = data[xBisector.left(data, queryX) - 1] setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] }) } }) const onMouseLeave = useEvent(() => { 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 (
{mouseState && ( {fmtX(mouseState.p[0])} )} {series.map((s, i) => ( ))}
) } export const SingleValueHistoryChart = (props: { data: HistoryPoint[] w: number h: number color: string xScale: ScaleTime yScale: ScaleContinuousNumeric pct?: boolean }) => { const { color, data, pct, yScale, w, h } = props const [viewXScale, setViewXScale] = useState>() const [mouseState, setMouseState] = useState>() const xScale = viewXScale ?? props.xScale const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) const py0 = yScale(yScale.domain()[0]) const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) const xBisector = bisector((p: HistoryPoint) => p[0]) 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 [min, max] = yScale.domain() const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const xAxis = axisBottom(xScale).ticks(w / 100) const yAxis = pct ? axisLeft(yScale).tickValues(pctTickValues).tickFormat(fmtY) : axisLeft(yScale) return { fmtX, fmtY, xAxis, yAxis } }, [w, h, pct, xScale, yScale]) const onSelect = useEvent((ev: D3BrushEvent) => { if (ev.selection) { const [mouseX0, mouseX1] = ev.selection as [number, number] setViewXScale(() => xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) ) setMouseState(undefined) } else { setViewXScale(undefined) setMouseState(undefined) } }) const onMouseOver = useEvent((ev: React.PointerEvent) => { if (ev.pointerType === 'mouse') { const [mouseX, mouseY] = pointer(ev) const queryX = xScale.invert(mouseX) const [_x, y] = data[xBisector.left(data, queryX) - 1] setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) } }) const onMouseLeave = useEvent(() => { setMouseState(undefined) }) return (
{mouseState && ( {fmtY(mouseState.p[1])} {fmtX(mouseState.p[0])} )}
) }