208 lines
5.8 KiB
TypeScript
208 lines
5.8 KiB
TypeScript
|
import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
|
||
|
import {
|
||
|
Axis,
|
||
|
CurveFactory,
|
||
|
D3BrushEvent,
|
||
|
area,
|
||
|
brushX,
|
||
|
curveStepAfter,
|
||
|
line,
|
||
|
select,
|
||
|
} from 'd3'
|
||
|
import { nanoid } from 'nanoid'
|
||
|
import clsx from 'clsx'
|
||
|
|
||
|
import { Contract } from 'common/contract'
|
||
|
|
||
|
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
|
||
|
export const MARGIN_X = MARGIN.right + MARGIN.left
|
||
|
export const MARGIN_Y = MARGIN.top + MARGIN.bottom
|
||
|
|
||
|
export const MAX_TIMESTAMP = 8640000000000000
|
||
|
export const MAX_DATE = new Date(MAX_TIMESTAMP)
|
||
|
|
||
|
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
|
||
|
const { h, axis } = props
|
||
|
const axisRef = useRef<SVGGElement>(null)
|
||
|
useEffect(() => {
|
||
|
if (axisRef.current != null) {
|
||
|
select(axisRef.current)
|
||
|
.transition()
|
||
|
.duration(250)
|
||
|
.call(axis)
|
||
|
.select('.domain')
|
||
|
.attr('stroke-width', 0)
|
||
|
}
|
||
|
}, [h, axis])
|
||
|
return <g ref={axisRef} transform={`translate(0, ${h})`} />
|
||
|
}
|
||
|
|
||
|
export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
|
||
|
const { w, h, axis } = props
|
||
|
const axisRef = useRef<SVGGElement>(null)
|
||
|
useEffect(() => {
|
||
|
if (axisRef.current != null) {
|
||
|
select(axisRef.current)
|
||
|
.transition()
|
||
|
.duration(250)
|
||
|
.call(axis)
|
||
|
.call((g) =>
|
||
|
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
|
||
|
)
|
||
|
.select('.domain')
|
||
|
.attr('stroke-width', 0)
|
||
|
}
|
||
|
}, [w, h, axis])
|
||
|
return <g ref={axisRef} />
|
||
|
}
|
||
|
|
||
|
const LinePathInternal = <P,>(
|
||
|
props: {
|
||
|
data: P[]
|
||
|
px: number | ((p: P) => number)
|
||
|
py: number | ((p: P) => number)
|
||
|
curve?: CurveFactory
|
||
|
} & SVGProps<SVGPathElement>
|
||
|
) => {
|
||
|
const { data, px, py, curve, ...rest } = props
|
||
|
const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter)
|
||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
|
return <path {...rest} fill="none" d={d3Line(data)!} />
|
||
|
}
|
||
|
export const LinePath = memo(LinePathInternal) as typeof LinePathInternal
|
||
|
|
||
|
const AreaPathInternal = <P,>(
|
||
|
props: {
|
||
|
data: P[]
|
||
|
px: number | ((p: P) => number)
|
||
|
py0: number | ((p: P) => number)
|
||
|
py1: number | ((p: P) => number)
|
||
|
curve?: CurveFactory
|
||
|
} & SVGProps<SVGPathElement>
|
||
|
) => {
|
||
|
const { data, px, py0, py1, curve, ...rest } = props
|
||
|
const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter)
|
||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||
|
return <path {...rest} d={d3Area(data)!} />
|
||
|
}
|
||
|
export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
|
||
|
|
||
|
export const AreaWithTopStroke = <P,>(props: {
|
||
|
color: string
|
||
|
data: P[]
|
||
|
px: number | ((p: P) => number)
|
||
|
py0: number | ((p: P) => number)
|
||
|
py1: number | ((p: P) => number)
|
||
|
curve?: CurveFactory
|
||
|
}) => {
|
||
|
const { color, data, px, py0, py1, curve } = props
|
||
|
return (
|
||
|
<g>
|
||
|
<AreaPath
|
||
|
data={data}
|
||
|
px={px}
|
||
|
py0={py0}
|
||
|
py1={py1}
|
||
|
curve={curve}
|
||
|
fill={color}
|
||
|
opacity={0.3}
|
||
|
/>
|
||
|
<LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
|
||
|
</g>
|
||
|
)
|
||
|
}
|
||
|
|
||
|
export const SVGChart = <X, Y>(props: {
|
||
|
children: ReactNode
|
||
|
w: number
|
||
|
h: number
|
||
|
xAxis: Axis<X>
|
||
|
yAxis: Axis<Y>
|
||
|
onSelect?: (ev: D3BrushEvent<any>) => void
|
||
|
onMouseOver?: (ev: React.PointerEvent) => void
|
||
|
onMouseLeave?: (ev: React.PointerEvent) => void
|
||
|
pct?: boolean
|
||
|
}) => {
|
||
|
const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } =
|
||
|
props
|
||
|
const overlayRef = useRef<SVGGElement>(null)
|
||
|
const innerW = w - MARGIN_X
|
||
|
const innerH = h - MARGIN_Y
|
||
|
const clipPathId = useMemo(() => nanoid(), [])
|
||
|
|
||
|
const justSelected = useRef(false)
|
||
|
useEffect(() => {
|
||
|
if (onSelect != null && overlayRef.current) {
|
||
|
const brush = brushX().extent([
|
||
|
[0, 0],
|
||
|
[innerW, innerH],
|
||
|
])
|
||
|
brush.on('end', (ev) => {
|
||
|
// when we clear the brush after a selection, that would normally cause
|
||
|
// another 'end' event, so we have to suppress it with this flag
|
||
|
if (!justSelected.current) {
|
||
|
justSelected.current = true
|
||
|
onSelect(ev)
|
||
|
if (overlayRef.current) {
|
||
|
select(overlayRef.current).call(brush.clear)
|
||
|
}
|
||
|
} else {
|
||
|
justSelected.current = false
|
||
|
}
|
||
|
})
|
||
|
select(overlayRef.current).call(brush)
|
||
|
}
|
||
|
}, [innerW, innerH, onSelect])
|
||
|
|
||
|
return (
|
||
|
<svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
||
|
<clipPath id={clipPathId}>
|
||
|
<rect x={0} y={0} width={innerW} height={innerH} />
|
||
|
</clipPath>
|
||
|
<g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}>
|
||
|
<XAxis axis={xAxis} w={innerW} h={innerH} />
|
||
|
<YAxis axis={yAxis} w={innerW} h={innerH} />
|
||
|
<g clipPath={`url(#${clipPathId})`}>{children}</g>
|
||
|
<g
|
||
|
ref={overlayRef}
|
||
|
x="0"
|
||
|
y="0"
|
||
|
width={innerW}
|
||
|
height={innerH}
|
||
|
fill="none"
|
||
|
pointerEvents="all"
|
||
|
onPointerEnter={onMouseOver}
|
||
|
onPointerMove={onMouseOver}
|
||
|
onPointerLeave={onMouseLeave}
|
||
|
/>
|
||
|
</g>
|
||
|
</svg>
|
||
|
)
|
||
|
}
|
||
|
|
||
|
export type TooltipPosition = { top: number; left: number }
|
||
|
|
||
|
export const ChartTooltip = (
|
||
|
props: TooltipPosition & { className?: string; children: React.ReactNode }
|
||
|
) => {
|
||
|
const { top, left, className, children } = props
|
||
|
return (
|
||
|
<div
|
||
|
className={clsx(
|
||
|
className,
|
||
|
'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2'
|
||
|
)}
|
||
|
style={{ top, left }}
|
||
|
>
|
||
|
{children}
|
||
|
</div>
|
||
|
)
|
||
|
}
|
||
|
|
||
|
export const getDateRange = (contract: Contract) => {
|
||
|
const { createdTime, closeTime, resolutionTime } = contract
|
||
|
const isClosed = !!closeTime && Date.now() > closeTime
|
||
|
const endDate = resolutionTime ?? (isClosed ? closeTime : null)
|
||
|
return [new Date(createdTime), endDate ? new Date(endDate) : null] as const
|
||
|
}
|