Skip to content

Commit

Permalink
Merge pull request #1557 from nextstrain/measurements-improvements
Browse files Browse the repository at this point in the history
Measurements improvements
  • Loading branch information
joverlee521 authored Oct 5, 2022
2 parents f058579 + 76e71b9 commit 2eb4bb3
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 80 deletions.
5 changes: 4 additions & 1 deletion src/components/measurements/hoverPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { InfoLine } from "../tree/infoPanels/hover";

const HoverPanel = ({hoverData}) => {
if (hoverData === null) return null;
const { mouseX, mouseY, containerId, data } = hoverData;
const { hoverTitle, mouseX, mouseY, containerId, data } = hoverData;
const panelStyle = {
position: "absolute",
minWidth: 200,
Expand Down Expand Up @@ -54,6 +54,9 @@ const HoverPanel = ({hoverData}) => {
return (
<div style={panelStyle}>
<div className={"tooltip"} style={infoPanelStyles.tooltip}>
<div style={infoPanelStyles.tooltipHeading}>
{hoverTitle}
</div>
{[...data.entries()].map(([field, value]) => {
return (
<InfoLine key={field} name={`${field}:`} value={value} />
Expand Down
73 changes: 47 additions & 26 deletions src/components/measurements/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useRef, useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { isEqual, orderBy } from "lodash";
import { NODE_VISIBLE } from "../../util/globals";
import { getTipColorAttribute } from "../../util/colorHelpers";
import { getColorByTitle, getTipColorAttribute } from "../../util/colorHelpers";
import { determineLegendMatch } from "../../util/tipRadiusHelpers";
import ErrorBoundary from "../../util/errorBoundry";
import Flex from "../framework/flex";
Expand All @@ -19,7 +19,9 @@ import {
colorMeasurementsSVG,
changeMeasurementsDisplay,
svgContainerDOMId,
toggleDisplay
toggleDisplay,
addHoverPanelToMeasurementsAndMeans,
addColorByAttrToGroupingLabel
} from "./measurementsD3";

/**
Expand Down Expand Up @@ -63,25 +65,22 @@ const treeStrainPropertySelector = (state) => {
// Only store properties of terminal strain nodes
if (!node.hasChildren) {
treeStrainVisibility[node.name] = tree.visibility[index];
// Only store colors for visible strains since only measurmeents
// for visible strains will be displayed
if (isVisible(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]
};
/*
* 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;
}, intitialTreeStrainProperty);
Expand Down Expand Up @@ -128,6 +127,8 @@ const filterMeasurements = (measurements, treeStrainVisibility, filters) => {
const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
// Use `lodash.isEqual` to deep compare object states to prevent unnecessary re-renderings of the component
const { treeStrainVisibility, treeStrainColors } = useSelector((state) => treeStrainPropertySelector(state), isEqual);
const colorings = useSelector((state) => state.metadata.colorings);
const colorBy = useSelector((state) => state.controls.colorBy);
const groupBy = useSelector((state) => state.controls.measurementsGroupBy);
const filters = useSelector((state) => state.controls.measurementsFilters);
const display = useSelector((state) => state.controls.measurementsDisplay);
Expand Down Expand Up @@ -159,9 +160,11 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
// Memoize all data needed for basic SVG to avoid extra re-drawings
const svgData = useDeepCompareMemo({ xScale, yScale, x_axis_label, threshold, groupingOrderedValues, groupedMeasurements});
// Memoize handleHover function to avoid extra useEffect calls
const handleHover = useMemo(() => (data, dataType, mouseX, mouseY) => {
const handleHover = useMemo(() => (data, dataType, mouseX, mouseY, colorByAttr=null) => {
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 (dataType === "measurement") {
Expand All @@ -185,14 +188,15 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
Object.entries(data).forEach(([key, value]) => newData.set(key, value));
}
newHoverData = {
hoverTitle,
mouseX,
mouseY,
containerId: svgContainerDOMId,
data: newData
};
}
setHoverData(newHoverData);
}, [fields]);
}, [fields, colorings, colorBy]);

useEffect(() => {
setPanelTitle(`${title || "Measurements"} (grouped by ${fields.get(groupBy).title})`);
Expand All @@ -201,13 +205,15 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
// Draw SVG from scratch
useEffect(() => {
clearMeasurementsSVG(d3Ref.current);
drawMeasurementsSVG(d3Ref.current, svgData, handleHover);
}, [svgData, handleHover]);
drawMeasurementsSVG(d3Ref.current, svgData);
}, [svgData]);

// 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);
drawMeansForColorBy(d3Ref.current, svgData, treeStrainColors, handleHover);
drawMeansForColorBy(d3Ref.current, svgData, treeStrainColors);
addHoverPanelToMeasurementsAndMeans(d3Ref.current, handleHover, treeStrainColors);
}, [svgData, treeStrainColors, handleHover]);

// Display raw/mean measurements when SVG is re-drawn, colors have changed, or display has changed
Expand Down Expand Up @@ -261,8 +267,23 @@ const Measurements = ({height, width, showLegend}) => {

const [title, setTitle] = useState("Measurements");

const getCardTitleStyle = () => {
/**
* 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 (
<Card title={title}>
<Card title={title} titleStyles={getCardTitleStyle()}>
{measurementsLoaded &&
(measurementsError ?
<Flex style={{ height, width}} direction="column" justifyContent="center">
Expand Down
135 changes: 97 additions & 38 deletions src/components/measurements/measurementsD3.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,20 @@ export const layout = {
thresholdStroke: "#DDD",
subplotFill: "#adb1b3",
subplotFillOpacity: "0.15",
diamondSize: 25,
standardDeviationStroke: 2,
overallMeanColor: "#000"
diamondSize: 50,
standardDeviationStroke: 3,
overallMeanColor: "#000",
yAxisTickSize: 6,
yAxisColorByLineHeight: 9,
yAxisColorByLineStrokeWidth: 4
};
// Display overall mean at the center of each subplot
layout['overallMeanYValue'] = layout.subplotHeight / 2;

const classes = {
xAxis: "measurementXAxis",
yAxis: "measurementYAxis",
yAxisColorByLabel: "measurementYAxisColorByLabel",
threshold: "measurementThreshold",
subplot: "measurementSubplot",
subplotBackground: "measurementSubplotBackground",
Expand Down Expand Up @@ -105,8 +109,9 @@ export const clearMeasurementsSVG = (ref) => {
.selectAll("*").remove();
};

const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, color, xScale, yValue, handleHover) => {
const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, colorBy, xScale, yValue) => {
const meanAndStandardDeviation = {
colorByAttr: colorBy.attribute,
mean: mean(values),
standardDeviation: deviation(values)
};
Expand All @@ -122,13 +127,7 @@ const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, colo
.attr("class", classes.mean)
.attr("transform", (d) => `translate(${xScale(d.mean)}, ${yValue})`)
.attr("d", symbol().type(symbolDiamond).size(layout.diamondSize))
.attr("fill", color)
.on("mouseover", (d) => {
// Get mouse position for HoverPanel
const { clientX, clientY } = d3event;
handleHover(d, "mean", clientX, clientY);
})
.on("mouseout", () => handleHover(null));
.attr("fill", colorBy.color);

if (meanAndStandardDeviation.standardDeviation !== undefined) {
container.append("line")
Expand All @@ -138,17 +137,11 @@ const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, colo
.attr("y1", yValue)
.attr("y2", yValue)
.attr("stroke-width", layout.standardDeviationStroke)
.attr("stroke", color)
.on("mouseover", (d) => {
// Get mouse position for HoverPanel
const { clientX, clientY } = d3event;
handleHover(d, "mean", clientX, clientY);
})
.on("mouseout", () => handleHover(null));
.attr("stroke", colorBy.color);
}
};

export const drawMeasurementsSVG = (ref, svgData, handleHover) => {
export const drawMeasurementsSVG = (ref, svgData) => {
const {xScale, yScale, x_axis_label, threshold, groupingOrderedValues, groupedMeasurements} = svgData;

// Do not draw SVG if there are no measurements
Expand Down Expand Up @@ -222,8 +215,26 @@ export const drawMeasurementsSVG = (ref, svgData, handleHover) => {
.call(
axisLeft(yScale)
.tickValues([yScale((layout.yMax - layout.yMin) / 2)])
.tickFormat(() => groupingValue))
.call((g) => g.attr("font-family", null));
.tickSize(layout.yAxisTickSize)
.tickFormat(groupingValue))
.call((g) => {
g.attr("font-family", null);
// If necessary, scale down the text to fit in the available space for the y-Axis labels
// This does mean that if the text is extremely long, it can be unreadable.
// We can improve on this by manually splitting the text into parts that can fit on multiple lines,
// but there's always limits of the available space so punting that for now.
// -Jover, 20 September 2022
g.selectAll('text')
.attr("transform", (_, i, element) => {
const textWidth = select(element[i]).node().getBoundingClientRect().width;
// Subtract the twice the y-axis tick size to give some padding around the text
const availableTextWidth = layout.leftPadding - (2 * layout.yAxisTickSize);
if (textWidth > availableTextWidth) {
return `scale(${availableTextWidth / textWidth})`;
}
return null;
});
});

// Add circles for each measurement
subplot.append("g")
Expand All @@ -238,33 +249,25 @@ export const drawMeasurementsSVG = (ref, svgData, handleHover) => {
.attr("cx", (d) => xScale(d.value))
.attr("cy", (d) => yScale(d[measurementJitterSymbol]))
.attr("r", layout.circleRadius)
.on("mouseover", (d, i, elements) => {
.on("mouseover.radius", (d, i, elements) => {
select(elements[i]).transition()
.duration("100")
.attr("r", layout.circleHoverRadius);

// Get mouse position for HoverPanel
const { clientX, clientY } = d3event;
// sets hover data state to trigger the hover panel display
handleHover(d, "measurement", clientX, clientY);
})
.on("mouseout", (_, i, elements) => {
.on("mouseout.radius", (_, i, elements) => {
select(elements[i]).transition()
.duration("200")
.attr("r", layout.circleRadius);
// sets hover data state to null to hide the hover panel display
handleHover(null);
});

// Draw overall mean and standard deviation for measurement values
drawMeanAndStandardDeviation(
measurements.map((d) => d.value),
subplot,
classes.overallMean,
layout.overallMeanColor,
{attribute: null, color: layout.overallMeanColor},
xScale,
layout.overallMeanYValue,
handleHover
layout.overallMeanYValue
);
});
};
Expand All @@ -277,7 +280,7 @@ export const colorMeasurementsSVG = (ref, treeStrainColors) => {
.style("fill", (d) => getBrighterColor(treeStrainColors[d.strain].color));
};

export const drawMeansForColorBy = (ref, svgData, treeStrainColors, handleHover) => {
export const drawMeansForColorBy = (ref, svgData, treeStrainColors) => {
const { xScale, groupingOrderedValues, groupedMeasurements } = svgData;
const svg = select(ref);
// Re move all current color by means
Expand All @@ -302,15 +305,14 @@ export const drawMeansForColorBy = (ref, svgData, treeStrainColors, handleHover)
// 2 x subplotPadding for padding around the overall mean display
const ySpacing = (layout.subplotHeight - 4 * layout.subplotPadding) / (numberOfColorByAttributes - 1);
let yValue = layout.subplotPadding;
Object.values(colorByGroups).forEach(({color, values}) => {
Object.entries(colorByGroups).forEach(([attribute, {color, values}]) => {
drawMeanAndStandardDeviation(
values,
subplot,
classes.colorMean,
color,
{attribute, color},
xScale,
yValue,
handleHover
yValue
);
// Increate yValue for next attribute mean
yValue += ySpacing;
Expand Down Expand Up @@ -342,3 +344,60 @@ export const toggleDisplay = (ref, elementClass, displayOn) => {
.selectAll(`.${classes[elementClass]}`)
.attr("display", displayAttr);
};

export const addHoverPanelToMeasurementsAndMeans = (ref, handleHover, treeStrainColors) => {
const svg = select(ref);
svg.selectAll(`.${classes.rawMeasurements},.${classes.mean},.${classes.standardDeviation}`)
.on("mouseover.hoverPanel", (d, i, elements) => {
// Get mouse position for HoverPanel
const { clientX, clientY } = d3event;

// Use class name to check data type
const className = elements[i].getAttribute("class");
const dataType = className === classes.rawMeasurements ? "measurement" : "mean";

// For the means, the bound data includes the color-by attribute
// For the measurements, we need to get the color-by attribute from treeStrainColors
let colorByAttr = d.colorByAttr;
if (dataType === "measurement") {
colorByAttr = treeStrainColors[d.strain]?.attribute || "undefined";
}

// sets hover data state to trigger the hover panel display
handleHover(d, dataType, clientX, clientY, colorByAttr);
})
.on("mouseout.hoverPanel", () => handleHover(null));
};

export const addColorByAttrToGroupingLabel = (ref, treeStrainColors) => {
const svg = select(ref);
// Remove all previous color-by labels for the y-axis
svg.selectAll(`.${classes.yAxisColorByLabel}`).remove();
// Loop through the y-axis labels to check if they have a corresponding color-by
svg.selectAll(`.${classes.yAxis}`).select(".tick")
.each((_, i, elements) => {
const groupingLabel = select(elements[i]);
const groupingValue = groupingLabel.text();
const groupingValueColorBy = treeStrainColors[groupingValue];
if (groupingValueColorBy) {
// Get the current label width to add colored line and text relative to the width
const labelWidth = groupingLabel.node().getBoundingClientRect().width;
groupingLabel.append("line")
.attr("class", classes.yAxisColorByLabel)
.attr("x1", -layout.yAxisTickSize)
.attr("x2", -labelWidth)
.attr("y1", layout.yAxisColorByLineHeight)
.attr("y2", layout.yAxisColorByLineHeight)
.attr("stroke-width", layout.yAxisColorByLineStrokeWidth)
.attr("stroke", groupingValueColorBy.color);

groupingLabel.append("text")
.attr("class", classes.yAxisColorByLabel)
.attr("x", labelWidth * -0.5)
.attr("dy", layout.yAxisColorByLineHeight * 2 + layout.yAxisColorByLineStrokeWidth)
.attr("text-anchor", "middle")
.attr("fill", "currentColor")
.text(`(${groupingValueColorBy.attribute})`);
}
});
};
2 changes: 1 addition & 1 deletion src/components/tree/infoPanels/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { parseIntervalsOfNsOrGaps } from "./MutationTable";

export const InfoLine = ({name, value, padBelow=false}) => {
const renderValues = () => {
if (!value) return null;
if (!value && value !== 0) return null;
if (Array.isArray(value)) {
return value.map((v) => (
<div key={v} style={{fontWeight: "300", marginLeft: "0em"}}>
Expand Down
Loading

0 comments on commit 2eb4bb3

Please sign in to comment.