From bf3c65bd6c17da41baac7ea1ce3ae7e23812fd39 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Mon, 26 Sep 2022 23:13:23 -0700 Subject: [PATCH] Implement basic time selection --- web/components/charts/generic-charts.tsx | 71 +++++++++++++++++++++--- web/components/charts/helpers.tsx | 63 ++++++++++++++++++--- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx index 897f2a40..c7b21c21 100644 --- a/web/components/charts/generic-charts.tsx +++ b/web/components/charts/generic-charts.tsx @@ -8,6 +8,7 @@ import { pointer, stack, stackOrderReverse, + D3BrushEvent, ScaleTime, ScaleContinuousNumeric, SeriesPoint, @@ -105,7 +106,11 @@ export const SingleValueDistributionChart = (props: { xScale: ScaleContinuousNumeric yScale: ScaleContinuousNumeric }) => { - 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] = useState>() @@ -122,6 +127,19 @@ export const SingleValueDistributionChart = (props: { 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) @@ -145,6 +163,7 @@ export const SingleValueDistributionChart = (props: { h={h} xAxis={xAxis} yAxis={yAxis} + onSelect={onSelect} onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} > @@ -171,7 +190,11 @@ export const MultiValueHistoryChart = (props: { yScale: ScaleContinuousNumeric 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>() type SP = SeriesPoint @@ -187,7 +210,7 @@ export const MultiValueHistoryChart = (props: { const [min, max] = yScale.domain() const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) - const xAxis = axisBottom(xScale).tickFormat(fmtX) + const xAxis = axisBottom(xScale) const yAxis = axisLeft(yScale) .tickValues(tickValues) .tickFormat(fmtY) @@ -200,6 +223,19 @@ export const MultiValueHistoryChart = (props: { 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) @@ -231,6 +267,7 @@ export const MultiValueHistoryChart = (props: { h={h} xAxis={xAxis} yAxis={yAxis} + onSelect={onSelect} onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} > @@ -259,12 +296,17 @@ export const SingleValueHistoryChart = (props: { yScale: d3.ScaleContinuousNumeric 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>() 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() @@ -273,16 +315,28 @@ export const SingleValueHistoryChart = (props: { const [min, max] = yScale.domain() const tickValues = getTickValues(min, max, h < 200 ? 3 : 5) - const xAxis = axisBottom(xScale).tickFormat(fmtX) + const xAxis = axisBottom(xScale) const yAxis = axisLeft(yScale) .tickValues(tickValues) .tickFormat(fmtY) return { fmtX, fmtY, xAxis, yAxis } }, [h, pct, xScale, yScale]) - const xBisector = bisector((p: HistoryPoint) => p[0]) - const onMouseOver = useEvent((event: React.PointerEvent) => { - const [mouseX, mouseY] = pointer(event) + 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] }) @@ -304,6 +358,7 @@ export const SingleValueHistoryChart = (props: { h={h} xAxis={xAxis} yAxis={yAxis} + onSelect={onSelect} onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} > diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index 56b51077..a330a4ff 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -1,6 +1,16 @@ -import { ReactNode, SVGProps, memo, useRef, useEffect } from 'react' -import { Axis, CurveFactory, area, curveStepAfter, line, select } from 'd3' +import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react' +import { + Axis, + CurveFactory, + D3BrushEvent, + area, + brushX, + curveStepAfter, + line, + select, +} from 'd3' import dayjs from 'dayjs' +import { nanoid } from 'nanoid' import { Contract } from 'common/contract' @@ -14,8 +24,11 @@ export const XAxis = (props: { w: number; h: number; axis: Axis }) => { useEffect(() => { if (axisRef.current != null) { select(axisRef.current) + .transition() + .duration(250) .call(axis) - .call((g) => g.select('.domain').remove()) + .select('.domain') + .attr('stroke-width', 0) } }, [h, axis]) return @@ -27,11 +40,14 @@ export const YAxis = (props: { w: number; h: number; axis: Axis }) => { useEffect(() => { if (axisRef.current != null) { select(axisRef.current) + .transition() + .duration(250) .call(axis) - .call((g) => g.select('.domain').remove()) .call((g) => g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) ) + .select('.domain') + .attr('stroke-width', 0) } }, [w, h, axis]) return @@ -99,20 +115,53 @@ export const SVGChart = (props: { h: number xAxis: Axis yAxis: Axis + onSelect?: (ev: D3BrushEvent) => void onMouseOver?: (ev: React.PointerEvent) => void onMouseLeave?: (ev: React.PointerEvent) => void pct?: boolean }) => { - const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave } = props + const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } = + props + const overlayRef = useRef(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 ( + + + - {children} - {children} +