import React, { CSSProperties, MutableRefObject, useCallback, useRef, useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { isEqual, orderBy } from "lodash"; import { NODE_VISIBLE } from "../../util/globals"; import { getColorByTitle, getTipColorAttribute } from "../../util/colorHelpers"; import { determineLegendMatch } from "../../util/tipRadiusHelpers"; import ErrorBoundary from "../../util/errorBoundary"; import Flex from "../framework/flex"; import Card from "../framework/card"; import Legend from "../tree/legend/legend"; import HoverPanel, { HoverData } from "./hoverPanel"; import { createXScale, createYScale, groupMeasurements, clearMeasurementsSVG, drawMeasurementsSVG, drawMeansForColorBy, colorMeasurementsSVG, changeMeasurementsDisplay, svgContainerDOMId, toggleDisplay, addHoverPanelToMeasurementsAndMeans, addColorByAttrToGroupingLabel, layout, jitterRawMeansByColorBy, addGroupingValueCrosshair, removeColorGroupingCrosshair, } from "./measurementsD3"; import { RootState } from "../../store"; import { MeasurementFilters } from "../../reducers/controls"; import { Visibility } from "../../reducers/tree/types"; import { Measurement, isMeasurement } from "../../reducers/measurements/types"; import { applyMeasurementsColorBy, isMeasurementColorBy, getActiveMeasurementFilters, matchesAllActiveFilters } from "../../actions/measurements"; import { changeColorBy } from "../../actions/colors"; interface MeanAndStandardDeviation { mean: number standardDeviation: number | undefined } function isMeanAndStandardDeviation(x: any): x is MeanAndStandardDeviation { return ( typeof x.mean === "number" && (typeof x.standardDeviation === "number" || x.standardDeviation === undefined) ) } interface TreeStrainVisibility { [strain: string]: Visibility } interface TreeStrainProperties { treeStrainVisibility: TreeStrainVisibility treeStrainColors: { [strain: string]: { attribute: string color: string } } } /** * A custom React Hook that returns a memoized value that will only change * if a deep comparison using lodash.isEqual determines the value is not * equivalent to the previous value. */ function useDeepCompareMemo(value: T): T { const ref: MutableRefObject = useRef(); if (!isEqual(value, ref.current)) { ref.current = value; } return ref.current; } // Checks visibility against global NODE_VISIBLE const isVisible = (visibility: Visibility): boolean => visibility === NODE_VISIBLE; /** * A custom React Redux Selector that reduces the tree redux state to an object * with the terminal strain names and their corresponding properties that * are relevant for the Measurement's panel. Uses the colorScale redux state to * find the current color attribute per strain. * * tree.visibility and tree.nodeColors need to be arrays that have the same * order as tree.nodes */ const treeStrainPropertySelector = ( state: RootState ): TreeStrainProperties => { const { tree, controls } = state; const { colorScale } = controls; const initialTreeStrainProperty: TreeStrainProperties = { treeStrainVisibility: {}, treeStrainColors: {} }; return tree.nodes.reduce((treeStrainProperty, node, index) => { const { treeStrainVisibility, treeStrainColors } = treeStrainProperty; // Only store properties of terminal strain nodes if (!node.hasChildren) { treeStrainVisibility[node.name] = tree.visibility[index]; /* * If the color scale is continuous, we want to group by the legend value * instead of the specific strain attribute in order to combine all values * within the legend bounds into a single group. */ let attribute = getTipColorAttribute(node, colorScale); if (colorScale.continuous) { const matchingLegendValue = colorScale.visibleLegendValues .find((legendValue) => determineLegendMatch(legendValue, node, colorScale)); if (matchingLegendValue !== undefined) attribute = matchingLegendValue; } treeStrainColors[node.name] = { attribute, color: tree.nodeColors[index] }; } return treeStrainProperty; }, initialTreeStrainProperty); }; /** * Filters provided measurements to measurements of strains that are currently * visible in the tree adn that are included in the active measurements filters. * * Visibility is indicated by the numeric visibility value in the provided * treeStrainVisibility object for strain. * * Returns the active filters object and the filtered measurements */ const filterMeasurements = ( measurements: Measurement[], treeStrainVisibility: TreeStrainVisibility, filters: MeasurementFilters ): { activeFilters: {string?: string[]} filteredMeasurements: Measurement[] } => { // Find active filters to filter measurements const activeFilters = getActiveMeasurementFilters(filters); return { activeFilters, filteredMeasurements: measurements.filter((measurement) => { // First check the strain is visible in the tree if (!isVisible(treeStrainVisibility[measurement.strain])) return false; // Then check that the measurement contains values for all active filters return matchesAllActiveFilters(measurement, activeFilters); }) }; }; const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}): JSX.Element => { const dispatch = useDispatch(); // Use `lodash.isEqual` to deep compare object states to prevent unnecessary re-renderings of the component const { treeStrainVisibility, treeStrainColors } = useSelector((state: RootState) => treeStrainPropertySelector(state), isEqual); // Convert legendValues to string to ensure that subsequent attribute matches work as intended const legendValues = useSelector((state: RootState) => state.controls.colorScale.legendValues.map(String), isEqual); const colorings = useSelector((state: RootState) => state.metadata.colorings); const colorBy = useSelector((state: RootState) => state.controls.colorBy); const defaultColorBy = useSelector((state: RootState) => state.controls.defaults.colorBy); const colorGrouping = useSelector((state: RootState) => state.controls.measurementsColorGrouping); const groupBy = useSelector((state: RootState) => state.controls.measurementsGroupBy); const filters = useSelector((state: RootState) => state.controls.measurementsFilters); const display = useSelector((state: RootState) => state.controls.measurementsDisplay); const showOverallMean = useSelector((state: RootState) => state.controls.measurementsShowOverallMean); const showThreshold = useSelector((state: RootState) => state.controls.measurementsShowThreshold); const collection = useSelector((state: RootState) => state.measurements.collectionToDisplay, isEqual); const { title, x_axis_label, thresholds, fields, measurements, groupings } = collection; // Ref to access the D3 SVG const svgContainerRef: MutableRefObject = useRef(null); const d3Ref: MutableRefObject = useRef(null); const d3XAxisRef: MutableRefObject = useRef(null); // State for storing data for the HoverPanel const [hoverData, setHoverData] = useState(null); // Filter and group measurements const {activeFilters, filteredMeasurements} = filterMeasurements(measurements, treeStrainVisibility, filters); const groupingOrderedValues = groupings.get(groupBy).values; // Default ordering of rows is the groupings value order from redux state let groupByValueOrder = groupingOrderedValues; // If there are active filters for the current group-by field, ordering is the user's filter order if (activeFilters[groupBy] && activeFilters[groupBy].length) { groupByValueOrder = activeFilters[groupBy]; } const groupedMeasurements = groupMeasurements(filteredMeasurements, groupBy, groupByValueOrder); // /** * Memoize D3 scale functions to allow deep comparison to work below for svgData * Using `useMemo` instead of `useCallback` because `useCallback` is specifically designed for inline functions * and will raise lint errors, see https://github.com/facebook/react/issues/19240#issuecomment-652945246 * * Silencing warnings for useMemo's dependency list since we need to do a deep comparison of `filteredMeasurements` array * -Jover, 28 August 2024 */ // eslint-disable-next-line react-hooks/exhaustive-deps const xScale = useMemo(() => createXScale(width, filteredMeasurements), [width, filteredMeasurements].map(useDeepCompareMemo)); const yScale = useMemo(() => createYScale(), []); // Memoize all data needed for basic SVG to avoid extra re-drawings const svgData = useDeepCompareMemo({ containerHeight: height, xScale, yScale, x_axis_label, thresholds, groupingOrderedValues, groupedMeasurements }); // Cache handleHover function to avoid extra useEffect calls const handleHover = useCallback(( data: Measurement | MeanAndStandardDeviation, mouseX: number, mouseY: number, colorByAttr: string = null ): void => { let newHoverData = null; if (data !== null) { // Set color-by attribute as title if provided const hoverTitle = colorByAttr !== null ? `Color by ${getColorByTitle(colorings, colorBy)} : ${colorByAttr}` : null; // Create a Map of data to save order of fields const newData = new Map(); if (isMeasurement(data)) { // Handle single measurement data // Filter out internal auspice fields (i.e. measurementsJitter and measurementsId) const displayFields = Object.keys(data).filter((field) => fields.has(field)); // Order fields for display const fieldOrder = [...fields.keys()]; const orderedFields = orderBy(displayFields, (field) => fieldOrder.indexOf(field)); orderedFields.forEach((field) => { newData.set(fields.get(field).title, data[field]); }); } else if (isMeanAndStandardDeviation(data)) { // Handle mean and standard deviation data newData.set("mean", data.mean.toFixed(2)); newData.set("standard deviation", data.standardDeviation ? data.standardDeviation.toFixed(2) : "N/A"); } else { // Catch unknown data types console.error(`"Unknown data type for hover panel: ${JSON.stringify(data)}`); // Display provided data without extra ordering or parsing Object.entries(data).forEach(([key, value]) => newData.set(key, value)); } newHoverData = { hoverTitle, mouseX, mouseY, containerId: svgContainerDOMId, data: newData }; } setHoverData(newHoverData); }, [fields, colorings, colorBy]); /** * Ref to save previous non-measurements coloring for toggling back to previous * coloring when clicking on the same measurements grouping twice. * Uses the default color by if the color is a measurements color on first * load, i.e. the color is set by the URL param `c=m-` */ const prevNonMeasurementColorBy: MutableRefObject = useRef(isMeasurementColorBy(colorBy) ? defaultColorBy : colorBy); useEffect(() => { if (!isMeasurementColorBy(colorBy)) { prevNonMeasurementColorBy.current = colorBy; } }, [colorBy]); const handleClickOnGrouping = useCallback((grouping: string): void => { if (grouping !== colorGrouping || !isMeasurementColorBy(colorBy)) { dispatch(applyMeasurementsColorBy(grouping)); } else if (grouping === colorGrouping && isMeasurementColorBy(colorBy)) { // Clicking on the same grouping twice will toggle back to the previous non-measurements coloring dispatch(changeColorBy(prevNonMeasurementColorBy.current)); } }, [dispatch, colorGrouping, colorBy]); useEffect(() => { setPanelTitle(`${title || "Measurements"} (grouped by ${fields.get(groupBy).title})`); }, [setPanelTitle, title, fields, groupBy]); // Draw SVG from scratch useEffect(() => { // Reset the container to the top to prevent sticky x-axis from keeping // the scroll position on whitespace. svgContainerRef.current.scrollTop = 0; clearMeasurementsSVG(d3Ref.current, d3XAxisRef.current); drawMeasurementsSVG(d3Ref.current, d3XAxisRef.current, svgData, handleClickOnGrouping); }, [svgData, handleClickOnGrouping]); // Color the SVG & redraw color-by means when SVG is re-drawn or when colors have changed useEffect(() => { addColorByAttrToGroupingLabel(d3Ref.current, treeStrainColors); colorMeasurementsSVG(d3Ref.current, treeStrainColors); jitterRawMeansByColorBy(d3Ref.current, svgData, treeStrainColors, legendValues); drawMeansForColorBy(d3Ref.current, svgData, treeStrainColors, legendValues); addHoverPanelToMeasurementsAndMeans(d3Ref.current, handleHover, treeStrainColors); }, [svgData, treeStrainColors, legendValues, handleHover]); // Display raw/mean measurements when SVG is re-drawn, colors have changed, or display has changed useEffect(() => { changeMeasurementsDisplay(d3Ref.current, display); }, [svgData, treeStrainColors, legendValues, handleHover, display]); useEffect(() => { toggleDisplay(d3Ref.current, "overallMean", showOverallMean); }, [svgData, showOverallMean]); useEffect(() => { toggleDisplay(d3Ref.current, "threshold", showThreshold); }, [svgData, showThreshold]); useEffect(() => { if(isMeasurementColorBy(colorBy)) { addGroupingValueCrosshair(d3Ref.current, colorGrouping); } else { removeColorGroupingCrosshair(d3Ref.current); } }, [svgData, colorBy, colorGrouping]) const getSVGContainerStyle = (): CSSProperties => { return { overflowY: "auto", position: "relative", height: height, width: width }; }; /** * Sticky x-axis with a set height to make sure the x-axis is always * at the bottom of the measurements panel */ const getStickyXAxisSVGStyle = (): CSSProperties => { return { width: "100%", height: layout.xAxisHeight, position: "sticky", zIndex: 99 }; }; /** * Position relative with bottom shifted up by the x-axis height to * allow x-axis to fit in the bottom of the panel when scrolling all the way * to the bottom of the measurements SVG */ const getMainSVGStyle = (): CSSProperties => { return { width: "100%", position: "relative", bottom: `${getStickyXAxisSVGStyle().height}px` }; }; return ( <> {showLegend && }
{hoverData && } {/* x-axis SVG must be above main measurements SVG for sticky positioning to work properly */}
); }; const Measurements = ({height, width, showLegend}): JSX.Element => { const measurementsLoaded = useSelector((state: RootState) => state.measurements.loaded); const measurementsError = useSelector((state: RootState) => state.measurements.error); const showOnlyPanels = useSelector((state: RootState) => state.controls.showOnlyPanels); const [title, setTitle] = useState("Measurements"); const getCardTitleStyle = (): CSSProperties => { /** * Additional styles of Card title forces it to be in one line and display * ellipsis if the title is too long to prevent the long title from pushing * the Card into the next line when viewing in grid mode */ return { width, display: "block", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }; }; return ( {measurementsLoaded ? :

{measurementsError || "Failed to fetch/load measurements due to unknown error"}

}
); }; export default Measurements;