From 66e35b9baaada124aecdf3a046071447485423da Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 9 Jun 2026 17:47:50 -0700 Subject: [PATCH 1/2] Option to make x-axis calendar-scaled --- core/webapp/vis/src/internal/D3Renderer.js | 13 +++- core/webapp/vis/src/plot.js | 87 ++++++++++++++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 3775ae94fac..3a955af7568 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2021,8 +2021,17 @@ 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; + if (jitterPosition && (geom.xScale.scaleType === scaleType.discrete || geom.xScale.scaleType === scaleType.continuous)) { + 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: jitter band is half a day's pixel width (matches the + // per-date half-slot for consecutive dates; safe for gaps since the spread is < 1 day). + const pixelsPerUnit = Math.abs(geom.xScale.scale(1) - geom.xScale.scale(0)); + xBinWidth = pixelsPerUnit / 2; + } xAcc = function(row) { var x = geom.xAes.getValue(row); var value = geom.getX(row); diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index 14702a91aa6..112b150b7c1 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -1691,6 +1691,16 @@ 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); + }; + LABKEY.vis.TrendingLinePlot = function(config){ if (!config.qcPlotType) config.qcPlotType = LABKEY.vis.TrendingLinePlotType.LeveyJennings; @@ -1774,6 +1784,34 @@ 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; + if (timeBasedXTick) { + let minDayNumber = null; + for (let i = 0; i < config.data.length; i++) { + const dn = LABKEY.vis.dateToDayNumber(config.data[i][config.properties.xTickLabel]); + if (dn !== null && (minDayNumber === null || dn < minDayNumber)) { + minDayNumber = dn; + } + } + for (let i = 0; i < config.data.length; i++) { + const label = config.data[i][config.properties.xTickLabel]; + const dn = LABKEY.vis.dateToDayNumber(label); + if (dn !== null && dayOffsetMap[label] === undefined) { + const offset = dn - 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 = [], @@ -2161,7 +2199,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 @@ -2192,6 +2235,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 @@ -2202,9 +2260,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]) { @@ -2261,6 +2320,22 @@ boxPlot.render(); } }; + // Calendar mode: continuous linear scale over day offsets, ticks only on data days. + if (timeBasedXTick) { + config.scales.x.scaleType = 'continuous'; + config.scales.x.trans = 'linear'; + // 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]; + config.scales.x.tickValues = uniqueDayOffsets; + config.scales.x.tickFormat = function(offset) { + return dayOffsetLabelMap[offset] !== undefined ? dayOffsetLabelMap[offset] : ''; + }; + } + if (hasYRightMetric) { config.scales.yRight = { scaleType: 'continuous', @@ -2349,7 +2424,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 = []; From 1ebc7d0333daae22546560ef5740de39f47537f7 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Fri, 19 Jun 2026 11:33:15 -0700 Subject: [PATCH 2/2] manual testing and code review updates --- core/webapp/vis/src/geom.js | 3 + core/webapp/vis/src/internal/D3Renderer.js | 69 +++++++++- core/webapp/vis/src/plot.js | 143 ++++++++++++++++++--- 3 files changed, 191 insertions(+), 24 deletions(-) diff --git a/core/webapp/vis/src/geom.js b/core/webapp/vis/src/geom.js index b480be57676..b3d0855ef58 100644 --- a/core/webapp/vis/src/geom.js +++ b/core/webapp/vis/src/geom.js @@ -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; }; diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 3a955af7568..c065721e07d 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2022,15 +2022,21 @@ LABKEY.vis.internal.D3Renderer = function(plot) { var jitters = {}; const jitterPosition = geom.position === position.jitter || geom.position === position.sequential; - if (jitterPosition && (geom.xScale.scaleType === scaleType.discrete || geom.xScale.scaleType === scaleType.continuous)) { + // 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: jitter band is half a day's pixel width (matches the - // per-date half-slot for consecutive dates; safe for gaps since the spread is < 1 day). - const pixelsPerUnit = Math.abs(geom.xScale.scale(1) - geom.xScale.scale(0)); - xBinWidth = pixelsPerUnit / 2; + // 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); @@ -2274,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(); diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index 36e305d67d5..4bb61a8d4da 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -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; @@ -1701,6 +1703,65 @@ boxPlot.render(); 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; @@ -1788,27 +1849,32 @@ boxPlot.render(); // 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; + let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null; if (timeBasedXTick) { - let minDayNumber = null; - for (let i = 0; i < config.data.length; i++) { - const dn = LABKEY.vis.dateToDayNumber(config.data[i][config.properties.xTickLabel]); - if (dn !== null && (minDayNumber === null || dn < minDayNumber)) { - minDayNumber = dn; - } - } + // 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 && dayOffsetMap[label] === undefined) { - const offset = dn - minDayNumber; - dayOffsetMap[label] = offset; - dayOffsetLabelMap[offset] = label; - if (offset > maxDayOffset) { - maxDayOffset = offset; + 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; }); } @@ -2320,20 +2386,50 @@ boxPlot.render(); } }; - // Calendar mode: continuous linear scale over day offsets, ticks only on data days. + // 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]; - config.scales.x.tickValues = uniqueDayOffsets; - config.scales.x.tickFormat = function(offset) { - return dayOffsetLabelMap[offset] !== undefined ? dayOffsetLabelMap[offset] : ''; - }; + + // 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) { @@ -2749,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