-
Notifications
You must be signed in to change notification settings - Fork 166
/
Copy pathmeasurementsD3.js
531 lines (485 loc) · 21 KB
/
measurementsD3.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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
import { extent, groups, mean, deviation } from "d3-array";
import { axisBottom, axisLeft } from "d3-axis";
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 } from "../../util/globals";
import { getBrighterColor } from "../../util/colorHelpers";
/* C O N S T A N T S */
export const layout = {
yMin: 0,
yMax: 100,
leftPadding: 180,
rightPadding: 30,
topPadding: 20,
xAxisHeight: 50,
subplotHeight: 100,
subplotPadding: 10,
circleRadius: 3,
circleHoverRadius: 5,
circleStrokeWidth: 1,
circleFillOpacity: 0.5,
circleStrokeOpacity: 0.75,
thresholdStrokeWidth: 2,
thresholdStroke: "#DDD",
subplotFill: "#adb1b3",
subplotFillOpacity: "0.15",
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",
groupingValue: "measurementGroupingValue",
yAxisColorByLabel: "measurementYAxisColorByLabel",
threshold: "measurementThreshold",
subplot: "measurementSubplot",
subplotBackground: "measurementSubplotBackground",
rawMeasurements: "rawMeasurements",
rawMeasurementsGroup: "rawMeasurementsGroup",
overallMean: "measurementsOverallMean",
colorMean: "measurementsColorMean",
mean: "mean",
standardDeviation: "standardDeviation"
};
export const svgContainerDOMId = "measurementsSVGContainer";
const getMeasurementDOMId = (measurement) => `meaurement_${measurement[measurementIdSymbol]}`;
const getSubplotDOMId = (groupingValueIndex) => `measurement_subplot_${groupingValueIndex}`;
/**
* Creates the D3 linear scale for the x-axis with the provided measurements'
* values as the domain and the panelWidth with hard-coded padding values as
* the range. The optional paddingProportion can be provided to include additional
* padding for the domain. Expected to be shared across all subplots.
* @param {number} panelWidth
* @param {Array<Object>} measurements
* @param {number} [paddingProportion=0.1]
* @returns {function}
*/
export const createXScale = (panelWidth, measurements, paddingProportion = 0.1) => {
// Padding the xScale based on proportion
// Copied from https://github.com/d3/d3-scale/issues/150#issuecomment-561304239
function padLinear([x0, x1], k) {
const dx = (x1 - x0) * k / 2;
return [x0 - dx, x1 + dx];
}
return (
scaleLinear()
.domain(padLinear(extent(measurements, (m) => m.value), paddingProportion))
.range([layout.leftPadding, panelWidth - layout.rightPadding])
.nice()
);
};
/**
* Creates the D3 linear scale for the y-axis of each individual subplot with
* the hardcoded yMin and yMax with circle diameter as the domain and the hard-coded
* subplot height as the range.
* @returns {function}
*/
export const createYScale = () => {
// Account for circle diameter so the plotted circles do not get cut off
const domainMin = layout.yMin - (2 * layout.circleRadius);
const domainMax = layout.yMax + (2 * layout.circleRadius);
return (
scaleLinear()
.domain([domainMin, domainMax])
.range([layout.subplotHeight, 0])
.nice()
);
};
/**
* Uses D3.groups() to aggregate measurements into a nested array of groups
* The groups are sorted by the order of values in the provided groupByValueOrder.
* @param {Array<Object>} measurements
* @param {string} groupBy
* @param {Array<string>} groupByValueOrder
* @returns {Array<Array<string, Array>>}
*/
export const groupMeasurements = (measurements, groupBy, groupByValueOrder) => {
return orderBy(
groups(measurements, (d) => d[groupBy]),
([groupingValue]) => groupByValueOrder.indexOf(groupingValue),
"asc");
};
export const clearMeasurementsSVG = (ref, xAxisRef) => {
select(ref)
.attr("height", null)
.selectAll("*").remove();
select(xAxisRef)
.selectAll("*").remove();
};
const drawMeanAndStandardDeviation = (values, d3ParentNode, containerClass, colorBy, xScale, yValue) => {
const meanAndStandardDeviation = {
colorByAttr: colorBy.attribute,
mean: mean(values),
standardDeviation: deviation(values)
};
// Container for both mean and standard deviation
const container = d3ParentNode.append("g")
.attr("class", containerClass)
.attr("display", "none")
.selectAll("meanAndStandardDeviation")
.data([meanAndStandardDeviation])
.enter();
container.append("path")
.attr("class", classes.mean)
.attr("transform", (d) => `translate(${xScale(d.mean)}, ${yValue})`)
.attr("d", symbol().type(symbolDiamond).size(layout.diamondSize))
.attr("fill", colorBy.color);
if (meanAndStandardDeviation.standardDeviation !== undefined) {
container.append("line")
.attr("class", classes.standardDeviation)
.attr("x1", (d) => xScale(d.mean - d.standardDeviation))
.attr("x2", (d) => xScale(d.mean + d.standardDeviation))
.attr("y1", yValue)
.attr("y2", yValue)
.attr("stroke-width", layout.standardDeviationStroke)
.attr("stroke", colorBy.color);
}
};
const drawStickyXAxis = (ref, containerHeight, svgHeight, xScale, x_axis_label) => {
const svg = select(ref);
/**
* 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)
.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.xAxisHeight * 2 / 3)
.attr("text-anchor", "middle")
.attr("fill", "currentColor")
.text(x_axis_label);
};
export const drawMeasurementsSVG = (ref, xAxisRef, svgData, handleClickOnGrouping) => {
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 (Array.isArray(thresholds)) {
for (const threshold of thresholds) {
if (typeof threshold !== "number") continue;
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;
groupedMeasurements.forEach(([groupingValue, measurements], index) => {
// Make each subplot its own SVG to re-use the same subplot yScale
const subplot = svg.append("svg")
.attr("class", classes.subplot)
.attr("id", getSubplotDOMId(groupingOrderedValues.indexOf(groupingValue)))
.attr("width", "100%")
.attr("height", layout.subplotHeight)
.attr("y", prevSubplotBottom);
// Add subplot height to keep track of y position for next subplot
prevSubplotBottom += layout.subplotHeight;
// Add a rect to fill the entire width with a light grey background for every other group
subplot.append("rect")
.attr("class", classes.subplotBackground)
.attr("x", 0)
.attr("y", 0)
.attr("height", "100%")
.attr("width", "100%")
.attr("fill", index % 2 ? layout.subplotFill : "none")
.attr("fill-opacity", layout.subplotFillOpacity);
// Add y axis with a single tick that displays the grouping value
subplot.append("g")
.attr("class", classes.yAxis)
.attr("transform", `translate(${layout.leftPadding}, 0)`)
.attr("cursor", "pointer")
.call(
axisLeft(yScale)
.tickValues([yScale((layout.yMax - layout.yMin) / 2)])
.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're always limits of the available space so punting that for now.
// -Jover, 20 September 2022
g.selectAll('text')
.attr("class", classes.groupingValue)
.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;
});
})
.on("click", () => handleClickOnGrouping(groupingValue));
// 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")
.selectAll("dot")
.data(measurements)
.enter()
.append("circle")
.attr("class", classes.rawMeasurements)
.attr("id", (d) => getMeasurementDOMId(d))
.attr("cx", (d) => xScale(d.value))
.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")
.attr("r", layout.circleHoverRadius);
})
.on("mouseout.radius", (_, i, elements) => {
select(elements[i]).transition()
.duration("200")
.attr("r", layout.circleRadius);
});
// Draw overall mean and standard deviation for measurement values
drawMeanAndStandardDeviation(
measurements.map((d) => d.value),
subplot,
classes.overallMean,
{attribute: null, color: layout.overallMeanColor},
xScale,
layout.overallMeanYValue
);
});
};
export const colorMeasurementsSVG = (ref, treeStrainColors) => {
const svg = select(ref);
svg.selectAll(`.${classes.rawMeasurements}`)
.style("stroke", (d) => treeStrainColors[d.strain].color)
.style("stroke-width", layout.circleStrokeWidth)
.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);
// Remove all current color by means
svg.selectAll(`.${classes.colorMean}`).remove();
// Calc and draw color by means for each group
groupedMeasurements.forEach(([groupingValue, measurements]) => {
// For each color-by attribute, create an array of measurement values and keep track of color
const colorByGroups = {};
measurements.forEach((measurement) => {
const { attribute, color } = treeStrainColors[measurement.strain];
colorByGroups[attribute] = colorByGroups[attribute] || {color: null, values: []};
colorByGroups[attribute].values.push(measurement.value);
if (!colorByGroups[attribute].color) {
colorByGroups[attribute].color = color;
}
});
// Plot mean/SD for each color-by attribute within subplot
const subplot = svg.select(`#${getSubplotDOMId(groupingOrderedValues.indexOf(groupingValue))}`);
const numberOfColorByAttributes = Object.keys(colorByGroups).length;
// Calc space between means to evenly spread them within subplot
// 2 x subplotPadding for padding of top and bottom of each subplot
// 2 x subplotPadding for padding around the overall mean display
const ySpacing = (layout.subplotHeight - 4 * layout.subplotPadding) / (numberOfColorByAttributes - 1);
let yValue = layout.subplotPadding;
// Order the color groups by the legend value order so that we have a stable order for the means
const orderedColorGroups = orderBy(
Object.keys(colorByGroups),
(key) => legendValues.indexOf(key),
"asc"
);
orderedColorGroups
.forEach((attribute) => {
const {color, values} = colorByGroups[attribute];
drawMeanAndStandardDeviation(
values,
subplot,
classes.colorMean,
{attribute, color},
xScale,
yValue
);
// Increase yValue for next attribute mean
yValue += ySpacing;
// If the next yValue is near the overall mean display,
// shift to below the overall mean display + subplotPadding
if (yValue > (layout.overallMeanYValue - layout.subplotPadding) &&
yValue < (layout.overallMeanYValue + layout.subplotPadding)) {
yValue = layout.overallMeanYValue + layout.subplotPadding;
}
});
});
};
export const changeMeasurementsDisplay = (ref, display) => {
const svg = select(ref);
const dataDisplayClasses = {
raw: classes.rawMeasurementsGroup,
mean: classes.colorMean
};
Object.entries(dataDisplayClasses).forEach(([displayOption, displayClass]) => {
svg.selectAll(`.${displayClass}`)
.attr("display", display === displayOption ? null : "none");
});
};
export const toggleDisplay = (ref, elementClass, displayOn) => {
const displayAttr = displayOn ? null : "none";
select(ref)
.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, 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.select(`.${classes.groupingValue}`).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})`);
}
});
};
const colorGroupingCrosshairId = "measurementsColorGroupingCrosshair";
export const removeColorGroupingCrosshair = (ref) => {
const svg = select(ref);
svg.select(`#${colorGroupingCrosshairId}`).remove();
};
export const addGroupingValueCrosshair = (ref, groupingValue) => {
// Remove previous color grouping crosshair
removeColorGroupingCrosshair(ref);
const svg = select(ref);
svg.selectAll(`.${classes.yAxis}`).select(".tick")
.each((_, i, elements) => {
const groupingLabel = select(elements[i]);
const currentGroupingValue = groupingLabel.select(`.${classes.groupingValue}`).text()
if (groupingValue === currentGroupingValue){
const {width} = groupingLabel.node().getBoundingClientRect();
groupingLabel.append("svg")
.attr("id", colorGroupingCrosshairId)
.attr("stroke", "currentColor")
.attr("fill", "currentColor")
.attr("strokeWidth", "0")
.attr("viewBox", "0 0 256 256")
.attr("height", layout.yAxisColorByLineHeight * 2)
.attr("width", layout.yAxisColorByLineHeight * 2)
.attr("x", -width - (layout.yAxisColorByLineHeight * 2))
.attr("y", -layout.yAxisColorByLineHeight)
.append("path")
// path copied from react-icons/pi/PiCrosshairSimpleBold
.attr("d", "M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm12,191.13V184a12,12,0,0,0-24,0v27.13A84.18,84.18,0,0,1,44.87,140H72a12,12,0,0,0,0-24H44.87A84.18,84.18,0,0,1,116,44.87V72a12,12,0,0,0,24,0V44.87A84.18,84.18,0,0,1,211.13,116H184a12,12,0,0,0,0,24h27.13A84.18,84.18,0,0,1,140,211.13Z" )
}
});
}