Implement basic time selection

This commit is contained in:
Marshall Polaris 2022-09-26 23:13:23 -07:00
parent 6f69ecd087
commit bf3c65bd6c
2 changed files with 119 additions and 15 deletions

View File

@ -8,6 +8,7 @@ import {
pointer, pointer,
stack, stack,
stackOrderReverse, stackOrderReverse,
D3BrushEvent,
ScaleTime, ScaleTime,
ScaleContinuousNumeric, ScaleContinuousNumeric,
SeriesPoint, SeriesPoint,
@ -105,7 +106,11 @@ export const SingleValueDistributionChart = (props: {
xScale: ScaleContinuousNumeric<number, number> xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
}) => { }) => {
const { color, data, xScale, yScale, w, h } = props 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] = const [mouseState, setMouseState] =
useState<PositionValue<DistributionPoint>>() useState<PositionValue<DistributionPoint>>()
@ -122,6 +127,19 @@ export const SingleValueDistributionChart = (props: {
return { fmtX, fmtY, xAxis, yAxis } return { fmtX, fmtY, xAxis, yAxis }
}, [xScale, yScale]) }, [xScale, yScale])
const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint>) => {
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 onMouseOver = useEvent((event: React.PointerEvent) => {
const [mouseX, mouseY] = pointer(event) const [mouseX, mouseY] = pointer(event)
const queryX = xScale.invert(mouseX) const queryX = xScale.invert(mouseX)
@ -145,6 +163,7 @@ export const SingleValueDistributionChart = (props: {
h={h} h={h}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
@ -171,7 +190,11 @@ export const MultiValueHistoryChart = (props: {
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
pct?: boolean pct?: boolean
}) => { }) => {
const { colors, data, xScale, yScale, labels, w, h, pct } = props 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<PositionValue<MultiPoint>>() const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>()
type SP = SeriesPoint<MultiPoint> type SP = SeriesPoint<MultiPoint>
@ -187,7 +210,7 @@ export const MultiValueHistoryChart = (props: {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) const tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).tickFormat(fmtX) const xAxis = axisBottom<Date>(xScale)
const yAxis = axisLeft<number>(yScale) const yAxis = axisLeft<number>(yScale)
.tickValues(tickValues) .tickValues(tickValues)
.tickFormat(fmtY) .tickFormat(fmtY)
@ -200,6 +223,19 @@ export const MultiValueHistoryChart = (props: {
return { fmtX, fmtY, xAxis, yAxis, series } return { fmtX, fmtY, xAxis, yAxis, series }
}, [h, pct, xScale, yScale, data, labels.length]) }, [h, pct, xScale, yScale, data, labels.length])
const onSelect = useEvent((ev: D3BrushEvent<MultiPoint>) => {
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 onMouseOver = useEvent((event: React.PointerEvent) => {
const [mouseX, mouseY] = pointer(event) const [mouseX, mouseY] = pointer(event)
const queryX = xScale.invert(mouseX) const queryX = xScale.invert(mouseX)
@ -231,6 +267,7 @@ export const MultiValueHistoryChart = (props: {
h={h} h={h}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
@ -259,12 +296,17 @@ export const SingleValueHistoryChart = (props: {
yScale: d3.ScaleContinuousNumeric<number, number> yScale: d3.ScaleContinuousNumeric<number, number>
pct?: boolean pct?: boolean
}) => { }) => {
const { color, data, xScale, yScale, pct, w, h } = props 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<PositionValue<HistoryPoint>>() const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>()
const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale]) const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale])
const py0 = yScale(0) const py0 = yScale(0)
const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale]) const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale])
const xBisector = bisector((p: HistoryPoint) => p[0])
const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => { const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
@ -273,16 +315,28 @@ export const SingleValueHistoryChart = (props: {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) const tickValues = getTickValues(min, max, h < 200 ? 3 : 5)
const xAxis = axisBottom<Date>(xScale).tickFormat(fmtX) const xAxis = axisBottom<Date>(xScale)
const yAxis = axisLeft<number>(yScale) const yAxis = axisLeft<number>(yScale)
.tickValues(tickValues) .tickValues(tickValues)
.tickFormat(fmtY) .tickFormat(fmtY)
return { fmtX, fmtY, xAxis, yAxis } return { fmtX, fmtY, xAxis, yAxis }
}, [h, pct, xScale, yScale]) }, [h, pct, xScale, yScale])
const xBisector = bisector((p: HistoryPoint) => p[0]) const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint>) => {
const onMouseOver = useEvent((event: React.PointerEvent) => { if (ev.selection) {
const [mouseX, mouseY] = pointer(event) 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 queryX = xScale.invert(mouseX)
const [_x, y] = data[xBisector.center(data, queryX)] const [_x, y] = data[xBisector.center(data, queryX)]
setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] }) setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
@ -304,6 +358,7 @@ export const SingleValueHistoryChart = (props: {
h={h} h={h}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >

View File

@ -1,6 +1,16 @@
import { ReactNode, SVGProps, memo, useRef, useEffect } from 'react' import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
import { Axis, CurveFactory, area, curveStepAfter, line, select } from 'd3' import {
Axis,
CurveFactory,
D3BrushEvent,
area,
brushX,
curveStepAfter,
line,
select,
} from 'd3'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { nanoid } from 'nanoid'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
@ -14,8 +24,11 @@ export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
useEffect(() => { useEffect(() => {
if (axisRef.current != null) { if (axisRef.current != null) {
select(axisRef.current) select(axisRef.current)
.transition()
.duration(250)
.call(axis) .call(axis)
.call((g) => g.select('.domain').remove()) .select('.domain')
.attr('stroke-width', 0)
} }
}, [h, axis]) }, [h, axis])
return <g ref={axisRef} transform={`translate(0, ${h})`} /> return <g ref={axisRef} transform={`translate(0, ${h})`} />
@ -27,11 +40,14 @@ export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
useEffect(() => { useEffect(() => {
if (axisRef.current != null) { if (axisRef.current != null) {
select(axisRef.current) select(axisRef.current)
.transition()
.duration(250)
.call(axis) .call(axis)
.call((g) => g.select('.domain').remove())
.call((g) => .call((g) =>
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
) )
.select('.domain')
.attr('stroke-width', 0)
} }
}, [w, h, axis]) }, [w, h, axis])
return <g ref={axisRef} /> return <g ref={axisRef} />
@ -99,20 +115,53 @@ export const SVGChart = <X, Y>(props: {
h: number h: number
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<Y> yAxis: Axis<Y>
onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (ev: React.PointerEvent) => void onMouseOver?: (ev: React.PointerEvent) => void
onMouseLeave?: (ev: React.PointerEvent) => void onMouseLeave?: (ev: React.PointerEvent) => void
pct?: boolean pct?: boolean
}) => { }) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave } = props const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } =
props
const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X const innerW = w - MARGIN_X
const innerH = h - MARGIN_Y 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 ( return (
<svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}> <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})`}> <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}>
<XAxis axis={xAxis} w={innerW} h={innerH} /> <XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} /> <YAxis axis={yAxis} w={innerW} h={innerH} />
{children} <g clipPath={`url(#${clipPathId})`}>{children}</g>
<rect <g
ref={overlayRef}
x="0" x="0"
y="0" y="0"
width={innerW} width={innerW}