-
Notifications
You must be signed in to change notification settings - Fork 164
/
Copy pathindex.js
365 lines (339 loc) · 13.5 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
import React, { useCallback, useRef, useEffect, useMemo, useState } from "react";
import { 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 from "./hoverPanel";
import {
createXScale,
createYScale,
groupMeasurements,
clearMeasurementsSVG,
drawMeasurementsSVG,
drawMeansForColorBy,
colorMeasurementsSVG,
changeMeasurementsDisplay,
svgContainerDOMId,
toggleDisplay,
addHoverPanelToMeasurementsAndMeans,
addColorByAttrToGroupingLabel,
layout,
jitterRawMeansByColorBy
} from "./measurementsD3";
/**
* 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.
* @param {*} value
* @returns {*}
*/
const useDeepCompareMemo = (value) => {
const ref = useRef();
if (!isEqual(value, ref.current)) {
ref.current = value;
}
return ref.current;
};
// Checks visibility against global NODE_VISIBLE
const isVisible = (visibility) => 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
* @param {Object} state
* @returns {Object<string,Object>}
*/
const treeStrainPropertySelector = (state) => {
const { tree, controls } = state;
const { colorScale } = controls;
const intitialTreeStrainProperty = {
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;
}, intitialTreeStrainProperty);
};
/**
* 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
* @param {Array<Object>} measurements
* @param {Object<string,number>} treeStrainVisibility
* @param {Object<string,Map>} filters
* @returns {Object<Object, Array>}
*/
const filterMeasurements = (measurements, treeStrainVisibility, filters) => {
// Find active filters to filter measurements
const activeFilters = {};
Object.entries(filters).forEach(([field, valuesMap]) => {
activeFilters[field] = activeFilters[field] || [];
valuesMap.forEach(({active}, fieldValue) => {
// Save array of active values for the field filter
if (active) activeFilters[field].push(fieldValue);
});
});
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
for (const [field, values] of Object.entries(activeFilters)) {
if (values.length > 0 && !values.includes(measurement[field])) return false;
}
return true;
})
};
};
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 legendValues = useSelector((state) => state.controls.colorScale.legendValues, 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);
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, 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);
// 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, 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") {
// 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 (dataType === "mean") {
// 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: ${dataType}`);
// 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]);
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);
}, [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]);
// 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]);
const getSVGContainerStyle = () => {
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 = () => {
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} 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"
ref={d3Ref}
style={getMainSVGStyle()}
/>
</div>
</>
);
};
const Measurements = ({height, width, showLegend}) => {
const measurementsLoaded = useSelector((state) => state.measurements.loaded);
const measurementsError = useSelector((state) => state.measurements.error);
const showOnlyPanels = useSelector((state) => state.controls.showOnlyPanels);
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 infocard={showOnlyPanels} title={title} titleStyles={getCardTitleStyle()}>
{measurementsLoaded &&
(measurementsError ?
<Flex style={{ height, width}} direction="column" justifyContent="center">
<p style={{ textAlign: "center" }}>
{measurementsError}
</p>
</Flex> :
<MeasurementsPlot
height={height}
width={width}
showLegend={showLegend}
setPanelTitle={setTitle}
/>
)
}
</Card>
);
};
export default Measurements;