From 9a1c50d06176901be80b9d3b74d2d46e0c516d15 Mon Sep 17 00:00:00 2001 From: eduardo aleixo Date: Wed, 18 Jan 2023 14:08:15 +0000 Subject: [PATCH] feat(webapp): sync crosshair in different timelines --- .../TimelineChart/CrosshairSync.plugin.ts | 88 +++++++++++++++++++ .../TimelineChart/TimelineChart.tsx | 3 +- .../TimelineChart/TimelineChartWrapper.tsx | 10 ++- .../pages/ContinuousComparisonView.tsx | 12 +++ .../javascript/pages/ContinuousDiffView.tsx | 12 +++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 webapp/javascript/components/TimelineChart/CrosshairSync.plugin.ts diff --git a/webapp/javascript/components/TimelineChart/CrosshairSync.plugin.ts b/webapp/javascript/components/TimelineChart/CrosshairSync.plugin.ts new file mode 100644 index 0000000000..9685962e00 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/CrosshairSync.plugin.ts @@ -0,0 +1,88 @@ +const defaultOptions = { + syncCrosshairsWith: [], +}; + +// Enhances the default Plot object with the API from the crosshair plugin +// https://github.com/flot/flot/blob/de34ce947d8ebfb2cac0b682a130ba079d8d654b/source/jquery.flot.crosshair.js#L74 +type PlotWithCrosshairsSupport = jquery.flot.plot & + jquery.flot.plotOptions & { + setCrosshair(pos: { x: number; y: number }): void; + clearCrosshair(): void; + }; + +(function ($) { + function init(plot: PlotWithCrosshairsSupport) { + function getOptions() { + return plot.getOptions() as jquery.flot.plotOptions & { + syncCrosshairsWith: typeof defaultOptions['syncCrosshairsWith']; + }; + } + + function accessExternalInstance(id: string) { + // Access another flotjs instance + // https://github.com/flot/flot/blob/de34ce947d8ebfb2cac0b682a130ba079d8d654b/source/jquery.flot.js#L969 + const p: PlotWithCrosshairsSupport = $(`#${id}`).data('plot'); + return p; + } + + function onPlotHover( + syncCrosshairsWith: typeof defaultOptions['syncCrosshairsWith'], + e: unknown, + position: { x: number; y: number } + ) { + syncCrosshairsWith.forEach((id) => + accessExternalInstance(id).setCrosshair(position) + ); + } + + function clearCrosshairs( + syncCrosshairsWith: typeof defaultOptions['syncCrosshairsWith'] + ) { + syncCrosshairsWith.forEach((id) => + accessExternalInstance(id).clearCrosshair() + ); + } + + plot.hooks!.bindEvents!.push(() => { + const options = getOptions(); + + plot + .getPlaceholder() + .bind('plothover', onPlotHover.bind(null, options.syncCrosshairsWith)); + + plot + .getPlaceholder() + .bind( + 'mouseleave', + clearCrosshairs.bind(null, options.syncCrosshairsWith) + ); + }); + + plot.hooks!.shutdown!.push(() => { + const options = getOptions(); + + clearCrosshairs(options.syncCrosshairsWith); + + plot + .getPlaceholder() + .bind( + 'mouseleave', + clearCrosshairs.bind(null, options.syncCrosshairsWith) + ); + + plot + .getPlaceholder() + .unbind( + 'plothover', + onPlotHover.bind(null, options.syncCrosshairsWith) + ); + }); + } + + $.plot.plugins.push({ + init, + options: defaultOptions, + name: 'crosshair-sync', + version: '1.0', + }); +})(jQuery); diff --git a/webapp/javascript/components/TimelineChart/TimelineChart.tsx b/webapp/javascript/components/TimelineChart/TimelineChart.tsx index 2c75f7aa6c..4f9be44659 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChart.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChart.tsx @@ -5,11 +5,12 @@ import React from 'react'; import ReactFlot from 'react-flot'; import 'react-flot/flot/jquery.flot.time.min'; import './Selection.plugin'; -import 'react-flot/flot/jquery.flot.crosshair.min'; +import 'react-flot/flot/jquery.flot.crosshair'; import './TimelineChartPlugin'; import './Tooltip.plugin'; import './Annotations.plugin'; import './ContextMenu.plugin'; +import './CrosshairSync.plugin'; interface TimelineChartProps { onSelect: (from: string, until: string) => void; diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx index 0966538a3b..9e975e0dd1 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx @@ -91,6 +91,9 @@ type TimelineChartWrapperProps = TimelineDataProps & { /** What element to render when clicking */ ContextMenu?: (props: ContextMenuProps) => React.ReactNode; + + /** The list of timeline IDs (flotjs component) to sync the crosshair with */ + syncCrosshairsWith?: string[]; }; class TimelineChartWrapper extends React.Component< @@ -150,6 +153,7 @@ class TimelineChartWrapper extends React.Component< clickable: true, }, annotations: [], + syncCrosshairsWith: [], yaxis: { show: false, min: 0, @@ -210,14 +214,18 @@ class TimelineChartWrapper extends React.Component< this.state.flotOptions.annotations = this.composeAnnotationsList(); } + // TODO: this only seems to sync props back into the state, which seems unnecessary componentDidUpdate(prevProps: TimelineChartWrapperProps) { if ( prevProps.selection !== this.props.selection || - prevProps.annotations !== this.props.annotations + prevProps.annotations !== this.props.annotations || + prevProps.syncCrosshairsWith !== this.props.syncCrosshairsWith ) { const newFlotOptions = this.state.flotOptions; newFlotOptions.grid.markings = this.plotMarkings(); newFlotOptions.annotations = this.composeAnnotationsList(); + newFlotOptions.syncCrosshairsWith = this.props.syncCrosshairsWith; + this.setState({ flotOptions: newFlotOptions }); } } diff --git a/webapp/javascript/pages/ContinuousComparisonView.tsx b/webapp/javascript/pages/ContinuousComparisonView.tsx index efc886dfa7..7a84bcfe9f 100644 --- a/webapp/javascript/pages/ContinuousComparisonView.tsx +++ b/webapp/javascript/pages/ContinuousComparisonView.tsx @@ -190,6 +190,10 @@ function ComparisonApp() { timelineA={leftTimeline} timelineB={rightTimeline} onSelect={handleSelectMain} + syncCrosshairsWith={[ + 'timeline-chart-left', + 'timeline-chart-right', + ]} selection={{ left: { from: leftFrom, @@ -292,6 +296,10 @@ function ComparisonApp() { id="timeline-chart-left" data-testid="timeline-left" selectionWithHandler + syncCrosshairsWith={[ + 'timeline-chart-double', + 'timeline-chart-right', + ]} timelineA={leftTimeline} selection={{ left: { @@ -349,6 +357,10 @@ function ComparisonApp() { id="timeline-chart-right" data-testid="timeline-right" timelineA={rightTimeline} + syncCrosshairsWith={[ + 'timeline-chart-double', + 'timeline-chart-left', + ]} selectionWithHandler selection={{ right: { diff --git a/webapp/javascript/pages/ContinuousDiffView.tsx b/webapp/javascript/pages/ContinuousDiffView.tsx index 3c36233972..54e7956789 100644 --- a/webapp/javascript/pages/ContinuousDiffView.tsx +++ b/webapp/javascript/pages/ContinuousDiffView.tsx @@ -127,6 +127,10 @@ function ComparisonDiffApp() { onSelect={(from, until) => { dispatch(actions.setFromAndUntil({ from, until })); }} + syncCrosshairsWith={[ + 'timeline-chart-left', + 'timeline-chart-right', + ]} selection={{ left: { from: leftFrom, @@ -174,6 +178,10 @@ function ComparisonDiffApp() { key="timeline-chart-left" id="timeline-chart-left" timelineA={leftTimeline} + syncCrosshairsWith={[ + 'timeline-chart-diff', + 'timeline-chart-right', + ]} selectionWithHandler onSelect={(from, until) => { dispatch(actions.setLeft({ from, until })); @@ -209,6 +217,10 @@ function ComparisonDiffApp() { id="timeline-chart-right" selectionWithHandler timelineA={rightTimeline} + syncCrosshairsWith={[ + 'timeline-chart-diff', + 'timeline-chart-left', + ]} onSelect={(from, until) => { dispatch(actions.setRight({ from, until })); }}