Skip to content
Open
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
3 changes: 3 additions & 0 deletions core/webapp/vis/src/geom.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ LABKEY.vis.Geom.ErrorBar = function(config){
this.width = ('width' in config && config.width != null && config.width != undefined) ? config.width : 6;
this.topOnly = config.topOnly ?? false;
this.errorShowVertical = config.showVertical ?? false;
// when true, each segment spans to its neighbors' midpoints so the line reads as one dashed line
// across no-data gaps (calendar axis) instead of disjoint per-point dashes
this.connectAdjacent = config.connectAdjacent ?? false;

return this;
};
Expand Down
72 changes: 70 additions & 2 deletions core/webapp/vis/src/internal/D3Renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2021,8 +2021,23 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
// For sequential jitters, keep track of the current count for a given x value
var jitters = {};

if (geom.xScale.scaleType === scaleType.discrete && (geom.position === position.jitter || geom.position === position.sequential)) {
xBinWidth = ((plot.grid.rightEdge - plot.grid.leftEdge) / (geom.xScale.scale.domain().length)) / 2;
const jitterPosition = geom.position === position.jitter || geom.position === position.sequential;
// Only the opt-in time-based continuous axis (calendar mode) gets day-jittered; other
// continuous-x callers (e.g. CDS scatter) keep their prior no-horizontal-jitter behavior.
const timeBasedContinuous = geom.xScale.scaleType === scaleType.continuous && geom.xScale.timeBasedXTick === true;
if (jitterPosition && (geom.xScale.scaleType === scaleType.discrete || timeBasedContinuous)) {
if (geom.xScale.scaleType === scaleType.discrete) {
xBinWidth = ((plot.grid.rightEdge - plot.grid.leftEdge) / (geom.xScale.scale.domain().length)) / 2;
}
else {
// Continuous (time-based) x-axis: size the same-day jitter band by the distinct-day count
// (mirroring the per-date slot half-width) so replicates fan out the same as per-date and
// aren't hidden when real-time spacing squeezes a day into a few pixels. Day centers stay
// at their true time position; only the same-day spread is normalized. dayCount is supplied by
// the time-based scale (plot.js) so the jitter band, bar width, and highlight rects share one count.
const slotCount = Math.max(geom.xScale.dayCount || 0, 10);
xBinWidth = ((plot.grid.rightEdge - plot.grid.leftEdge) / slotCount) / 2;
}
xAcc = function(row) {
var x = geom.xAes.getValue(row);
var value = geom.getX(row);
Expand Down Expand Up @@ -2265,6 +2280,59 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
return (isNaN(x) || x == null || isNaN(y) || y == null || isNaN(error) || error == null);
});

// connectAdjacent: render each level (top/bottom) as ONE continuous polyline through the points so a
// dash pattern runs evenly across the whole line, including no-data gaps ("---- ---- ----"). Per-point
// tiled segments restart the dash each segment and read as solid where points are dense, so avoid that.
if (geom.connectAdjacent) {
const strokeColor = typeof colorAcc === 'function' ? (data.length ? colorAcc(data[0]) : '#000000') : colorAcc;
// Build the line as flat horizontal runs at each constant level: connect consecutive same-y points
// (so the dash spans no-data gaps within a level), but START A NEW SUBPATH whenever the level changes
// (guide-set boundary) or y is undefined (e.g. log scale where mean +/- error <= 0). This avoids a
// diagonal connector between levels and avoids bridging over undefined points, matching the per-point
// bars. Each run extends +/- errorLineWidth at its ends, like the per-date segments.
const buildPath = function(sign) {
let d = '', runStartX = null, runEndX = null, runY = null;
const flush = function() {
if (runY !== null) {
d += 'M' + (runStartX - errorLineWidth) + ',' + runY + ' L' + (runEndX + errorLineWidth) + ',' + runY + ' ';
}
};
for (let k = 0; k < data.length; k++) {
const row = data[k];
const x = xAcc_(row), value = geom.yAes.getValue(row), error = geom.errorAes.getValue(row);
if (value == null || isNaN(x)) {
continue; // no data point that day (e.g. missing-fill row): bridge over it, don't break the level
}
const y = geom.yScale.scale(value + sign * error);
if (y == null || isNaN(y)) { // defined value but unplottable (log scale, value +/- error <= 0): break here
flush();
runStartX = runEndX = runY = null;
} else if (runY === null) { // start a new run
runStartX = runEndX = x; runY = y;
} else if (y === runY) { // same level: extend the run across the gap
runEndX = x;
} else { // level changed (guide-set boundary): close run, start a new one (no diagonal)
flush();
runStartX = runEndX = x; runY = y;
}
}
flush();
return d;
};
const drawLine = function(cls, sign) {
const lineSel = layer.selectAll('path.' + cls).data([data]);
lineSel.enter().append('path').attr('class', cls);
lineSel.attr('d', buildPath(sign)).attr('stroke', strokeColor).attr('fill', 'none')
.attr('stroke-width', 1).style('stroke-dasharray', '6, 3');
lineSel.exit().remove();
};
drawLine('error-bar-top', 1);
if (!geom.topOnly) {
drawLine('error-bar-bottom', -1);
}
return;
}

const selection = layer.selectAll('.error-bar').data(data);
selection.exit().remove();

Expand Down
192 changes: 187 additions & 5 deletions core/webapp/vis/src/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ boxPlot.render();
newScale.trans = origScale.trans ? origScale.trans : 'linear';
newScale.tickValues = origScale.tickValues ? origScale.tickValues : null;
newScale.tickFormat = origScale.tickFormat ? origScale.tickFormat : null;
newScale.timeBasedXTick = origScale.timeBasedXTick ? origScale.timeBasedXTick : null;
newScale.dayCount = origScale.dayCount ? origScale.dayCount : null;
newScale.tickDigits = origScale.tickDigits ? origScale.tickDigits : null;
newScale.tickMax = origScale.tickMax ? origScale.tickMax : null;
newScale.tickLabelMax = origScale.tickLabelMax ? origScale.tickLabelMax : null;
Expand Down Expand Up @@ -1691,6 +1693,75 @@ boxPlot.render();
TrailingCV: 'TrailingCV'
};

// Whole-day number (days since epoch) for a date string, or null if unparseable. Shared so overlay
// code positions calendar-mode points using the same day offsets as this plot.
LABKEY.vis.dateToDayNumber = function(dateStr) {
if (!dateStr) {
return null;
}
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : Math.round(d.getTime() / 86400000);
};

// Format a day number (days since epoch, UTC) back to a YYYY-MM-DD label for calendar-axis ticks.
LABKEY.vis.dayNumberToDateLabel = function(dayNumber) {
const d = new Date(dayNumber * 86400000);
const pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
};

// Pick day-offset tick positions for the calendar axis at "nice" calendar intervals (day/week/month/year),
// choosing the finest interval that keeps the tick count within maxTicks AND keeps consecutive ticks at
// least minGapOffsets apart (in day-offset units) so the date labels don't overlap.
LABKEY.vis.calendarTickOffsets = function(minDayNumber, maxDayOffset, maxTicks, minGapOffsets) {
const limit = Math.max(maxTicks || 1, 1);
const minGap = minGapOffsets > 0 ? minGapOffsets : 0;
const offsets = [];

const dayLadder = [1, 2, 3, 7, 14];
for (let i = 0; i < dayLadder.length; i++) {
if (dayLadder[i] >= minGap && Math.floor(maxDayOffset / dayLadder[i]) + 1 <= limit) {
for (let off = 0; off <= maxDayOffset; off += dayLadder[i]) {
offsets.push(off);
}
return offsets;
}
}

// Larger spans: tick on the 1st of the month, stepping whole months so the count fits.
const minDate = new Date(minDayNumber * 86400000);
const maxDate = new Date((minDayNumber + maxDayOffset) * 86400000);
const totalMonths = (maxDate.getUTCFullYear() - minDate.getUTCFullYear()) * 12
+ (maxDate.getUTCMonth() - minDate.getUTCMonth());
const monthLadder = [1, 2, 3, 6, 12, 24, 60, 120];
const minGapMonths = minGap > 0 ? minGap / 30.4 : 0; // ~days per month
let stepMonths = monthLadder[monthLadder.length - 1];
for (let i = 0; i < monthLadder.length; i++) {
if (monthLadder[i] >= minGapMonths && Math.floor(totalMonths / monthLadder[i]) + 1 <= limit) {
stepMonths = monthLadder[i];
break;
}
}

let year = minDate.getUTCFullYear(), month = minDate.getUTCMonth();
if (minDate.getUTCDate() > 1) { // start at the first month boundary at/after minDate
month++;
if (month > 11) { month = 0; year++; }
}
while (true) {
const off = Math.round(Date.UTC(year, month, 1) / 86400000) - minDayNumber;
if (off > maxDayOffset) {
break;
}
if (off >= 0) {
offsets.push(off);
}
month += stepMonths;
while (month > 11) { month -= 12; year++; }
}
return offsets;
};

LABKEY.vis.TrendingLinePlot = function(config){
if (!config.qcPlotType)
config.qcPlotType = LABKEY.vis.TrendingLinePlotType.LeveyJennings;
Expand Down Expand Up @@ -1774,6 +1845,39 @@ boxPlot.render();
}
uniqueXAxisLabels = Object.keys(uniqueXAxisKeys).sort();

// Calendar (time-based) x-axis: position each day by its offset from the earliest day so spacing
// reflects elapsed time. Offsets are keyed by xTickLabel (the date) so same-day rows share a position.
const timeBasedXTick = config.properties.timeBasedXTick === true;
const dayOffsetMap = {}, dayOffsetLabelMap = {};
let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null;
if (timeBasedXTick) {
// Parse each distinct date label once, tracking the earliest day; then convert to offsets.
const labelToDn = {}, seenLabel = {};
for (let i = 0; i < config.data.length; i++) {
const label = config.data[i][config.properties.xTickLabel];
if (seenLabel[label]) {
continue;
}
seenLabel[label] = true;
const dn = LABKEY.vis.dateToDayNumber(label);
if (dn !== null) {
labelToDn[label] = dn;
if (minDayNumber === null || dn < minDayNumber) {
minDayNumber = dn;
}
}
}
for (const label in labelToDn) {
const offset = labelToDn[label] - minDayNumber;
dayOffsetMap[label] = offset;
dayOffsetLabelMap[offset] = label;
if (offset > maxDayOffset) {
maxDayOffset = offset;
}
}
uniqueDayOffsets = Object.keys(dayOffsetLabelMap).map(Number).sort(function(a, b) { return a - b; });
}

// create a sequential index to use for the x-axis value and keep a map from that index to the tick label
// also, pull out the meanStdDev data for the unique x-axis values and calculate average values for the (LJ) trend line data
var tickLabelMap = {}, index = -1, distinctColorValues = [], meanStdDevData = [],
Expand Down Expand Up @@ -2161,7 +2265,12 @@ boxPlot.render();
}
};

if (config.properties.groupMatchingXTick) {
if (timeBasedXTick) {
index = dayOffsetMap[row[config.properties.xTickLabel]];
if (index === undefined) {
index = uniqueXAxisLabels.indexOf(row[config.properties.xTick]);
}
} else if (config.properties.groupMatchingXTick) {
index = uniqueXAxisLabels.indexOf(row[config.properties.xTick]);
} else {
index++; // Issue 54018
Expand Down Expand Up @@ -2192,6 +2301,21 @@ boxPlot.render();
}
}

// Time-based arrays are keyed by day offset; compact out the gaps so layers never bind undefined rows.
if (timeBasedXTick) {
const compactArray = function(arr) {
const compacted = [];
for (let k = 0; k < arr.length; k++) {
if (arr[k] !== undefined && arr[k] !== null) {
compacted.push(arr[k]);
}
}
return compacted;
};
meanStdDevData = compactArray(meanStdDevData);
groupedTrendlineData = compactArray(groupedTrendlineData);
}

// Issue 51887: Log scale extends much lower than needed for some Panorama QC plots
// If the yAxisDomain min value is less than 0, then the scale is extended way-below the smallest value
// during the log conversion at getLogScale L810, the below code ensures that the minimum scale of the y-axis
Expand All @@ -2202,9 +2326,10 @@ boxPlot.render();
}
}

// min x-axis tick length is 10 by default
var maxSeqValue = config.data.length > 0 ? config.data[config.data.length - 1].seqValue + 1 : 0;
for (var i = maxSeqValue; i < 10; i++) {
// min x-axis tick length is 10 by default (not applied for a time-based axis, where the
// spacing is driven by real dates and padding would add empty days at the end)
const maxSeqValue = config.data.length > 0 ? config.data[config.data.length - 1].seqValue + 1 : 0;
for (let i = maxSeqValue; i < 10 && !timeBasedXTick; i++) {
var temp = {type: 'empty', seqValue: i};
temp[config.properties.xTickLabel] = "";
if (config.properties.color && config.data[0]) {
Expand Down Expand Up @@ -2261,6 +2386,52 @@ boxPlot.render();
}
};

// Calendar mode: continuous linear scale over day offsets.
if (timeBasedXTick) {
config.scales.x.scaleType = 'continuous';
config.scales.x.trans = 'linear';
config.scales.x.timeBasedXTick = true; // opt-in flag so the renderer only day-jitters this axis
config.scales.x.dayCount = uniqueDayOffsets.length; // distinct-day count shared with jitter/bar/rect sizing
// Anchor endpoints like the per-date scale (min 10 slots); space the interior by elapsed time.
// pos(0)=1/(slots+1), pos(maxOffset)=numDates/(slots+1).
const numDates = uniqueDayOffsets.length;
const avgStep = numDates > 1 ? maxDayOffset / (numDates - 1) : 1;
const slots = Math.max(numDates, 10);
config.scales.x.domain = [-avgStep, avgStep * slots];

// A date label is ~80px; the domain maps avgStep*(slots+1) offset-units across ~the plot width, so
// require at least this many offset-units between adjacent ticks to avoid overlapping labels. This
// matters when few dates are close in time: the min-10-slot domain padding compresses them into the
// left of the axis, so consecutive-day labels collide unless we thin them.
const labelPx = 80;
const plotPx = Math.max(config.width - 110, 100); // approx grid width after margins
const minLabelGapOffsets = (labelPx * avgStep * (slots + 1)) / plotPx;

// Default to one tick per data day; switch to adaptive calendar intervals when the per-day labels
// would overlap (too many days, or the closest two are within one label width of each other).
let perDayTicksFit = uniqueDayOffsets.length <= tickMax;
for (let i = 1; perDayTicksFit && i < uniqueDayOffsets.length; i++) {
if (uniqueDayOffsets[i] - uniqueDayOffsets[i - 1] < minLabelGapOffsets) {
perDayTicksFit = false;
}
}

if (perDayTicksFit) {
// One tick per data day.
config.scales.x.tickValues = uniqueDayOffsets;
config.scales.x.tickFormat = function(offset) {
return dayOffsetLabelMap[offset] !== undefined ? dayOffsetLabelMap[offset] : '';
};
}
else {
// Crowded: ticks at nice calendar intervals so labels stay evenly spaced and readable.
config.scales.x.tickValues = LABKEY.vis.calendarTickOffsets(minDayNumber, maxDayOffset, tickMax, minLabelGapOffsets);
config.scales.x.tickFormat = function(offset) {
return LABKEY.vis.dayNumberToDateLabel(offset + minDayNumber);
};
}
}

if (hasYRightMetric) {
config.scales.yRight = {
scaleType: 'continuous',
Expand Down Expand Up @@ -2349,7 +2520,9 @@ boxPlot.render();
config.layers = [];
}
else {
var barWidth = Math.max(config.width / config.data[config.data.length-1].seqValue / 4, 3);
// Size bars by distinct-day count (per-date slot count, floored at 9), not the day span.
const barWidthDenom = timeBasedXTick ? Math.max(uniqueDayOffsets.length - 1, 9) : config.data[config.data.length-1].seqValue;
const barWidth = Math.max(config.width / barWidthDenom / 4, 3);
// the below if-else sections add the mean/SD/error bars to the plots
if (config.qcPlotType === LABKEY.vis.TrendingLinePlotType.LeveyJennings) {
config.layers = [];
Expand Down Expand Up @@ -2672,6 +2845,15 @@ boxPlot.render();
config.layers.push(new LABKEY.vis.Layer(getPathLayerConfig('yLeft', config.properties.value)));
}
}

// Calendar axis: draw mean/SD/bound lines as one dashed line spanning no-data gaps (not per-point dashes).
if (timeBasedXTick) {
config.layers.forEach(function(layer) {
if (layer.geom && layer.geom.type === 'ErrorBar') {
layer.geom.connectAdjacent = true;
}
});
}
}

// points based on the data value, color and hover text can be added via params to config
Expand Down