Merge pull request #5205 from solgenomics/topic/generic_trial_upload
[sgn.git] / js / source / entries / dataset_scatterplot.js
blob220d9f315a197d19ae2b2f28cbdbba8cd77b4dc5
1 export function init(datasetId, datasetName) {
2     class Dataset {
3         constructor() {
4             this.datasets = {};
5             this.data = [];
6             // store phenotype id, trait, value
7             this.outliers = [];
8             this.outlierCutoffs = new Set();
9             this.firstRefresh = true;
10             this.stdDevMultiplier = $("#outliers_range").slider("value");
11             this.selection = "default";
12             this.datasetId = datasetId;
13             this.observations = {};
14             this.traits = {};
15             this.traitsIds = {};
16             this.phenoIds = [];
17             this.traitVals = [];
18             this.storedOutliersIds = [];
19             this.metricValue = document.querySelector('input[name="dataset_metric"]:checked').value;
20         }
22         getPhenotypes() {
23             const LocalThis = this;
24             this.firstRefresh = false;
25             new jQuery.ajax({
26                 url: '/ajax/dataset/retrieve/' + this.datasetId + '/phenotypes?include_phenotype_primary_key=1',
27                 success: function (response) {
28                     LocalThis.observations = response.phenotypes;
29                     LocalThis.setDropDownTraits();
30                 },
31                 error: function (response) {
32                     alert('Error');
33                 }
34             })
35         }
37         getTraits() {
38             const LocalThis = this;
39             this.firstRefresh = false;
40             new jQuery.ajax({
41                 url: '/ajax/dataset/retrieve/' + this.datasetId + '/traits',
42                 success: function (response) {
43                     LocalThis.traitsIds = response.traits.map(
44                         trait => trait[0]
45                     );
46                     // console.log(LocalThis.traitsIds);
47                 },
48                 error: function (response) {
49                     alert('Error');
50                 }
51             })
52         }
54         getStoredOutliers() {
55             const LocalThis = this;
56             new jQuery.ajax({
57                 type: 'POST',
58                 url: '/ajax/dataset/retrieve_outliers/' + LocalThis.datasetId,
59                 success: function (response) {
60                     LocalThis.storedOutliersIds = response.outliers !== null ? response.outliers : [];
61                 },
62                 error: function (response) {
63                     alert('Error');
64                 }
65             })
66         }
68         setDropDownTraits() {
69             const keys = this.observations[0];
70             // Construct trait object
71             for (let i = 39; i < keys.length - 1; i++) {
72                 if (i % 2 == 1 && i <= keys.length - 2) {
73                     this.traits[keys[i]] = {};
74                 }
75             }
77             for (let i = 1; i < this.observations.length; i++) {
78                 // Goes through each observation, and populates the traits hash with each trait, using the phenotype id as the key, and the traitValue as the value.
79                 for (let j = 39; j < this.observations[i].length - 1; j++) {
80                     if (j % 2 == 1) {
81                         this.traits[keys[j]][this.observations[i][j + 1]] = this.observations[i][j];
82                     }
83                 }
84             }
85             // Use traits to set select options
86             const select = document.getElementById("trait_selection");
87             for (const traitName of Object.keys(this.traits)) {
88                 const option = document.createElement("option");
89                 option.value = traitName;
90                 option.innerHTML = traitName;
91                 select.appendChild(option);
92             }
94         }
96         setData() {
97             if (this.selection != "default") {
98                 // Gets a list of pheno ids, filters them so only the ones that have non null values are included and then sorts the ids by their value by looking up their values in traits hash.
100                 // console.log(this.traits);
101                 this.phenoIds = Object.keys(this.traits[this.selection])
102                     .filter((phenoId) => !isNaN(parseFloat(this.traits[this.selection][phenoId])))
103                     .sort((a, b) => this.traits[this.selection][a] - this.traits[this.selection][b]);
105                 // console.log(this.phenoIds);
107                 this.traitVals = this.phenoIds.map((id) => parseFloat(this.traits[this.selection][id]));
108                 // console.log(this.traitVals);
109                 // Debugging check: You should see a list of ids and the corresponding values, logs should be sorted by increasing values.
110                 // for (let id of this.phenoIds) {
111                 //   console.log(id, this.traits[this.selection][id].value);
112                 // }
113             }
114         }
116         standardDeviation(values) {
117             var average = function (data) {
118                 var sum = data.reduce(function (sum, value) {
119                     return sum + value;
120                 }, 0);
122                 var avg = sum / data.length;
123                 return avg;
124             }
126             var avg = average(values);
128             var squareDiffs = values.map(function (value) {
129                 var diff = value - avg;
130                 var sqrDiff = diff * diff;
131                 return sqrDiff;
132             });
134             var avgSquareDiff = average(squareDiffs);
136             var stdDev = Math.sqrt(avgSquareDiff);
137             return [avg, stdDev];
138         }
140         median(values) {
141             if (!Array.isArray(values)) return 0;
142             let sorted = [...values].sort((a, b) => a - b);
143             let middle = Math.floor(sorted.length / 2);
145             if (sorted.length % 2 === 0) {
146                 return (sorted[middle - 1] + sorted[middle]) / 2;
147             }
149             return sorted[middle];
150         } // OK - tested
152         mad(values) {
153             // MAD = Median(|Xi - Median(Xi)|)
154             if (!Array.isArray(values)) return 0;
155             let medianValue = this.median(values);
156             let medianMap = values.map(x => Math.abs(x - medianValue));
158             return this.median(medianMap);
159         } // OK - tested
162         quartile(values, factor) {
163             if (!Array.isArray(values)) return 0;
165             let sorted = [...values].sort((a, b) => a - b);
166             let index = (sorted.length - 1) * factor;
167             let lowerIndex = Math.floor(index);
168             let upperIndex = Math.ceil(index);
169             let interpolation = index - lowerIndex;
170             return sorted[lowerIndex] * (1 - interpolation) + sorted[upperIndex] * interpolation;
171         }
174         iqr(values) {
175             let q1 = this.quartile(values, 0.25);
176             let q3 = this.quartile(values, 0.75);
177             return q3 - q1;
178         }
180         addEventListeners() {
181             let LocalThis = this;
183             // Handle Slider Events
184             var sliderSelector = $("#outliers_range");
185             sliderSelector.on("slidechange", (event, ui) => {
186                 LocalThis.stdDevMultiplier = ui.value;
187                 LocalThis.outliers = [];
188                 LocalThis.outlierCutoffs = new Set();
189                 d3.select("svg").remove();
190                 LocalThis.render();
191             })
192             // Handle Metric Radio                      
193             var metricSelectors = document.querySelectorAll('input[type=radio][name="dataset_metric"]');
195             Array.prototype.forEach.call(metricSelectors, (metricRadio) => {
196                 metricRadio.addEventListener("change", (event) => {
199                     LocalThis.metricValue = document.querySelector('input[name="dataset_metric"]:checked').value;
200                     d3.select("svg").remove();
201                     LocalThis.render();
202                 })
203             })
205             // Handle Select Events
206             let selection = document.getElementById("trait_selection");
207             selection.addEventListener("change", (event) => {
208                 d3.select("svg").remove();
209                 LocalThis.selection = event.target.value;
210                 LocalThis.setData();
211                 LocalThis.outliers = [];
212                 LocalThis.outlierCutoffs = new Set();
213                 sliderSelector.slider("option", "value", 3);
214                 LocalThis.stdDevMultiplier = sliderSelector.slider("value");
215                 if (!this.firstRefresh) {
216                     d3.select("svg").remove();
217                     LocalThis.render();
218                     removeRosnserTable();
219                 }
220             });
222             let storeOutliersButton = document.getElementById("store_outliers");
223             storeOutliersButton.onclick = function () {
224                 let allOutliers = new Set(LocalThis.storedOutliersIds.concat(LocalThis.outliers));
225                 let stringOutliers = [...allOutliers].join(',');
226                 let stringOutlierCutoffs = [...LocalThis.outlierCutoffs].join(',');
227                 new jQuery.ajax({
228                     type: 'POST',
229                     url: '/ajax/dataset/store_outliers/' + LocalThis.datasetId,
230                     data: { outliers: stringOutliers, outlier_cutoffs: stringOutlierCutoffs },
231                     success: function (response) {
232                         alert('outliers successfully stored!');
233                         LocalThis.storedOutliersIds = [...allOutliers];
234                         d3.select("svg").remove();
235                         LocalThis.render();
236                     },
237                     error: function (response) {
238                         alert('Error');
239                     }
240                 })
241             }
243             let resetOutliersButton = document.getElementById("reset_outliers");
244             resetOutliersButton.onclick = function () {
245                 new jQuery.ajax({
246                     type: 'POST',
247                     url: '/ajax/dataset/store_outliers/' + LocalThis.datasetId,
248                     data: { outliers: "", outlier_cutoffs: "" },
249                     success: function (response) {
250                         alert('outliers successfully reseted!');
251                         LocalThis.storedOutliersIds = [];
252                         d3.select("svg").remove();
253                         LocalThis.render();
254                         removeRosnserTable();
255                     },
256                     error: function (response) {
257                         alert('Error');
258                     }
259                 })
260             }
262             let resetTraitOutliersButton = document.getElementById("reset_trait");
263             resetTraitOutliersButton.onclick = function () {
264                 let filteredNonTrait = LocalThis.storedOutliersIds.filter(elem => !LocalThis.phenoIds.includes(elem));
265                 let stringFilteredNonTrait = filteredNonTrait.join(',');
266                 new jQuery.ajax({
267                     type: 'POST',
268                     url: '/ajax/dataset/store_outliers/' + LocalThis.datasetId,
269                     data: { outliers: stringFilteredNonTrait, outlier_cutoffs: "" },
270                     success: function (response) {
271                         alert('outliers successfully reseted!');
272                         LocalThis.storedOutliersIds = filteredNonTrait;
273                         d3.select("svg").remove();
274                         LocalThis.render();
275                         removeRosnserTable();
276                     },
277                     error: function (response) {
278                         alert('Error');
279                     }
280                 })
281             }
283             let rosnersTestButton = document.getElementById("rosner_test");
284             rosnersTestButton.onclick = function () {
285                 if (document.getElementById("trait_selection").value == 'default') return;
286                 // add spinner 
287                 document.getElementById("loading-spinner").style.visibility = 'visible';
288                 new jQuery.ajax({
289                     type: 'POST',
290                     url: '/ajax/dataset/rosner_test/' + LocalThis.datasetId,
291                     data: { dataset_trait: document.getElementById("trait_selection").value },
292                     success: function (response) {
293                         // alert(response.message);
294                         createOutlierTable(response.file);
295                         document.getElementById("loading-spinner").style.visibility = 'hidden';
296                     },
297                     error: function (response) {
298                         alert('Error');
299                         document.getElementById("loading-spinner").style.visibility = 'visible';
300                     }
301                 })
302             }
304             function createOutlierTable(file) {
305                 // check if exist and remove
306                 let data_rows = file.slice(1, file.length);
307                 let column_names = file[0].map(value => ({ "title": value }));
308                 let outliers = data_rows.filter((elem) => { return elem[7] == 'TRUE' }).map((elem) => elem[4]);
310                 removeRosnserTable();
312                 let table = document.createElement("table");
313                 table.setAttribute("id", "rosner_table");
314                 table.classList = "display";
315                 document.getElementById("statistic_tests").appendChild(table);
317                 let addRosnerButton = document.createElement("button");
318                 if (outliers.length == 0) {
319                     addRosnerButton.disabled = true;
320                 }
321                 addRosnerButton.setAttribute("id", "rosner_add");
322                 addRosnerButton.classList = "btn btn-sm btn-success btn-dataset";
323                 addRosnerButton.textContent = "Add Rosner test outliers";
324                 addRosnerButton.addEventListener('click', function () {
325                     addRosnserOutliers(outliers);
326                 });
328                 document.getElementById("statistic_tests").appendChild(addRosnerButton);
330                 jQuery('#rosner_table').DataTable({
331                     columns: column_names,
332                     data: data_rows,
333                     searching: false,
334                     paging: false,
335                     info: false
336                 });
338                 return false;
339             }
341             function addRosnserOutliers(rosnerOutliers) {
342                 if (rosnerOutliers.length == 0) {
343                     return;
344                 }
345                 // console.log(rosnerOutliers);
347                 let allOutliers = new Set(LocalThis.storedOutliersIds.concat(rosnerOutliers));
348                 let stringOutliers = [...allOutliers].join(',');
349                 let stringOutlierCutoffs = [...LocalThis.outlierCutoffs].join(',');
350                 new jQuery.ajax({
351                     type: 'POST',
352                     url: '/ajax/dataset/store_outliers/' + LocalThis.datasetId,
353                     data: { outliers: stringOutliers, outlier_cutoffs: stringOutlierCutoffs },
354                     success: function (response) {
355                         alert('outliers successfully stored!');
356                         LocalThis.storedOutliersIds = [...allOutliers];
357                         d3.select("svg").remove();
358                         LocalThis.render();
359                     },
360                     error: function (response) {
361                         alert('Error');
362                     }
363                 })
365                 return false;
366             }
368             function removeRosnserTable() {
369                 if ($.fn.DataTable.isDataTable("#rosner_table")) {
370                     jQuery('#rosner_table').DataTable().destroy();
371                 }
372                 if (document.getElementById("rosner_table")) {
373                     document.getElementById("rosner_table").remove();
374                     document.getElementById("rosner_add").remove();
375                 }
376             }
377         }
381         render() {
382             if (this.firstRefresh) {
383                 this.getPhenotypes();
384                 this.getTraits();
385                 this.getStoredOutliers();
386                 this.addEventListeners();
387             } else if (this.selection != "default") {
389                 const LocalThis = this;
391                 const [mean, stdDev] = this.standardDeviation(this.traitVals);
392                 const [median, mad] = [this.median(this.traitVals), this.mad(this.traitVals)];
393                 const [quartiles, factor] = [[this.quartile(this.traitVals, 0.25), this.quartile(this.traitVals, 0.75)], this.iqr(this.traitVals)];
395                 let settings;
396                 switch (LocalThis.metricValue) {
397                     case "mean":
398                         settings = [mean, stdDev, "Mean", "Std Dev"];
399                         break;
400                     case "median":
401                         settings = [median, mad, "Median", "MAD"];
402                         break;
403                     case "iqr":
404                         settings = [quartiles, factor, "Q1,Q3", "IQR"];
405                         break;
406                     default:
407                         settings = [median, mad, "Median", "MAD"];
408                 }
409                 const [metric, deviation, metricString, deviationString] = settings;
411                 let filter = LocalThis.stdDevMultiplier;
413                 function rightCutoffCalc() {
414                     if (LocalThis.metricValue == "iqr") {
415                         return quartiles[1] + factor * filter;
416                     } else {
417                         return metric + deviation * filter;
418                     }
419                 }
421                 function leftCutoffCalc() {
422                     if (LocalThis.metricValue == "iqr") {
423                         return quartiles[0] - factor * filter;
424                     } else {
425                         return Math.max(metric - deviation * filter, 0);
426                     }
427                 }
429                 let rightCutoff = rightCutoffCalc();
430                 let leftCutoff = leftCutoffCalc();
432                 this.outliers = [];
434                 const margin = { top: 10, right: 30, bottom: 30, left: 60 },
435                     width = 1180 - margin.left - margin.right,
436                     height = 600 - margin.top - margin.bottom;
438                 var svg = d3.select("#trait_graph")
439                     .append("svg")
440                     .attr("width", width + margin.left + margin.right)
441                     .attr("height", height + margin.top + margin.bottom)
442                     .append("g")
443                     .attr(
444                         "transform",
445                         "translate(" + margin.left + "," + margin.top + ")"
446                     );
448                 const greenColor = "#00ba38"
449                     , yellowColor = "#ffe531"
450                     , redColor = "#f7756c"
451                     , blueColor = "#337ab7";
453                 var isOutlier = function (id, value, leftCutoff, rightCutoff) {
455                     let color = "";
456                     let stroke;
458                     if (value >= leftCutoff && value <= rightCutoff) {
459                         color = greenColor;
460                     }
462                     if (LocalThis.storedOutliersIds.includes(id.toString())) {
463                         stroke = "black";
464                     }
466                     if (value <= leftCutoff || value >= rightCutoff) {
467                         if (leftCutoff >= 0) { LocalThis.outlierCutoffs.add(leftCutoff) };
468                         LocalThis.outlierCutoffs.add(rightCutoff);
469                         LocalThis.outliers.push(id);
470                         color = redColor;
471                     }
473                     return [color, stroke];
474                 }
476                 // Add ackground ggplot2 like
477                 svg.append("rect")
478                     .attr("x", 0)
479                     .attr("y", 0)
480                     .attr("height", height)
481                     .attr("width", width)
482                     .style("fill", "#ebebeb")
484                 // Add X axis
485                 var x = d3.scaleLinear()
486                     .domain([0, this.phenoIds.length])
487                     .range([0, width]);
489                 // Add Y axis
490                 var y = d3.scaleLinear()
491                     .domain([Math.min(...this.traitVals), Math.max(...this.traitVals)])
492                     .range([height, 0]);
493                 svg.append("g")
494                     .call(d3.axisLeft(y).tickSize(-width * 1).ticks(10))
495                     .style("font", "16px arial")
497                 // Customization
498                 svg.selectAll(".tick line").attr("stroke", "white")
500                 svg.append("text")
501                     .attr("transform", "rotate(-90)")
502                     .attr("y", 0 - margin.left)
503                     .attr("x", 0 - (height / 2))
504                     .attr("dy", ".65em")
505                     .style("text-anchor", "middle")
506                     .style("font", "16px arial")
507                     .text("Trait Value");
510                 // Add dots
511                 var tooltip = d3.select("#trait_graph")
512                     .append("div")
513                     .attr("id", "tooltip")
514                     .attr("class", "tooltip")
515                     .style("background-color", "white")
516                     .style("border", "solid")
517                     .style("border-width", "2px")
518                     .style("font-size", "15px")
519                     .style("border-radius", "5px")
520                     .style("padding", "5px")
521                     .style("opacity", 0);
523                 var mouseover = function (d) {
524                     tooltip
525                         .style("opacity", 1)
526                     d3.select(this)
527                         .style("fill", "white")
528                         .style("opacity", 1)
529                 }
531                 var mousemove = function (d) {
532                     tooltip
533                         .html("id: " + LocalThis.phenoIds[d] + "<br>" + "val: " + LocalThis.traitVals[d])
534                         .style("left", (d3.mouse(this)[0] + 50) + "px")
535                         .style("top", (d3.mouse(this)[1] + 40) + "px")
536                 }
538                 var mouseleave = function (d) {
539                     tooltip
540                         .style("opacity", 0)
541                     d3.select(this)
542                         .style("fill", function (d) { return isOutlier(LocalThis.phenoIds[d], LocalThis.traitVals[d], leftCutoff, rightCutoff)[0] })
543                         .style("stroke-width", 2)
544                         .style("stroke", function (d) { return isOutlier(LocalThis.phenoIds[d], LocalThis.traitVals[d], leftCutoff, rightCutoff)[1] })
545                         .style("fill-opacity", (d) => { return (LocalThis.traitVals[d] <= leftCutoff || LocalThis.traitVals[d] >= rightCutoff ? 0.2 : 0.8) })
546                         .style("stroke-opacity", (d) => { return (LocalThis.traitVals[d] <= leftCutoff || LocalThis.traitVals[d] >= rightCutoff ? 0.4 : 0.8) })
547                 }
549                 svg.append('g')
550                     .selectAll("dot")
551                     .data([...Array(this.phenoIds.length).keys()])
552                     .enter()
553                     .append("circle")
554                     .attr("cx", function (d) { return x(d); })
555                     .attr("cy", function (d) { return y(LocalThis.traitVals[d]); })
556                     .attr("r", 6)
557                     .style("fill", function (d) { return isOutlier(LocalThis.phenoIds[d], LocalThis.traitVals[d], leftCutoff, rightCutoff)[0] })
558                     .style("stroke-width", 2)
559                     .style("stroke", function (d) { return isOutlier(LocalThis.phenoIds[d], LocalThis.traitVals[d], leftCutoff, rightCutoff)[1] })
560                     .style("fill-opacity", (d) => { return (LocalThis.traitVals[d] <= leftCutoff || LocalThis.traitVals[d] >= rightCutoff ? 0.2 : 0.8) })
561                     .style("stroke-opacity", (d) => { return (LocalThis.traitVals[d] <= leftCutoff || LocalThis.traitVals[d] >= rightCutoff ? 0.4 : 0.8) })
562                     .on("mouseover", mouseover)
563                     .on("mousemove", mousemove)
564                     .on("mouseleave", mouseleave);
566                 let metricArray;
567                 if (LocalThis.metricValue != "iqr") {
568                     metricArray = [metric];
569                 } else {
570                     metricArray = metric;
571                 }
573                 metricArray.forEach((number) => {
574                     svg.append("line")
575                         .attr("class", "mean-line")
576                         .attr("x1", 0)
577                         .attr("y1", y(number))
578                         .attr("x2", width)
579                         .attr("y2", y(number))
580                         .attr("fill", "none")
581                         .attr("stroke", "black");
582                 })
584                 svg.append("line")
585                     .attr("class", "sd-line-top")
586                     .attr("x1", 0)
587                     .attr("y1", y(rightCutoff))
588                     .attr("x2", width)
589                     .attr("y2", y(rightCutoff))
590                     .attr("fill", "none")
591                     .attr("stroke", "darkgrey");
593                 svg.append("line")
594                     .attr("class", "sd-line-bottom")
595                     .attr("x1", 0)
596                     .attr("y1", y(leftCutoff >= 0 ? leftCutoff : 0))
597                     .attr("x2", width)
598                     .attr("y2", y(leftCutoff >= 0 ? leftCutoff : 0))
599                     .attr("fill", "none")
600                     .attr("stroke", "darkgrey");
602                 // legend builder                               
603                 const legendSize = {
604                     width: 250,
605                     height: 135,
606                     get posX() {
607                         return 5;
608                     },
609                     get posY() {
610                         return 15
611                     }
612                 };
613                 const dotSize = 7
615                 const legend = svg.append("g")
616                     .attr('id', 'legend')
617                     .attr('height', legendSize.height)
618                     .attr('width', legendSize.width)
619                     .attr('transform', 'translate(5, 5)');
621                 legend.append('rect')
622                     .attr('height', legendSize.height)
623                     .attr('width', legendSize.width)
624                     .attr('x', 0)
625                     .attr('y', 0)
626                     .attr('fill', 'white')
627                     .style("stroke", "lightgrey")
628                     .style("stroke-width", 3);
630                 legend.append('circle')
631                     .attr('r', dotSize)
632                     .attr('class', 'dot-legend')
633                     .attr('fill', greenColor)
634                     .attr('cx', legendSize.posX + 20)
635                     .attr('cy', legendSize.posY + 5)
637                 legend.append('circle')
638                     .attr('r', dotSize)
639                     .attr('stroke', "black")
640                     .style("stroke-width", 2)
641                     .style("fill", "none")
642                     .attr('class', 'dot-legend')
643                     .attr('cx', legendSize.posX + 20)
644                     .attr('cy', legendSize.posY + 30)
646                 legend.append('circle')
647                     .attr('r', dotSize)
648                     .attr('class', 'dot-legend')
649                     .attr('fill', redColor)
650                     .attr('cx', legendSize.posX + 20)
651                     .attr('cy', legendSize.posY + 55)
654                 svg.append('text')
655                     .attr('x', legendSize.posX + 25 + dotSize + 5)
656                     .attr('y', legendSize.posY + 10 + dotSize / 2 + 1)
657                     .style("font", "arial")
658                     .text('normal data point')
660                 svg.append('text')
661                     .attr('x', legendSize.posX + 25 + dotSize + 5)
662                     .attr('y', legendSize.posY + 35 + dotSize / 2 + 1)
663                     .style("font", "arial")
664                     .text('outliers value stored in database')
666                 svg.append('text')
667                     .attr('x', legendSize.posX + 25 + dotSize + 5)
668                     .attr('y', legendSize.posY + 60 + dotSize / 2 + 1)
669                     .style("font", "arial")
670                     .text('outliers from current cutoff')
673                 legend.append("text")
674                     .text(metricString + ": " + (LocalThis.metricValue == "iqr" ? "(" + metric[0].toFixed(1) + ", " + metric[1].toFixed(1) + ")" : metric.toFixed(2)))
675                     .attr('x', legendSize.posX + 20 - dotSize / 2)
676                     .attr('y', legendSize.posY + 85);
678                 legend.append("text")
679                     .text(deviationString + ": " + deviation.toFixed(2))
680                     .style("font", "arial")
681                     .attr('x', legendSize.posX + (LocalThis.metricValue == "iqr" ? 165 : 130) - dotSize / 2)
682                     .attr('y', legendSize.posY + 85);
684                 legend.append("text")
685                     .text(() => {
686                         return "L. Cutoff: " + leftCutoff.toFixed(2);
687                     })
688                     .style("font", "arial")
689                     .attr('x', legendSize.posX + 20 - dotSize / 2)
690                     .attr('y', legendSize.posY + 110);
692                 legend.append("text")
693                     .text(() => {
694                         return "R. Cutoff: " + rightCutoff.toFixed(2);
695                     })
696                     .style("font", "arial")
697                     .attr('x', legendSize.posX + 130 - dotSize / 2)
698                     .attr('y', legendSize.posY + 110);
699             }
700         }
701     }
702     const dataset = new Dataset;
703     return dataset;