manifold/web/components/charts/generic-charts.tsx

397 lines
11 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react'
import { bisector } from 'd3-array'
import { axisBottom, axisLeft } from 'd3-axis'
import { D3BrushEvent } from 'd3-brush'
import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale'
import {
CurveFactory,
SeriesPoint,
curveLinear,
curveStepBefore,
curveStepAfter,
stack,
stackOrderReverse,
} from 'd3-shape'
import { range } from 'lodash'
import {
ContinuousScale,
Margin,
SVGChart,
AreaPath,
AreaWithTopStroke,
Point,
SliceMarker,
TooltipParams,
TooltipComponent,
computeColorStops,
formatPct,
} from './helpers'
import { useEvent } from 'web/hooks/use-event'
import { formatMoney } from 'common/util/format'
import { nanoid } from 'nanoid'
export type MultiPoint<T = unknown> = Point<Date, number[], T>
export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T>
export type ValueKind = 'm$' | 'percent' | 'amount'
type SliceExtent = { y0: number; y1: number }
const interpolateY = (
curve: CurveFactory,
x: number,
x0: number,
x1: number,
y0: number,
y1: number
) => {
if (curve === curveLinear) {
const p = (x - x0) / (x1 - x0)
return y0 * (1 - p) + y1 * p
} else if (curve === curveStepAfter) {
return y0
} else if (curve === curveStepBefore) {
return y1
}
}
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]
}
const dataAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[],
xScale: ContinuousScale<X>
) => {
const bisect = bisector((p: P) => p.x)
return (posX: number) => {
const x = xScale.invert(posX)
const i = bisect.left(data, x)
const prev = data[i - 1] as P | undefined
const next = data[i] as P | undefined
return { prev, next, x: posX }
}
}
export const DistributionChart = <P extends DistributionPoint>(props: {
data: P[]
w: number
h: number
color: string
margin: Margin
xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P>
}) => {
const { data, w, h, color, margin, yScale, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0])
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const xAxis = axisBottom<number>(xScale).ticks(w / 100)
const yAxis = axisLeft<number>(yScale).tickFormat((n) => formatPct(n, 2))
return { xAxis, yAxis }
}, [w, xScale, yScale])
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
<AreaWithTopStroke
color={color}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
</SVGChart>
)
}
export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
data: P[]
w: number
h: number
colors: readonly string[]
margin: Margin
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
}) => {
const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
type SP = SeriesPoint<P>
const px = useCallback((p: SP) => xScale(p.data.x), [xScale])
const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis =
yKind === 'percent'
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis }
}, [w, h, yKind, xScale, yScale])
const series = useMemo(() => {
const d3Stack = stack<P, number>()
.keys(range(0, Math.max(...data.map(({ y }) => y.length))))
.value(({ y }, k) => y[k])
.order(stackOrderReverse)
return d3Stack(data)
}, [data])
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
)
} else {
setViewXScale(undefined)
}
})
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
{series.map((s, i) => (
<AreaPath
key={i}
data={s}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
fill={colors[i]}
/>
))}
</SVGChart>
)
}
export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
data: P[]
w: number
h: number
color: string | ((p: P) => string)
margin: Margin
xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => {
const { data, w, h, color, margin, yScale, yKind, Tooltip } = props
const curve = props.curve ?? curveLinear
const [mouse, setMouse] = useState<TooltipParams<P> & SliceExtent>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(0)
const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain()
const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis =
yKind === 'percent'
? axisLeft<number>(yScale)
.tickValues(pctTickValues)
.tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis }
}, [w, h, yKind, xScale, yScale])
const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
const x0 = p.prev ? xScale(p.prev.x) : xScale.range()[0]
const x1 = p.next ? xScale(p.next.x) : xScale.range()[1]
const y0 = p.prev ? yScale(p.prev.y) : yScale.range()[0]
const y1 = p.next ? yScale(p.next.y) : yScale.range()[1]
const markerY = interpolateY(curve, mouseX, x0, x1, y0, y1)
if (p.prev && markerY) {
setMouse({
x: mouseX,
y: markerY,
y0: py0,
y1: markerY,
data: p.prev,
})
} else {
setMouse(undefined)
}
})
const onMouseLeave = useEvent(() => setMouse(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number]
const newViewXScale = xScale
.copy()
.domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
setViewXScale(() => newViewXScale)
const dataInView = data.filter((p) => {
const x = newViewXScale(p.x)
return x >= 0 && x <= w
})
const yMin = Math.min(...dataInView.map((p) => p.y))
const yMax = Math.max(...dataInView.map((p) => p.y))
// Prevents very small selections from being too zoomed in
if (yMax - yMin > 0.05) {
// adds a little padding to the top and bottom of the selection
yScale.domain([yMin - (yMax - yMin) * 0.1, yMax + (yMax - yMin) * 0.1])
}
} else {
setViewXScale(undefined)
yScale.domain([0, 1])
}
})
const gradientId = useMemo(() => nanoid(), [])
const stops = useMemo(
() =>
typeof color !== 'string' ? computeColorStops(data, color, px) : null,
[color, data, px]
)
return (
<SVGChart
w={w}
h={h}
margin={margin}
xAxis={xAxis}
yAxis={yAxis}
ttParams={
mouse ? { x: mouse.x, y: mouse.y, data: mouse.data } : undefined
}
onSelect={onSelect}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip}
>
{stops && (
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id={gradientId}>
{stops.map((s, i) => (
<stop key={i} offset={`${s.x / w}`} stopColor={s.color} />
))}
</linearGradient>
</defs>
)}
<AreaWithTopStroke
color={typeof color === 'string' ? color : `url(#${gradientId})`}
data={data}
px={px}
py0={py0}
py1={py1}
curve={curve ?? curveLinear}
/>
{mouse && (
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
)}
</SVGChart>
)
}