diff --git a/frontend/src/lib/components/AdvancedPlot/advancedPlot.tsx b/frontend/src/lib/components/AdvancedPlot/advancedPlot.tsx index 2da5d42fa..df1705b79 100644 --- a/frontend/src/lib/components/AdvancedPlot/advancedPlot.tsx +++ b/frontend/src/lib/components/AdvancedPlot/advancedPlot.tsx @@ -5,6 +5,7 @@ import Plotly, { PlotlyHTMLElement } from "plotly.js-dist-min"; export type HighlightedCurve = { curveNumber: number; + width?: number; color: string; }; @@ -38,9 +39,9 @@ export type AdvancedPlotProps = { export const AdvancedPlot: React.FC = (props) => { const [isUnmounting, setIsUnmounting] = React.useState(false); const [data, setData] = React.useState(props.data); - const [layout, setLayout] = React.useState>(props.layout); + const [layout, setLayout] = React.useState>(cloneDeep(props.layout)); const [prevData, setPrevData] = React.useState(props.data); - const [prevLayout, setPrevLayout] = React.useState>(props.layout); + const [prevLayout, setPrevLayout] = React.useState>(cloneDeep(props.layout)); const [prevFrames, setPrevFrames] = React.useState(props.frames || undefined); const [prevHighlightedCurves, setPrevHighlightedCurves] = React.useState(undefined); @@ -50,19 +51,24 @@ export const AdvancedPlot: React.FC = (props) => { const promiseRef = React.useRef>(Promise.resolve()); const timeout = React.useRef | null>(null); const hoverTimeout = React.useRef | null>(null); + const unhoverTimeout = React.useRef | null>(null); let changes = false; + let currentData = data; + let currentLayout = layout; if (props.data !== prevData) { changes = true; setData(props.data); setPrevData(props.data); + currentData = props.data; } if (!isEqual(props.layout, prevLayout)) { changes = true; - setLayout(props.layout); - setPrevLayout(props.layout); + setLayout(cloneDeep(props.layout)); + setPrevLayout(cloneDeep(props.layout)); + currentLayout = cloneDeep(props.layout); } if (!isEqual(props.frames, prevFrames)) { @@ -71,10 +77,12 @@ export const AdvancedPlot: React.FC = (props) => { } if (changes) { - console.debug("update data"); - updatePlotly(props.data, props.layout, props.highlightedCurves, prevHighlightedCurvesRef.current); + console.debug("data or layout changed"); + updatePlotly(currentData, currentLayout, props.highlightedCurves, prevHighlightedCurvesRef.current); + setPrevHighlightedCurves(cloneDeep(props.highlightedCurves)); + prevHighlightedCurvesRef.current = cloneDeep(props.highlightedCurves || []); } else if (!isEqual(props.highlightedCurves, prevHighlightedCurves)) { - console.debug("update highlighted curves"); + console.debug("highlighted curves changed"); setPrevHighlightedCurves(cloneDeep(props.highlightedCurves)); updateHighlightedCurves(props.highlightedCurves, prevHighlightedCurvesRef.current); prevHighlightedCurvesRef.current = cloneDeep(props.highlightedCurves || []); @@ -99,9 +107,7 @@ export const AdvancedPlot: React.FC = (props) => { return Plotly.react(graphDiv, data, layout, props.config); }) .then(() => { - setPrevHighlightedCurves(cloneDeep(newHighlightedCurves)); updateHighlightedCurves(newHighlightedCurves, oldHighlightedCurves, true); - prevHighlightedCurvesRef.current = cloneDeep(props.highlightedCurves || []); }) .catch((error) => { console.error(error); @@ -126,6 +132,37 @@ export const AdvancedPlot: React.FC = (props) => { (highlightedCurve) => highlightedCurve.curveNumber ); + /* + + const oldHighlightedCurveNumbers = (oldHighlightedCurves ?? []).map( + (highlightedCurve) => highlightedCurve.curveNumber + ); + + if (oldHighlightedCurveNumbers.length > 0) { + Plotly.restyle( + graphDiv, + { + "line.width": 1, + }, + oldHighlightedCurveNumbers + ); + } + + const highlightedCurveNumbers = (newHighlightedCurves ?? []).map( + (highlightedCurve) => highlightedCurve.curveNumber + ); + + if (highlightedCurveNumbers.length > 0) { + Plotly.restyle( + graphDiv, + { + "line.width": 4, + }, + highlightedCurveNumbers + ); + } + */ + if (!plotlyUpdated && oldHighlightedCurves && oldHighlightedCurves.length > 0) { const tracesToBeDeleted = []; for (let i = 0; i < oldHighlightedCurves.length; i++) { @@ -140,7 +177,10 @@ export const AdvancedPlot: React.FC = (props) => { const traces: Data[] = []; newHighlightedCurves.forEach((highlightedCurve) => { const dataObj: Data = data[highlightedCurve.curveNumber]; - if (dataObj && dataObj.type === "scatter") { + if (!dataObj) { + return; + } + if (dataObj.type === "scatter" || dataObj.type === "scattergl") { traces.push({ ...dataObj, marker: { @@ -150,8 +190,10 @@ export const AdvancedPlot: React.FC = (props) => { line: { ...dataObj.line, color: highlightedCurve.color, + width: highlightedCurve.width, }, showlegend: false, + hoverinfo: "skip", }); } else { console.warn("highlighted curve is not a scatter plot", dataObj); @@ -164,77 +206,113 @@ export const AdvancedPlot: React.FC = (props) => { } React.useEffect(function handleMount() { - let interactionDisabled = false; const graphDiv = divRef.current as unknown as PlotlyHTMLElement; setIsUnmounting(false); - function handleHover(event: Plotly.PlotHoverEvent) { - if (!interactionDisabled) { - if (hoverTimeout.current) { - clearTimeout(hoverTimeout.current); - } - hoverTimeout.current = setTimeout(() => { - if (props.onHover && event.points[0].data.showlegend !== false) { - props.onHover(event); - } - }, 100); - } - } - - function handleUnHover() { - if (props.onUnhover && !interactionDisabled) { - if (hoverTimeout.current) { - clearTimeout(hoverTimeout.current); - } - - hoverTimeout.current = setTimeout(() => props.onUnhover && props.onUnhover(), 100); - } - } - - // When zooming in, the relayout function is only called once there hasn't been a wheel event in a certain time. - // However, the hover event would still be sent and might update the data, causing a layout change and, hence, - // a jump in the plot's zoom. To prevent this, we disable the hover event for a certain time after a wheel event. - function handleWheel() { - if (timeout.current) { - clearTimeout(timeout.current); - } - interactionDisabled = true; - timeout.current = setTimeout(() => { - interactionDisabled = false; - }, 500); - } - - function handleTouchZoom(e: TouchEvent) { - if (e.touches.length === 2) { - handleWheel(); - } + function handleRelayout(event: Plotly.PlotRelayoutEvent) { + setLayout((prev) => ({ + ...prev, + ...event, + })); } if (graphDiv) { Plotly.newPlot(graphDiv, data, layout, props.config); - graphDiv.on("plotly_hover", handleHover); - graphDiv.on("plotly_unhover", handleUnHover); - graphDiv.addEventListener("wheel", handleWheel); - graphDiv.addEventListener("touchmove", handleTouchZoom); + graphDiv.on("plotly_relayout", handleRelayout); } return function handleUnmount() { if (graphDiv) { setIsUnmounting(true); - graphDiv.removeAllListeners("plotly_hover"); - graphDiv.removeAllListeners("plotly_unhover"); - graphDiv.removeEventListener("wheel", handleWheel); - graphDiv.removeEventListener("touchmove", handleTouchZoom); + // Purge should remove all event listeners + // https://github.com/plotly/plotly.js/blob/a5577d994ea06785be100f9e7decff3e6cd8ab1f/src/plots/plots.js#L1817 Plotly.purge(graphDiv); } - if (timeout.current) { - clearTimeout(timeout.current); - } - if (hoverTimeout.current) { - clearTimeout(hoverTimeout.current); - } }; }, []); + React.useEffect( + function addHoverEventHandlers() { + let interactionDisabled = false; + const graphDiv = divRef.current as unknown as PlotlyHTMLElement; + + function handleHover(event: Plotly.PlotHoverEvent) { + if (!interactionDisabled) { + console.debug("hover"); + if (hoverTimeout.current) { + clearTimeout(hoverTimeout.current); + } + hoverTimeout.current = setTimeout(() => { + if (props.onHover) { + props.onHover(event); + } + }, 100); + } + } + + function handleUnHover() { + if (!interactionDisabled) { + console.debug("unhover"); + if (unhoverTimeout.current) { + clearTimeout(unhoverTimeout.current); + } + + unhoverTimeout.current = setTimeout(() => { + if (props.onUnhover) { + props.onUnhover(); + } + }, 100); + } + } + + // When zooming in, the relayout function is only called once there hasn't been a wheel event in a certain time. + // However, the hover event would still be sent and might update the data, causing a layout change and, hence, + // a jump in the plot's zoom. To prevent this, we disable the hover event for a certain time after a wheel event. + function handleWheel() { + if (timeout.current) { + clearTimeout(timeout.current); + } + interactionDisabled = true; + timeout.current = setTimeout(() => { + interactionDisabled = false; + }, 500); + } + + function handleTouchZoom(e: TouchEvent) { + if (e.touches.length === 2) { + handleWheel(); + } + } + + if (graphDiv && divRef.current) { + graphDiv.on("plotly_hover", handleHover); + graphDiv.on("plotly_unhover", handleUnHover); + graphDiv.addEventListener("wheel", handleWheel); + graphDiv.addEventListener("touchmove", handleTouchZoom); + } + + return function removeHoverEventHandlers() { + // It should be safe to ignore the "on" properties here, since they are a properties that are going to + // be replaced the next time a handler is assigned (as long as it works like "onClick" etc.). + if (graphDiv) { + graphDiv.removeEventListener("wheel", handleWheel); + graphDiv.removeEventListener("touchmove", handleTouchZoom); + if (graphDiv.removeAllListeners) { + // Doc is pretty bad, but "handler" is probably the event name + graphDiv.removeAllListeners("plotly_hover"); + graphDiv.removeAllListeners("plotly_unhover"); + } + } + if (timeout.current) { + clearTimeout(timeout.current); + } + if (hoverTimeout.current) { + clearTimeout(hoverTimeout.current); + } + }; + }, + [props.onHover, props.onUnhover, isUnmounting] + ); + return
; };