import { useCallback, useMemo, useState } from 'react'
import {
axisBottom,
axisLeft,
bisector,
curveLinear,
curveStepAfter,
pointer,
stack,
stackOrderReverse,
D3BrushEvent,
ScaleTime,
ScaleContinuousNumeric,
SeriesPoint,
} from 'd3'
import { range } 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'
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]
}
type LegendItem = { color: string; label: string; value?: string }
const Legend = (props: { className?: string; items: LegendItem[] }) => {
const { items, className } = props
return (
{items.map((item) => (
-
{item.label}
{item.value}
))}
)
}
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 [xScale, setXScale] = useState(() => props.xScale)
const [mouseState, setMouseState] =
useState>()
const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale])
const py0 = yScale(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).tickFormat(fmtX)
const yAxis = axisLeft(yScale).tickFormat(fmtY)
return { fmtX, fmtY, xAxis, yAxis }
}, [xScale, yScale])
const onSelect = useEvent((ev: D3BrushEvent) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
setMouseState(undefined)
} else {
setXScale(() => props.xScale)
setMouseState(undefined)
}
})
const onMouseOver = useEvent((event: React.PointerEvent) => {
const [mouseX, mouseY] = pointer(event)
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
// note that we have to type this funkily in order to succesfully store
// a function inside of useState
const [xScale, setXScale] = useState(() => props.xScale)
const [mouseState, setMouseState] = useState>()
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, series } = 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 tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom(xScale)
const yAxis = axisLeft(yScale)
.tickValues(tickValues)
.tickFormat(fmtY)
const d3Stack = stack()
.keys(range(0, labels.length))
.value(([_date, probs], o) => probs[o])
.order(stackOrderReverse)
const series = d3Stack(data)
return { fmtX, fmtY, xAxis, yAxis, series }
}, [h, pct, xScale, yScale, data, labels.length])
const onSelect = useEvent((ev: D3BrushEvent) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
setMouseState(undefined)
} else {
setXScale(() => props.xScale)
setMouseState(undefined)
}
})
const onMouseOver = useEvent((event: React.PointerEvent) => {
const [mouseX, mouseY] = pointer(event)
const queryX = xScale.invert(mouseX)
const [_x, ys] = data[xBisector.center(data, queryX)]
setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] })
})
const onMouseLeave = useEvent(() => {
setMouseState(undefined)
})
return (
{mouseState && (
{fmtX(mouseState.p[0])}
)}
{series.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, pct, yScale, w, h } = props
// note that we have to type this funkily in order to succesfully store
// a function inside of useState
const [xScale, setXScale] = useState(() => props.xScale)
const [mouseState, setMouseState] = useState>()
const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale])
const py0 = yScale(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 tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom(xScale)
const yAxis = axisLeft(yScale)
.tickValues(tickValues)
.tickFormat(fmtY)
return { fmtX, fmtY, xAxis, yAxis }
}, [h, pct, xScale, yScale])
const onSelect = useEvent((ev: D3BrushEvent) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
setMouseState(undefined)
} else {
setXScale(() => props.xScale)
setMouseState(undefined)
}
})
const onMouseOver = useEvent((ev: React.PointerEvent) => {
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])}
)}
)
}