Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Measurements paper improvements #1631

Merged
merged 5 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/actions/measurements.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { pick } from "lodash";
import { measurementIdSymbol, measurementJitterSymbol } from "../util/globals";
import { layout as measurementsLayout } from "../components/measurements/measurementsD3";
import { measurementIdSymbol } from "../util/globals";
import {
APPLY_MEASUREMENTS_FILTER,
CHANGE_MEASUREMENTS_COLLECTION,
Expand Down Expand Up @@ -109,6 +108,16 @@ export const loadMeasurements = (json) => (dispatch, getState) => {
}

collections.forEach((collection) => {
/**
* Keep backwards compatibility with single value threshold.
* Make sure thresholds are an array of values so that we don't have to
* check the data type in the D3 drawing process
* `collection.thresholds` takes precedence over the deprecated `collection.threshold`
*/
if (typeof collection.threshold === "number") {
collection.thresholds = collection.thresholds || [collection.threshold];
delete collection.threshold;
}
/*
* Create fields Map for easier access of titles and to keep ordering
* First add fields from JSON to keep user's ordering
Expand Down Expand Up @@ -160,10 +169,7 @@ export const loadMeasurements = (json) => (dispatch, getState) => {
}
});

// Add jitter and stable id for each measurement to help visualization
const { yMin, yMax } = measurementsLayout;
// Generates a random number between the y min and max, inclusively
measurement[measurementJitterSymbol] = Math.random() * (yMax - yMin + 1) + yMin;
// Add stable id for each measurement to help visualization
measurement[measurementIdSymbol] = index;
});

Expand Down
7 changes: 5 additions & 2 deletions src/components/controls/measurementsOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,12 @@ const MeasurementsOptions = () => {
/>
<Toggle
// Only display threshold toggle if the collection has a valid threshold
display={typeof collection.threshold === "number"}
display={
Array.isArray(collection.thresholds) &&
collection.thresholds.some((threshold) => typeof threshold === "number")
}
on={showThreshold}
label="Show measurement threshold"
label="Show measurement threshold(s)"
Comment on lines -117 to +120
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking

This diff made me realize two things.

Labels like this should go thru our i18n framework (i18next) so they can be translated in the future.

label={t("Show measurement threshold(s)")}

Once they do, we can avoid the ugly "(s)" by using the standard pluralization/number agreement features, e.g. something like:

label={t("Show measurement threshold", {count: collection.thresholds.length})}

with corresponding translations, e.g. for en something like:

{
  "Show measurement threshold_one": "Show measurement threshold",
  "Show measurement threshold_others": "Show measurement thresholds",
}

(I realize these are out of scope for this PR.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, I'm totally missing the translation feature for all measurements options...

Very cool that i18next supports plurals formatting like this!
I decided that I'm going to punt this for now as I was reading the i18next docs since we are still using i18next v19.9.2 and I haven't dug into which JSON format we are using:

Starting with i18next>=21.3.0 you can use the built-in formatting functions based on the Intl API

You may need to polyfill the Intl.PluralRules API, in case it is not available it will fallback to the i18next JSON format v3 plural handling.

callback={() => dispatch({type: TOGGLE_MEASUREMENTS_THRESHOLD, data: !showThreshold})}
/>
</div>
Expand Down
62 changes: 55 additions & 7 deletions src/components/measurements/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
svgContainerDOMId,
toggleDisplay,
addHoverPanelToMeasurementsAndMeans,
addColorByAttrToGroupingLabel
addColorByAttrToGroupingLabel,
layout,
jitterRawMeansByColorBy
} from "./measurementsD3";

/**
Expand Down Expand Up @@ -136,10 +138,12 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
const showOverallMean = useSelector((state) => state.controls.measurementsShowOverallMean);
const showThreshold = useSelector((state) => state.controls.measurementsShowThreshold);
const collection = useSelector((state) => state.measurements.collectionToDisplay, isEqual);
const { title, x_axis_label, threshold, fields, measurements, groupings } = collection;
const { title, x_axis_label, thresholds, fields, measurements, groupings } = collection;

// Ref to access the D3 SVG
const svgContainerRef = useRef(null);
const d3Ref = useRef(null);
const d3XAxisRef = useRef(null);

// State for storing data for the HoverPanel
const [hoverData, setHoverData] = useState(null);
Expand All @@ -159,7 +163,15 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
const xScale = useMemo(() => createXScale(width, measurements), [width, measurements]);
const yScale = useMemo(() => createYScale(), []);
// Memoize all data needed for basic SVG to avoid extra re-drawings
const svgData = useDeepCompareMemo({ xScale, yScale, x_axis_label, threshold, groupingOrderedValues, groupedMeasurements});
const svgData = useDeepCompareMemo({
containerHeight: height,
xScale,
yScale,
x_axis_label,
thresholds,
groupingOrderedValues,
groupedMeasurements
});
// Memoize handleHover function to avoid extra useEffect calls
const handleHover = useMemo(() => (data, dataType, mouseX, mouseY, colorByAttr=null) => {
let newHoverData = null;
Expand Down Expand Up @@ -205,14 +217,18 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {

// Draw SVG from scratch
useEffect(() => {
clearMeasurementsSVG(d3Ref.current);
drawMeasurementsSVG(d3Ref.current, svgData);
// 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);
}, [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);
jitterRawMeansByColorBy(d3Ref.current, svgData, treeStrainColors, legendValues);
drawMeansForColorBy(d3Ref.current, svgData, treeStrainColors, legendValues);
addHoverPanelToMeasurementsAndMeans(d3Ref.current, handleHover, treeStrainColors);
}, [svgData, treeStrainColors, legendValues, handleHover]);
Expand All @@ -239,23 +255,55 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => {
};
};

/**
* Sticky x-axis with a set height to make sure the x-axis is always
* at the bottom of the measurements panel
*/
const getStickyXAxisSVGStyle = () => {
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 = () => {
return {
width: "100%",
position: "relative",
bottom: `${getStickyXAxisSVGStyle().height}px`
};
};

return (
<>
{showLegend &&
<ErrorBoundary>
<Legend right width={width}/>
</ErrorBoundary>
}
<div id={svgContainerDOMId} style={getSVGContainerStyle()}>
<div id={svgContainerDOMId} ref={svgContainerRef} style={getSVGContainerStyle()}>
{hoverData &&
<HoverPanel
hoverData={hoverData}
/>
}
{/* x-axis SVG must be above main measurements SVG for sticky positioning to work properly */}
<svg
id="d3MeasurementsXAxisSVG"
ref={d3XAxisRef}
style={getStickyXAxisSVGStyle()}
/>
<svg
id="d3MeasurementsSVG"
width="100%"
ref={d3Ref}
style={getMainSVGStyle()}
/>
</div>
</>
Expand Down
140 changes: 105 additions & 35 deletions src/components/measurements/measurementsD3.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { scaleLinear } from "d3-scale";
import { select, event as d3event } from "d3-selection";
import { symbol, symbolDiamond } from "d3-shape";
import { orderBy } from "lodash";
import { measurementIdSymbol, measurementJitterSymbol } from "../../util/globals";
import { measurementIdSymbol } from "../../util/globals";
import { getBrighterColor } from "../../util/colorHelpers";

/* C O N S T A N T S */
Expand All @@ -14,12 +14,14 @@ export const layout = {
leftPadding: 180,
rightPadding: 30,
topPadding: 20,
bottomPadding: 50,
xAxisHeight: 50,
subplotHeight: 100,
subplotPadding: 10,
circleRadius: 3,
circleHoverRadius: 5,
circleStrokeWidth: 1,
circleFillOpacity: 0.5,
circleStrokeOpacity: 0.75,
thresholdStrokeWidth: 2,
thresholdStroke: "#DDD",
subplotFill: "#adb1b3",
Expand Down Expand Up @@ -103,10 +105,13 @@ export const groupMeasurements = (measurements, groupBy, groupByValueOrder) => {
"asc");
};

export const clearMeasurementsSVG = (ref) => {
export const clearMeasurementsSVG = (ref, xAxisRef) => {
select(ref)
.attr("height", null)
.selectAll("*").remove();

select(xAxisRef)
.selectAll("*").remove();
};

const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, colorBy, xScale, yValue) => {
Expand Down Expand Up @@ -141,48 +146,72 @@ const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, colo
}
};

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

// Do not draw SVG if there are no measurements
if (groupedMeasurements && groupedMeasurements.length === 0) return;

const drawStickyXAxis = (ref, containerHeight, svgHeight, xScale, x_axis_label) => {
const svg = select(ref);
const svgWidth = svg.node().getBoundingClientRect().width;

// The number of groups is the number of subplots, which determines the final SVG height
const totalSubplotHeight = (layout.subplotHeight * groupedMeasurements.length);
const svgHeight = totalSubplotHeight + layout.topPadding + layout.bottomPadding;
svg.attr("height", svgHeight);

// Add threshold if provided
if (threshold !== null) {
const thresholdXValue = xScale(threshold);
svg.append("line")
.attr("class", classes.threshold)
.attr("x1", thresholdXValue)
.attr("x2", thresholdXValue)
.attr("y1", layout.topPadding)
.attr("y2", svgHeight - layout.bottomPadding)
.attr("stroke-width", layout.thresholdStrokeWidth)
.attr("stroke", layout.thresholdStroke)
// Hide threshold by default since another function will toggle display
.attr("display", "none");
}

// Add x-axis to the bottom of the SVG
// (above the bottomPadding to leave room for the x-axis label)
/**
* Add top sticky-constraint to make sure the x-axis is always visible
* Uses the minimum constraint to keep x-axis directly at the bottom of the
* measurements SVG even when the SVG is smaller than the container
*/
const stickyTopConstraint = Math.min((containerHeight - layout.xAxisHeight), svgHeight);
svg.style("top", `${stickyTopConstraint}px`);

// Add white background rect so the axis doesn't overlap with underlying measurements
svg.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("height", "100%")
.attr("width", "100%")
.attr("fill", "white")
.attr("fill-opacity", 1);

// Draw sticky x-axis
const svgWidth = svg.node().getBoundingClientRect().width;
svg.append("g")
.attr("class", classes.xAxis)
.attr("transform", `translate(0, ${svgHeight - layout.bottomPadding})`)
.call(axisBottom(xScale))
.call((g) => g.attr("font-family", null))
.append("text")
.attr("x", layout.leftPadding + ((svgWidth - layout.leftPadding - layout.rightPadding)) / 2)
.attr("y", layout.bottomPadding * 2 / 3)
.attr("y", layout.xAxisHeight * 2 / 3)
.attr("text-anchor", "middle")
.attr("fill", "currentColor")
.text(x_axis_label);
};

export const drawMeasurementsSVG = (ref, xAxisRef, svgData) => {
const {containerHeight, xScale, yScale, x_axis_label, thresholds, groupingOrderedValues, groupedMeasurements} = svgData;

// Do not draw SVG if there are no measurements
if (groupedMeasurements && groupedMeasurements.length === 0) return;

const svg = select(ref);

// The number of groups is the number of subplots, which determines the final SVG height
const totalSubplotHeight = (layout.subplotHeight * groupedMeasurements.length);
const svgHeight = totalSubplotHeight + layout.topPadding;
svg.attr("height", svgHeight);

// x-axis is in a different SVG element to allow sticky positioning
drawStickyXAxis(xAxisRef, containerHeight, svgHeight, xScale, x_axis_label);

// Add threshold(s) if provided
if (thresholds !== null) {
for (const threshold of thresholds) {
const thresholdXValue = xScale(threshold);
svg.append("line")
.attr("class", classes.threshold)
.attr("x1", thresholdXValue)
.attr("x2", thresholdXValue)
.attr("y1", layout.topPadding)
.attr("y2", svgHeight)
.attr("stroke-width", layout.thresholdStrokeWidth)
.attr("stroke", layout.thresholdStroke)
// Hide threshold by default since another function will toggle display
.attr("display", "none");
}
}

// Create a subplot for each grouping
let prevSubplotBottom = layout.topPadding;
Expand Down Expand Up @@ -237,6 +266,7 @@ export const drawMeasurementsSVG = (ref, svgData) => {
});

// Add circles for each measurement
// Note, "cy" is added later when jittering within color-by groups
subplot.append("g")
.attr("class", classes.rawMeasurementsGroup)
.attr("display", "none")
Expand All @@ -247,8 +277,9 @@ export const drawMeasurementsSVG = (ref, svgData) => {
.attr("class", classes.rawMeasurements)
.attr("id", (d) => getMeasurementDOMId(d))
.attr("cx", (d) => xScale(d.value))
.attr("cy", (d) => yScale(d[measurementJitterSymbol]))
.attr("r", layout.circleRadius)
.attr("fill-opacity", layout.circleFillOpacity)
.attr("stroke-opacity", layout.circleStrokeOpacity)
.on("mouseover.radius", (d, i, elements) => {
select(elements[i]).transition()
.duration("100")
Expand Down Expand Up @@ -280,6 +311,45 @@ export const colorMeasurementsSVG = (ref, treeStrainColors) => {
.style("fill", (d) => getBrighterColor(treeStrainColors[d.strain].color));
};

export const jitterRawMeansByColorBy = (ref, svgData, treeStrainColors, legendValues) => {
const { groupedMeasurements } = svgData;
const svg = select(ref);

groupedMeasurements.forEach(([_, measurements]) => {
// For each color-by attribute, create an array of measurement DOM ids
const colorByGroups = {};
measurements.forEach((measurement) => {
const { attribute } = treeStrainColors[measurement.strain];
colorByGroups[attribute] = colorByGroups[attribute] || [];
colorByGroups[attribute].push(getMeasurementDOMId(measurement));
});
// Calculate total available subplot height
// Accounts for top/bottom padding and padding between color-by groups
const numberOfColorByAttributes = Object.keys(colorByGroups).length;
const totalColorByPadding = (numberOfColorByAttributes - 1) * 2 * layout.circleRadius;
const availableSubplotHeight = layout.subplotHeight - (2*layout.subplotPadding) - totalColorByPadding;

let currentYMin = layout.subplotPadding;
Object.keys(colorByGroups)
// Sort by legendValues for stable ordering of color-by groups
.sort((a, b) => legendValues.indexOf(a) - legendValues.indexOf(b))
.forEach((attribute) => {
// Calculate max Y value for each color-by attribute
// This is determined by the proportion of measurements in each attribute group
const domIds = colorByGroups[attribute];
const proportionOfMeasurements = domIds.length / measurements.length;
const currentYMax = currentYMin + (proportionOfMeasurements * availableSubplotHeight);
// Jitter "cy" value for each raw measurement
domIds.forEach((domId) => {
const jitter = Math.random() * (currentYMax - currentYMin) + currentYMin;
svg.select(`#${domId}`).attr("cy", jitter);
});
// Set next min Y value for next color-by attribute group
currentYMin = currentYMax + (2 * layout.circleRadius);
});
});
};

export const drawMeansForColorBy = (ref, svgData, treeStrainColors, legendValues) => {
const { xScale, groupingOrderedValues, groupedMeasurements } = svgData;
const svg = select(ref);
Expand Down
1 change: 0 additions & 1 deletion src/util/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ export const isValueValid = (value) => {
export const strainSymbol = Symbol('strain');
export const genotypeSymbol = Symbol('genotype');
export const measurementIdSymbol = Symbol('measurementId');
export const measurementJitterSymbol = Symbol('measurementJitter');

/**
* Address to fetch tiles from (including access key).
Expand Down