import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' import { pointer, select } from 'd3-selection' import { Axis, AxisScale } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' import { area, line, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' import dayjs from 'dayjs' import clsx from 'clsx' import { Contract } from 'common/contract' import { useMeasureSize } from 'web/hooks/use-measure-size' import { useIsMobile } from 'web/hooks/use-is-mobile' export type Point = { x: X; y: Y; obj?: T } export interface ContinuousScale extends AxisScale { invert(n: number): T } export type XScale

= P extends Point ? AxisScale : never export type YScale

= P extends Point ? AxisScale : never export type Margin = { top: number right: number bottom: number left: number } export const XAxis = (props: { w: number; h: number; axis: Axis }) => { const { h, axis } = props const axisRef = useRef(null) useEffect(() => { if (axisRef.current != null) { select(axisRef.current) .transition() .duration(250) .call(axis) .select('.domain') .attr('stroke-width', 0) } }, [h, axis]) return } export const YAxis = (props: { w: number; h: number; axis: Axis }) => { const { w, h, axis } = props const axisRef = useRef(null) useEffect(() => { if (axisRef.current != null) { select(axisRef.current) .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 } const LinePathInternal = ( props: { data: P[] px: number | ((p: P) => number) py: number | ((p: P) => number) curve: CurveFactory } & SVGProps ) => { const { data, px, py, curve, ...rest } = props const d3Line = line

(px, py).curve(curve) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return } export const LinePath = memo(LinePathInternal) as typeof LinePathInternal const AreaPathInternal = ( props: { data: P[] px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) curve: CurveFactory } & SVGProps ) => { const { data, px, py0, py1, curve, ...rest } = props const d3Area = area

(px, py0, py1).curve(curve) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return } export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal export const AreaWithTopStroke = (props: { data: P[] color: string px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) curve: CurveFactory }) => { const { data, color, px, py0, py1, curve } = props return ( ) } export const SliceMarker = (props: { color: string x: number y0: number y1: number }) => { const { color, x, y0, y1 } = props return ( ) } export const SVGChart = (props: { children: ReactNode w: number h: number margin: Margin xAxis: Axis yAxis: Axis ttParams: TooltipParams | undefined onSelect?: (ev: D3BrushEvent) => void onMouseOver?: (mouseX: number, mouseY: number) => void onMouseLeave?: () => void Tooltip?: TooltipComponent }) => { const { children, w, h, margin, xAxis, yAxis, ttParams, onSelect, onMouseOver, onMouseLeave, Tooltip, } = props const tooltipMeasure = useMeasureSize() const overlayRef = useRef(null) const innerW = w - (margin.left + margin.right) const innerH = h - (margin.top + margin.bottom) const clipPathId = useMemo(() => nanoid(), []) const isMobile = useIsMobile() 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) onMouseLeave?.() if (overlayRef.current) { select(overlayRef.current).call(brush.clear) } } else { justSelected.current = false } }) // mqp: shape-rendering null overrides the default d3-brush shape-rendering // of `crisp-edges`, which seems to cause graphical glitches on Chrome // (i.e. the bug where the area fill flickers white) select(overlayRef.current) .call(brush) .select('.selection') .attr('shape-rendering', 'null') } }, [innerW, innerH, onSelect, onMouseLeave]) const onPointerMove = (ev: React.PointerEvent) => { if (ev.pointerType === 'mouse' && onMouseOver) { const [x, y] = pointer(ev) onMouseOver(x, y) } } const onTouchMove = (ev: React.TouchEvent) => { if (onMouseOver) { const touch = ev.touches[0] const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top onMouseOver(x, y) } } const onPointerLeave = () => { onMouseLeave?.() } return (

{ttParams && Tooltip && ( )} {children} {!isMobile ? ( ) : ( )}
) } export type TooltipPosition = { left: number; bottom: number } export const getTooltipPosition = ( mouseX: number, mouseY: number, containerWidth: number, containerHeight: number, tooltipWidth: number, tooltipHeight: number, isMobile: boolean ) => { let left = mouseX + 12 let bottom = !isMobile ? containerHeight - mouseY + 12 : containerHeight - tooltipHeight + 12 if (tooltipWidth != null) { const overflow = left + tooltipWidth - containerWidth if (overflow > 0) { left -= overflow } } if (tooltipHeight != null) { const overflow = tooltipHeight - mouseY if (overflow > 0) { bottom -= overflow } } return { left, bottom } } export type TooltipParams = { x: number; y: number; data: T } export type TooltipProps = TooltipParams & { xScale: ContinuousScale } export type TooltipComponent = React.ComponentType> export const TooltipContainer = (props: { setElem: (e: HTMLElement | null) => void pos: TooltipPosition margin: Margin className?: string children: React.ReactNode }) => { const { setElem, pos, margin, className, children } = props return (
{children}
) } export const computeColorStops = ( data: P[], pc: (p: P) => string, px: (p: P) => number ) => { const segments: { x: number; color: string }[] = [] let currOffset = 0 let currColor = pc(data[0]) for (const p of data) { const c = pc(p) if (c !== currColor) { segments.push({ x: currOffset, color: currColor }) currOffset = px(p) currColor = c } } segments.push({ x: currOffset, color: currColor }) const stops: { x: number; color: string }[] = [] stops.push({ x: segments[0].x, color: segments[0].color }) for (const s of segments.slice(1)) { stops.push({ x: s.x, color: stops[stops.length - 1].color }) stops.push({ x: s.x, color: s.color }) } return stops } export const getDateRange = (contract: Contract) => { const { createdTime, closeTime, resolutionTime } = contract const isClosed = !!closeTime && Date.now() > closeTime const endDate = resolutionTime ?? (isClosed ? closeTime : null) return [createdTime, endDate ?? null] as const } export const getRightmostVisibleDate = ( contractEnd: number | null | undefined, lastActivity: number | null | undefined, now: number ) => { if (contractEnd != null) { return contractEnd } else if (lastActivity != null) { // client-DB clock divergence may cause last activity to be later than now return Math.max(lastActivity, now) } else { return now } } export const formatPct = (n: number, digits?: number) => { return `${(n * 100).toFixed(digits ?? 0)}%` } export 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) } } export const formatDateInRange = (d: Date, 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 formatDate(d, opts) }