Skip to content

Commit a0b34b4

Browse files
committed
Option to make x-axis calendar-scaled
1 parent 0a48bff commit a0b34b4

3 files changed

Lines changed: 152 additions & 71 deletions

File tree

src/org/labkey/targetedms/TargetedMSController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,7 @@ public static class LeveyJenningsPlotOptions
817817
private String _metric2;
818818
private String _yAxisScale;
819819
private Boolean _groupedX;
820+
private Boolean _calendarX;
820821
private Boolean _singlePlot;
821822
private Boolean _showExcluded;
822823
private Boolean _showExcludedPrecursors;
@@ -842,6 +843,8 @@ public Map<String, String> getAsMapOfStrings()
842843
valueMap.put("yAxisScale", _yAxisScale);
843844
if (_groupedX != null)
844845
valueMap.put("groupedX", Boolean.toString(_groupedX));
846+
if (_calendarX != null)
847+
valueMap.put("calendarX", Boolean.toString(_calendarX));
845848
if (_singlePlot != null)
846849
valueMap.put("singlePlot", Boolean.toString(_singlePlot));
847850
if (_showExcluded != null)
@@ -888,6 +891,11 @@ public void setGroupedX(Boolean groupedX)
888891
_groupedX = groupedX;
889892
}
890893

894+
public void setCalendarX(Boolean calendarX)
895+
{
896+
_calendarX = calendarX;
897+
}
898+
891899
public void setSinglePlot(Boolean singlePlot)
892900
{
893901
_singlePlot = singlePlot;

webapp/TargetedMS/js/QCPlotHelperBase.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", {
781781
mouseOutFn: this.plotPointMouseOut,
782782
mouseOutFnScope: this,
783783
position: this.groupedX ? 'sequential' : undefined,
784+
timeBasedXTick: this.calendarX === true,
784785
legendMouseOverFn: this.legendMouseOver,
785786
legendMouseOverFnScope: this,
786787
legendMouseOutFn: this.plotPointMouseOut,
@@ -931,6 +932,7 @@ Ext4.define("LABKEY.targetedms.QCPlotHelperBase", {
931932
mouseOverFn: this.plotPointMouseOver,
932933
mouseOverFnScope: this,
933934
position: this.groupedX ? 'sequential' : undefined,
935+
timeBasedXTick: this.calendarX === true,
934936
disableRangeDisplay: this.isMultiSeries(),
935937
hoverTextFn: !showDataPoints ? function() { return 'Narrow the date range to show individual data points.' } : undefined,
936938
hideSDLines: true,

webapp/TargetedMS/js/QCTrendPlotPanel.js

Lines changed: 142 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
6767
startDate: null,
6868
endDate: null,
6969
groupedX: false,
70+
calendarX: false,
7071
singlePlot: false,
7172
showDataPoints: false,
7273
showExpRunRange: false,
@@ -85,6 +86,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
8586

8687
SHOW_ALL_IN_A_SINGLE_PLOT: 'Show all series in a single plot',
8788
LABEL_WIDTH: 115,
89+
// Shared column widths (px) so the Excluded-replicates radios line up under the X-axis grouping radios.
90+
XAXIS_COL_WIDTHS: [100, 82, 80],
8891

8992
// Max number of plots/series to show per page
9093
maxCount: 50,
@@ -227,6 +230,11 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
227230
// apply the initial values to the panel object so they are used in form field initialization
228231
Ext4.apply(this, initValues);
229232

233+
// calendar grouping is a variant of date grouping, so it always implies groupedX
234+
if (this.calendarX) {
235+
this.groupedX = true;
236+
}
237+
230238
// if we have a dateRangeOffset, we need to calculate the start and end date
231239
if (this.dateRangeOffset > -1) {
232240
this.startDate = this.formatDate(this.calculateStartDateByOffset());
@@ -1013,28 +1021,40 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
10131021

10141022
getGroupedXRadioGroup : function() {
10151023
if (!this.groupedXRadioGroup) {
1016-
this.groupedXRadioGroup = Ext4.create('Ext.form.RadioGroup', {
1024+
const me = this;
1025+
const onChange = function(radio, checked) {
1026+
if (!checked) {
1027+
return;
1028+
}
1029+
const val = radio.inputValue;
1030+
const newCalendarX = val === 'calendar';
1031+
const newGroupedX = val === 'date' || val === 'calendar';
1032+
// ignore the change event fired for the initially-checked radio during construction
1033+
if (newCalendarX === me.calendarX && newGroupedX === me.groupedX) {
1034+
return;
1035+
}
1036+
me.calendarX = newCalendarX;
1037+
me.groupedX = newGroupedX;
1038+
me.havePlotOptionsChanged = true;
1039+
1040+
me.setBrushingEnabled(false);
1041+
me.layoutAnnotationData();
1042+
me.setLoadingMsg();
1043+
me.processPlotData();
1044+
};
1045+
1046+
const colWidths = this.XAXIS_COL_WIDTHS;
1047+
this.groupedXRadioGroup = Ext4.create('Ext.form.FieldContainer', {
10171048
id: 'grouped-x-field',
10181049
fieldLabel: 'X-axis grouping',
10191050
labelWidth: this.LABEL_WIDTH,
1020-
columns: 2,
1021-
vertical: false,
1051+
layout: { type: 'hbox' },
1052+
defaults: { xtype: 'radio', name: 'xAxisGrouping', listeners: { change: onChange } },
10221053
items: [
1023-
{ boxLabel: 'per replicate', id: 'x-axis-grouping-replicate', name: 'xAxisGrouping', inputValue: 'replicate', checked: this.groupedX === false },
1024-
{ boxLabel: 'per date', id: 'x-axis-grouping-date', name: 'xAxisGrouping', inputValue: 'date', checked: this.groupedX === true }
1025-
],
1026-
listeners: {
1027-
scope: this,
1028-
change: function(group, newValue) {
1029-
var val = newValue && (newValue.xAxisGrouping || newValue['xAxisGrouping']);
1030-
var groupByDate = val === 'date' || (val === true); // fallback safety
1031-
this.groupedX = groupByDate;
1032-
this.havePlotOptionsChanged = true;
1033-
1034-
this.setBrushingEnabled(false);
1035-
this.getAnnotationData();
1036-
}
1037-
}
1054+
{ boxLabel: 'per replicate', width: colWidths[0], id: 'x-axis-grouping-replicate', inputValue: 'replicate', checked: this.groupedX === false },
1055+
{ boxLabel: 'per date', width: colWidths[1], id: 'x-axis-grouping-date', inputValue: 'date', checked: this.groupedX === true && this.calendarX !== true },
1056+
{ boxLabel: 'calendar', width: colWidths[2], id: 'x-axis-grouping-calendar', inputValue: 'calendar', checked: this.groupedX === true && this.calendarX === true }
1057+
]
10381058
});
10391059
}
10401060

@@ -1074,27 +1094,33 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
10741094

10751095
getExcludedReplicatesRadioGroup : function() {
10761096
if (!this.excludedReplicatesRadioGroup) {
1077-
this.excludedReplicatesRadioGroup = Ext4.create('Ext.form.RadioGroup', {
1097+
const me = this;
1098+
const onChange = function(radio, checked) {
1099+
if (!checked) {
1100+
return;
1101+
}
1102+
const newShow = radio.inputValue === 'show';
1103+
// ignore the change event fired for the initially-checked radio during construction
1104+
if (newShow === me.showExcluded) {
1105+
return;
1106+
}
1107+
me.showExcluded = newShow;
1108+
me.havePlotOptionsChanged = true;
1109+
1110+
me.getAnnotationData();
1111+
};
1112+
1113+
const colWidths = this.XAXIS_COL_WIDTHS;
1114+
this.excludedReplicatesRadioGroup = Ext4.create('Ext.form.FieldContainer', {
10781115
id: 'show-excluded-points',
10791116
fieldLabel: 'Excluded replicates',
10801117
labelWidth: this.LABEL_WIDTH,
1081-
columns: 2,
1082-
vertical: false,
1118+
layout: { type: 'hbox' },
1119+
defaults: { xtype: 'radio', name: 'excludedSamples', listeners: { change: onChange } },
10831120
items: [
1084-
{ boxLabel: 'show', id: 'excluded-replicates-show', name: 'excludedSamples', inputValue: 'show', checked: this.showExcluded === true },
1085-
{ boxLabel: 'hide', id: 'excluded-replicates-hide', name: 'excludedSamples', inputValue: 'hide', checked: this.showExcluded === false }
1086-
],
1087-
listeners: {
1088-
scope: this,
1089-
change: function(group, newValue) {
1090-
var val = newValue && (newValue.excludedSamples || newValue['excludedSamples']);
1091-
var newShow = val === 'show' || (val === true); // fallback safety
1092-
this.showExcluded = newShow;
1093-
this.havePlotOptionsChanged = true;
1094-
1095-
this.getAnnotationData();
1096-
}
1097-
}
1121+
{ boxLabel: 'show', width: colWidths[0], id: 'excluded-replicates-show', inputValue: 'show', checked: this.showExcluded === true },
1122+
{ boxLabel: 'hide', width: colWidths[1], id: 'excluded-replicates-hide', inputValue: 'hide', checked: this.showExcluded === false }
1123+
]
10981124
});
10991125
}
11001126

@@ -1424,9 +1450,6 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
14241450

14251451
processAnnotationData: function(data) {
14261452
if (data) {
1427-
this.annotationShape = LABKEY.vis.Scale.Shape()[4]; // 0: circle, 1: triangle, 2: square, 3: diamond, 4: X
1428-
this.legendData = [];
1429-
14301453
const collapsedData = [];
14311454
const collapsedMap = {};
14321455

@@ -1445,38 +1468,49 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
14451468

14461469
this.annotationData = collapsedData;
14471470

1448-
var dateCount = {};
1471+
this.layoutAnnotationData();
1472+
this.getPlotsData();
1473+
}
1474+
},
14491475

1450-
// if more than one type of legend present, add a legend header for annotations
1451-
if (this.annotationData.length > 0 && (this.singlePlot || this.showMeanCUSUMPlot() || this.showVariableCUSUMPlot())) {
1452-
this.legendData.push({
1453-
text: 'Annotations',
1454-
separator: true
1455-
});
1456-
}
1476+
// Compute annotation stacking and the annotation legend entries
1477+
layoutAnnotationData: function() {
1478+
this.annotationShape = LABKEY.vis.Scale.Shape()[4]; // 0: circle, 1: triangle, 2: square, 3: diamond, 4: X
1479+
this.legendData = [];
14571480

1458-
for (let i = 0; i < this.annotationData.length; i++) {
1459-
const annotation = this.annotationData[i];
1460-
const annotationDate = this.formatDate(new Date(annotation['Date']), !this.groupedX);
1481+
if (!this.annotationData) {
1482+
return;
1483+
}
14611484

1462-
// track if we need to stack annotations that fall on the same date
1463-
if (!dateCount[annotationDate]) {
1464-
dateCount[annotationDate] = 0;
1465-
}
1466-
annotation.yStepIndex = dateCount[annotationDate];
1467-
dateCount[annotationDate]++;
1468-
1469-
// get unique annotation names and colors for the legend
1470-
if (Ext4.Array.pluck(this.legendData, "text").indexOf(annotation['Name']) === -1) {
1471-
this.legendData.push({
1472-
text: annotation['Name'],
1473-
color: '#' + annotation['Color'],
1474-
shape: this.annotationShape
1475-
});
1476-
}
1485+
const dateCount = {};
1486+
1487+
// if more than one type of legend present, add a legend header for annotations
1488+
if (this.annotationData.length > 0 && (this.singlePlot || this.showMeanCUSUMPlot() || this.showVariableCUSUMPlot())) {
1489+
this.legendData.push({
1490+
text: 'Annotations',
1491+
separator: true
1492+
});
1493+
}
1494+
1495+
for (let i = 0; i < this.annotationData.length; i++) {
1496+
const annotation = this.annotationData[i];
1497+
const annotationDate = this.formatDate(new Date(annotation['Date']), !this.groupedX);
1498+
1499+
// track if we need to stack annotations that fall on the same date
1500+
if (!dateCount[annotationDate]) {
1501+
dateCount[annotationDate] = 0;
14771502
}
1503+
annotation.yStepIndex = dateCount[annotationDate];
1504+
dateCount[annotationDate]++;
14781505

1479-
this.getPlotsData();
1506+
// get unique annotation names and colors for the legend
1507+
if (Ext4.Array.pluck(this.legendData, "text").indexOf(annotation['Name']) === -1) {
1508+
this.legendData.push({
1509+
text: annotation['Name'],
1510+
color: '#' + annotation['Color'],
1511+
shape: this.annotationShape
1512+
});
1513+
}
14801514
}
14811515
},
14821516

@@ -2227,6 +2261,23 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
22272261
return d3.select('#' + plot.renderTo + ' svg');
22282262
},
22292263

2264+
// Pixel width of one x-axis slot, used to size guide-set/outlier highlight rectangles. For calendar
2265+
// mode (continuous axis) this is the plot width over the distinct-day count; otherwise inter-tick spacing.
2266+
getXBinWidth : function(plot) {
2267+
if (this.calendarX) {
2268+
const seen = {};
2269+
let count = 0;
2270+
Ext4.each(plot.data || [], function(d) {
2271+
if (d && d.seqValue !== undefined && !seen[d.seqValue]) {
2272+
seen[d.seqValue] = true;
2273+
count++;
2274+
}
2275+
});
2276+
return (plot.grid.rightEdge - plot.grid.leftEdge) / Math.max(count, 10);
2277+
}
2278+
return (plot.grid.rightEdge - plot.grid.leftEdge) / (plot.scales.x.scale.domain().length);
2279+
},
2280+
22302281
toggleGuideSetMsgDisplay : function() {
22312282
var toolbarMsg = this.down('#GuideSetMessageToolBar');
22322283
if (toolbarMsg) {
@@ -2238,7 +2289,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
22382289
// for each precursor in precursorInfo
22392290
let me = this;
22402291

2241-
let binWidth = (plot.grid.rightEdge - plot.grid.leftEdge) / (plot.scales.x.scale.domain().length);
2292+
let binWidth = this.getXBinWidth(plot);
22422293
let yRange = plot.scales.yLeft.range;
22432294

22442295
let xAcc = function (d) {
@@ -2256,7 +2307,8 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
22562307
let clickedReplicateData = [];
22572308
clickedReplicateData.push({
22582309
'EndIndex': data.seqValue,
2259-
'StartIndex': data.seqValue
2310+
'StartIndex': data.seqValue,
2311+
'ReplicateName': data.ReplicateName
22602312
})
22612313

22622314
let outlierRect = "rect.outlier-" + j;
@@ -2277,7 +2329,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
22772329
.attr('fill', color).attr('fill-opacity', 0.1)
22782330
.append("title")
22792331
.text(function (d) {
2280-
return "Selected replicate: " + Ext4.String.htmlEncode(plot.data[d.EndIndex].ReplicateName);
2332+
return "Selected replicate: " + Ext4.String.htmlEncode(d.ReplicateName);
22812333
});
22822334

22832335
this.sendSvgElementToBack(plot, outlierRect);
@@ -2325,7 +2377,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
23252377
}
23262378
}, this);
23272379

2328-
var binWidth = (plot.grid.rightEdge - plot.grid.leftEdge) / (plot.scales.x.scale.domain().length);
2380+
const binWidth = this.getXBinWidth(plot);
23292381
var yRange = plot.scales.yLeft.range;
23302382

23312383
var xAcc = function (d) {
@@ -2350,8 +2402,11 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
23502402
var pointsData = precursorInfo.data;
23512403
var expDataArr = [];
23522404

2353-
for (var i = startIndex; i <= endIndex; i++) {
2354-
expDataArr.push(pointsData[i].value);
2405+
// startIndex/endIndex are seqValues (day offsets in calendar mode), not array indices.
2406+
for (let i = 0; i < pointsData.length; i++) {
2407+
if (pointsData[i].seqValue >= startIndex && pointsData[i].seqValue <= endIndex && pointsData[i].value !== undefined) {
2408+
expDataArr.push(pointsData[i].value);
2409+
}
23552410
}
23562411

23572412
var expMean = LABKEY.targetedms.PlotSettingsUtil.formatNumeric(LABKEY.vis.Stat.getMean(expDataArr));
@@ -2483,9 +2538,24 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
24832538
return annotationDates.indexOf(objDate) === -1;
24842539
});
24852540

2541+
// Calendar mode keys the x-axis by day offset, so resolve the earliest plotted day for the conversion.
2542+
let minDayNumber = null;
2543+
if (this.calendarX) {
2544+
Ext4.each(precursorInfo.data, function(row) {
2545+
const dn = LABKEY.vis.dateToDayNumber(row['date']);
2546+
if (dn !== null && (minDayNumber === null || dn < minDayNumber)) {
2547+
minDayNumber = dn;
2548+
}
2549+
});
2550+
}
2551+
24862552
// use direct D3 code to inject the annotation icons to the rendered SVG
24872553
var xAcc = function(d) {
24882554
var annotationDate = me.formatDate(new Date(d['Date']), !me.groupedX);
2555+
if (me.calendarX) {
2556+
const dn = LABKEY.vis.dateToDayNumber(annotationDate);
2557+
return plot.scales.x.scale(dn !== null && minDayNumber !== null ? dn - minDayNumber : 0);
2558+
}
24892559
return plot.scales.x.scale(xAxisLabels.indexOf(annotationDate));
24902560
};
24912561
var yAcc = function(d) {
@@ -3155,6 +3225,7 @@ Ext4.define('LABKEY.targetedms.QCTrendPlotPanel', {
31553225
plotTypes: this.plotTypes,
31563226
yAxisScale: this.yAxisScale,
31573227
groupedX: this.groupedX,
3228+
calendarX: this.calendarX,
31583229
singlePlot: this.singlePlot,
31593230
showExcluded: this.showExcluded,
31603231
dateRangeOffset: this.dateRangeOffset,

0 commit comments

Comments
 (0)