From 66e35b9baaada124aecdf3a046071447485423da Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 9 Jun 2026 17:47:50 -0700 Subject: [PATCH 1/8] 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/8] 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 From 54e3e38fe1a4c543fd0e2907fe7bf3003782158c Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Thu, 23 Jul 2026 17:15:24 -0700 Subject: [PATCH 3/8] fix calendar axis handling --- core/webapp/vis/src/plot.js | 72 +++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index da532ca6760..1310ef21b22 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -394,6 +394,7 @@ boxPlot.render(); newScale.tickFormat = origScale.tickFormat ? origScale.tickFormat : null; newScale.timeBasedXTick = origScale.timeBasedXTick ? origScale.timeBasedXTick : null; newScale.dayCount = origScale.dayCount ? origScale.dayCount : null; + newScale.dayNumberOffsetMap = origScale.dayNumberOffsetMap ? origScale.dayNumberOffsetMap : null; newScale.tickDigits = origScale.tickDigits ? origScale.tickDigits : null; newScale.tickMax = origScale.tickMax ? origScale.tickMax : null; newScale.tickLabelMax = origScale.tickLabelMax ? origScale.tickLabelMax : null; @@ -1845,35 +1846,62 @@ 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. + // Calendar (time-based) x-axis: space each day by elapsed time; with an ordinal prefix (guide-set "always show") the prefix rows collapse to ordinal slots and only the recent window is time-spaced. const timeBasedXTick = config.properties.timeBasedXTick === true; - const dayOffsetMap = {}, dayOffsetLabelMap = {}; - let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null; + const prefixField = config.properties.calendarPrefixField; + const prefixValue = config.properties.calendarPrefixValue; + const dayOffsetMap = {}, dayOffsetLabelMap = {}, dayNumberOffsetMap = {}; + let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null, ordinalPrefixApplied = false; if (timeBasedXTick) { - // Parse each distinct date label once, tracking the earliest day; then convert to offsets. - const labelToDn = {}, seenLabel = {}; + // Collect each distinct date label once (data order): its day number and whether it is a prefix row. + const labelInfo = {}, prefixLabels = [], recentLabels = []; for (let i = 0; i < config.data.length; i++) { const label = config.data[i][config.properties.xTickLabel]; - if (seenLabel[label]) { + if (labelInfo[label] !== undefined) { continue; } - seenLabel[label] = true; const dn = LABKEY.vis.dateToDayNumber(label); + const isPrefix = prefixField !== undefined && prefixValue !== undefined + && config.data[i][prefixField] === prefixValue; + labelInfo[label] = { dn: dn, prefix: isPrefix }; if (dn !== null) { - labelToDn[label] = dn; if (minDayNumber === null || dn < minDayNumber) { minDayNumber = dn; } + (isPrefix ? prefixLabels : recentLabels).push(label); } } - for (const label in labelToDn) { - const offset = labelToDn[label] - minDayNumber; + + const byDn = function(a, b) { return labelInfo[a].dn - labelInfo[b].dn; }; + const putOffset = function(label, offset) { dayOffsetMap[label] = offset; dayOffsetLabelMap[offset] = label; + dayNumberOffsetMap[labelInfo[label].dn] = offset; if (offset > maxDayOffset) { maxDayOffset = offset; } + }; + + if (prefixLabels.length > 0 && recentLabels.length > 0) { + // Prefix days -> ordinal slots 0..p-1; recent days -> p + elapsed days from the first recent day. + ordinalPrefixApplied = true; + prefixLabels.sort(byDn); + recentLabels.sort(byDn); + for (let k = 0; k < prefixLabels.length; k++) { + putOffset(prefixLabels[k], k); + } + const firstRecentDn = labelInfo[recentLabels[0]].dn; + for (let k = 0; k < recentLabels.length; k++) { + putOffset(recentLabels[k], prefixLabels.length + (labelInfo[recentLabels[k]].dn - firstRecentDn)); + } + } + else { + // No prefix split: every day spaced by its offset from the earliest day. + for (const label in labelInfo) { + if (labelInfo[label].dn !== null) { + putOffset(label, labelInfo[label].dn - minDayNumber); + } + } } uniqueDayOffsets = Object.keys(dayOffsetLabelMap).map(Number).sort(function(a, b) { return a - b; }); } @@ -2392,6 +2420,7 @@ boxPlot.render(); 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 + config.scales.x.dayNumberOffsetMap = dayNumberOffsetMap; // day number -> x offset, for annotation overlay alignment // 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; @@ -2423,6 +2452,27 @@ boxPlot.render(); return dayOffsetLabelMap[offset] !== undefined ? dayOffsetLabelMap[offset] : ''; }; } + else if (ordinalPrefixApplied) { + // Piecewise axis: offsets aren't linear in day number, so tick only on data days (thinned to fit) and label from the offset->date map. + const picked = [uniqueDayOffsets[0]]; + for (let i = 1; i < uniqueDayOffsets.length; i++) { + if (uniqueDayOffsets[i] - picked[picked.length - 1] >= minLabelGapOffsets) { + picked.push(uniqueDayOffsets[i]); + } + } + let thinned = picked; + if (picked.length > tickMax) { + thinned = []; + const step = Math.ceil(picked.length / tickMax); + for (let i = 0; i < picked.length; i += step) { + thinned.push(picked[i]); + } + } + config.scales.x.tickValues = thinned; + 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); From d2b2780b7355178152d4c19999e11b1562e56f12 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Mon, 27 Jul 2026 17:13:30 -0700 Subject: [PATCH 4/8] fix calendar axis guide set prefix split, add axis break --- core/webapp/vis/src/internal/D3Renderer.js | 34 +++++++++- core/webapp/vis/src/plot.js | 73 +++++++++++++++------- core/webapp/vis/src/utils.js | 10 ++- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index c065721e07d..e7ba480aebc 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -962,6 +962,27 @@ LABKEY.vis.internal.D3Renderer = function(plot) { } }; + // Mark where the calendar x-axis jumps from the ordinal guide-set block to the time-scaled date window. + var renderCalendarAxisBreak = function() { + this.canvas.selectAll('g.calendar-axis-break').remove(); + + var xScale = plot.scales.x; + if (!xScale || !xScale.scale || xScale.calendarBreakOffset === null || xScale.calendarBreakOffset === undefined) { + return; + } + + var x = xScale.scale(xScale.calendarBreakOffset), top = plot.grid.topEdge, bottom = plot.grid.bottomEdge; + var g = this.canvas.append('g').attr('class', 'calendar-axis-break'); + g.append('line').attr('x1', x).attr('x2', x).attr('y1', top).attr('y2', bottom) + .attr('stroke', '#B0B0B0').attr('stroke-width', 1).style('stroke-dasharray', '2,3'); + // the "//" glyph straddling the axis line + [-3, 1].forEach(function(dx) { + g.append('line').attr('x1', x + dx - 3).attr('x2', x + dx + 3) + .attr('y1', bottom + 5).attr('y2', bottom - 5) + .attr('stroke', '#666666').attr('stroke-width', 1.5); + }); + }; + var renderXTopAxis = function() { if (!xTopAxis) { xTopAxis = LABKEY.vis.internal.Axis().orient('top'); @@ -1625,6 +1646,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { if (!plot.disableAxis.xTop) { renderXTopAxis.call(this); } if (!plot.disableAxis.yLeft) { renderYLeftAxis.call(this); } if (!plot.disableAxis.yRight) { renderYRightAxis.call(this); } + renderCalendarAxisBreak.call(this); addBrush.call(this); }; @@ -2285,6 +2307,9 @@ LABKEY.vis.internal.D3Renderer = function(plot) { // 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; + // pixel position of the calendar axis break, so no run spans the guide-set/window discontinuity + const breakOffset = geom.xScale.calendarBreakOffset; + const breakX = breakOffset === null || breakOffset === undefined ? null : geom.xScale.scale(breakOffset); // 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 @@ -2303,6 +2328,10 @@ LABKEY.vis.internal.D3Renderer = function(plot) { if (value == null || isNaN(x)) { continue; // no data point that day (e.g. missing-fill row): bridge over it, don't break the level } + if (breakX !== null && runY !== null && runEndX < breakX && x > breakX) { // crossing the axis break: end the run here + flush(); + runStartX = runEndX = runY = null; + } 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(); @@ -2600,11 +2629,14 @@ LABKEY.vis.internal.D3Renderer = function(plot) { }; var _renderPath = function(layer, data, geom, xAcc) { + // don't connect points across the calendar axis break (guide-set block -> date window) + var breakOffset = geom.xScale ? geom.xScale.calendarBreakOffset : null, + breakX = breakOffset === null || breakOffset === undefined ? null : geom.xScale.scale(breakOffset); var yAcc = function(d) {var val = geom.getY(d); return val == null ? null : val;}, size = geom.sizeAes && geom.sizeScale ? geom.sizeScale.scale(geom.sizeAes.getValue(data)) : function() {return geom.size}, color = geom.color, line = function(d) { - var path = LABKEY.vis.makePath(d.data, xAcc, yAcc); + var path = LABKEY.vis.makePath(d.data, xAcc, yAcc, breakX); return path.length == 0 ? null : path; }; if (geom.pathColorAes && geom.colorScale) { diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index 1310ef21b22..6efffd66dc1 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -393,8 +393,9 @@ boxPlot.render(); 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.dayCount = origScale.dayCount !== undefined ? origScale.dayCount : null; newScale.dayNumberOffsetMap = origScale.dayNumberOffsetMap ? origScale.dayNumberOffsetMap : null; + newScale.calendarBreakOffset = origScale.calendarBreakOffset !== undefined ? origScale.calendarBreakOffset : null; newScale.tickDigits = origScale.tickDigits ? origScale.tickDigits : null; newScale.tickMax = origScale.tickMax ? origScale.tickMax : null; newScale.tickLabelMax = origScale.tickLabelMax ? origScale.tickLabelMax : null; @@ -1851,26 +1852,52 @@ boxPlot.render(); const prefixField = config.properties.calendarPrefixField; const prefixValue = config.properties.calendarPrefixValue; const dayOffsetMap = {}, dayOffsetLabelMap = {}, dayNumberOffsetMap = {}; - let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null, ordinalPrefixApplied = false; + let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null, ordinalPrefixApplied = false, + calendarBreakOffset = null; if (timeBasedXTick) { - // Collect each distinct date label once (data order): its day number and whether it is a prefix row. - const labelInfo = {}, prefixLabels = [], recentLabels = []; + // One entry per distinct date label: day number, whether any row on it is tagged, whether any row on it is plottable. + const labelInfo = {}, orderedLabels = []; + const havePrefixCfg = prefixField !== undefined && prefixValue !== undefined; for (let i = 0; i < config.data.length; i++) { - const label = config.data[i][config.properties.xTickLabel]; - if (labelInfo[label] !== undefined) { - continue; - } - const dn = LABKEY.vis.dateToDayNumber(label); - const isPrefix = prefixField !== undefined && prefixValue !== undefined - && config.data[i][prefixField] === prefixValue; - labelInfo[label] = { dn: dn, prefix: isPrefix }; - if (dn !== null) { - if (minDayNumber === null || dn < minDayNumber) { - minDayNumber = dn; + const row = config.data[i], label = row[config.properties.xTickLabel]; + let info = labelInfo[label]; + if (info === undefined) { + const dn = LABKEY.vis.dateToDayNumber(label); + info = labelInfo[label] = { dn: dn, prefix: false, real: false }; + if (dn !== null) { + orderedLabels.push(label); + if (minDayNumber === null || dn < minDayNumber) { + minDayNumber = dn; + } } - (isPrefix ? prefixLabels : recentLabels).push(label); + } + if (row.type !== 'missing' && row.type !== 'empty') { // the two no-value filler row markers + info.real = true; + } + if (havePrefixCfg && row[prefixField] === prefixValue) { + info.prefix = true; + } + } + + // Anchor the window at the first plottable day after the last tagged day; untagged filler days before it fold into the prefix block instead of re-stretching the axis over the dead span. + let lastPrefixDn = null, windowAnchorDn = null; + for (let k = 0; k < orderedLabels.length; k++) { + const info = labelInfo[orderedLabels[k]]; + if (info.prefix && (lastPrefixDn === null || info.dn > lastPrefixDn)) { + lastPrefixDn = info.dn; + } + } + for (let k = 0; lastPrefixDn !== null && k < orderedLabels.length; k++) { + const info = labelInfo[orderedLabels[k]]; + if (info.real && info.dn > lastPrefixDn && (windowAnchorDn === null || info.dn < windowAnchorDn)) { + windowAnchorDn = info.dn; } } + const prefixLabels = [], recentLabels = []; + for (let k = 0; k < orderedLabels.length; k++) { + const label = orderedLabels[k]; + (windowAnchorDn !== null && labelInfo[label].dn >= windowAnchorDn ? recentLabels : prefixLabels).push(label); + } const byDn = function(a, b) { return labelInfo[a].dn - labelInfo[b].dn; }; const putOffset = function(label, offset) { @@ -1883,24 +1910,25 @@ boxPlot.render(); }; if (prefixLabels.length > 0 && recentLabels.length > 0) { - // Prefix days -> ordinal slots 0..p-1; recent days -> p + elapsed days from the first recent day. + // Prefix days -> ordinal slots 0..p-1, then one blank slot, then the window time-spaced from its first day. ordinalPrefixApplied = true; prefixLabels.sort(byDn); recentLabels.sort(byDn); for (let k = 0; k < prefixLabels.length; k++) { putOffset(prefixLabels[k], k); } + const gapSlots = 1; + calendarBreakOffset = prefixLabels.length - 0.5 + (gapSlots / 2); // midpoint of the blank slot const firstRecentDn = labelInfo[recentLabels[0]].dn; + const windowStartOffset = prefixLabels.length + gapSlots; for (let k = 0; k < recentLabels.length; k++) { - putOffset(recentLabels[k], prefixLabels.length + (labelInfo[recentLabels[k]].dn - firstRecentDn)); + putOffset(recentLabels[k], windowStartOffset + (labelInfo[recentLabels[k]].dn - firstRecentDn)); } } else { // No prefix split: every day spaced by its offset from the earliest day. - for (const label in labelInfo) { - if (labelInfo[label].dn !== null) { - putOffset(label, labelInfo[label].dn - minDayNumber); - } + for (let k = 0; k < orderedLabels.length; k++) { + putOffset(orderedLabels[k], labelInfo[orderedLabels[k]].dn - minDayNumber); } } uniqueDayOffsets = Object.keys(dayOffsetLabelMap).map(Number).sort(function(a, b) { return a - b; }); @@ -2421,6 +2449,7 @@ boxPlot.render(); 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 config.scales.x.dayNumberOffsetMap = dayNumberOffsetMap; // day number -> x offset, for annotation overlay alignment + config.scales.x.calendarBreakOffset = calendarBreakOffset; // x offset of the guide-set/window break, or null when there is no split // 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; diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 332185b3ce3..271bae6fe74 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -51,8 +51,9 @@ LABKEY.vis.makeLine = function(x1, y1, x2, y2){ return "M " + x1 + " " + y1 + " L " + x2 + " " + y2; }; -LABKEY.vis.makePath = function(data, xAccessor, yAccessor){ - var pathString = ''; +// breakX (optional): x pixel of an axis break; the path starts a new subpath rather than drawing across it +LABKEY.vis.makePath = function(data, xAccessor, yAccessor, breakX){ + var pathString = '', prevX = null; for(var i = 0; i < data.length; i++){ var x = xAccessor(data[i]); @@ -60,12 +61,15 @@ LABKEY.vis.makePath = function(data, xAccessor, yAccessor){ if(!LABKEY.vis.isValid(x) || !LABKEY.vis.isValid(y)){ continue; } - + if(pathString == ''){ pathString = pathString + 'M' + x + ' ' + y; + } else if(breakX != null && prevX < breakX && x > breakX){ + pathString = pathString + ' M' + x + ' ' + y; } else { pathString = pathString + ' L' + x + ' ' + y; } + prevX = x; } return pathString; }; From 6b9f941046408239fde3e80210f7ec87ea96793e Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Mon, 27 Jul 2026 17:18:10 -0700 Subject: [PATCH 5/8] support explicit calendar window start date --- core/webapp/vis/src/plot.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index 6efffd66dc1..0fb1fc504f1 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -1887,10 +1887,17 @@ boxPlot.render(); lastPrefixDn = info.dn; } } - for (let k = 0; lastPrefixDn !== null && k < orderedLabels.length; k++) { - const info = labelInfo[orderedLabels[k]]; - if (info.real && info.dn > lastPrefixDn && (windowAnchorDn === null || info.dn < windowAnchorDn)) { - windowAnchorDn = info.dn; + // an explicitly named window start (the selected date range) anchors the split even when that day has no data + const windowStartDn = LABKEY.vis.dateToDayNumber(config.properties.calendarWindowStartDate); + if (lastPrefixDn !== null && windowStartDn !== null && windowStartDn > lastPrefixDn) { + windowAnchorDn = windowStartDn; + } + else { + for (let k = 0; lastPrefixDn !== null && k < orderedLabels.length; k++) { + const info = labelInfo[orderedLabels[k]]; + if (info.real && info.dn > lastPrefixDn && (windowAnchorDn === null || info.dn < windowAnchorDn)) { + windowAnchorDn = info.dn; + } } } const prefixLabels = [], recentLabels = []; From d4fd84d3d731abd34cbbccac82cc4916b0be9712 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Mon, 27 Jul 2026 18:24:12 -0700 Subject: [PATCH 6/8] drop unplaceable calendar rows, cap day slot width at closest day spacing --- core/webapp/vis/src/internal/D3Renderer.js | 7 +++-- core/webapp/vis/src/plot.js | 31 ++++++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index e7ba480aebc..3e263a7731a 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2055,10 +2055,9 @@ LABKEY.vis.internal.D3Renderer = function(plot) { // 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; + // at their true time position; only the same-day spread is normalized. The shared helper caps + // that width at the closest actual day spacing so the fan can't reach the neighbouring day. + xBinWidth = LABKEY.vis.calendarSlotWidth(geom.xScale, plot.grid.rightEdge - plot.grid.leftEdge) / 2; } xAcc = function(row) { var x = geom.xAes.getValue(row); diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index 0fb1fc504f1..1d959cd9686 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -394,6 +394,7 @@ boxPlot.render(); newScale.tickFormat = origScale.tickFormat ? origScale.tickFormat : null; newScale.timeBasedXTick = origScale.timeBasedXTick ? origScale.timeBasedXTick : null; newScale.dayCount = origScale.dayCount !== undefined ? origScale.dayCount : null; + newScale.minDayGapOffsets = origScale.minDayGapOffsets !== undefined ? origScale.minDayGapOffsets : null; newScale.dayNumberOffsetMap = origScale.dayNumberOffsetMap ? origScale.dayNumberOffsetMap : null; newScale.calendarBreakOffset = origScale.calendarBreakOffset !== undefined ? origScale.calendarBreakOffset : null; newScale.tickDigits = origScale.tickDigits ? origScale.tickDigits : null; @@ -1705,6 +1706,16 @@ boxPlot.render(); return isNaN(d.getTime()) ? null : Math.round(d.getTime() / 86400000); }; + // Width (px) of one calendar-axis day slot: the per-date slot width, capped by the closest actual day + // spacing so same-day jitter and highlight rects never spill into the neighbouring day. + LABKEY.vis.calendarSlotWidth = function(xScale, gridWidth) { + let width = gridWidth / Math.max(xScale.dayCount || 0, 10); + if (xScale.minDayGapOffsets > 0 && xScale.scale) { + width = Math.min(width, Math.abs(xScale.scale(xScale.minDayGapOffsets) - xScale.scale(0))); + } + return width; + }; + // 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); @@ -1853,7 +1864,7 @@ boxPlot.render(); const prefixValue = config.properties.calendarPrefixValue; const dayOffsetMap = {}, dayOffsetLabelMap = {}, dayNumberOffsetMap = {}; let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null, ordinalPrefixApplied = false, - calendarBreakOffset = null; + calendarBreakOffset = null, minDayGapOffsets = 0; if (timeBasedXTick) { // One entry per distinct date label: day number, whether any row on it is tagged, whether any row on it is plottable. const labelInfo = {}, orderedLabels = []; @@ -1939,6 +1950,18 @@ boxPlot.render(); } } uniqueDayOffsets = Object.keys(dayOffsetLabelMap).map(Number).sort(function(a, b) { return a - b; }); + for (let k = 1; k < uniqueDayOffsets.length; k++) { + const gap = uniqueDayOffsets[k] - uniqueDayOffsets[k - 1]; + if (minDayGapOffsets === 0 || gap < minDayGapOffsets) { + minDayGapOffsets = gap; + } + } + + // a row whose date wouldn't parse has no place on a time-scaled axis, and faking an ordinal index for it + // would collide with a real day offset, so drop it rather than plant it mid-axis + config.data = config.data.filter(function(row) { + return dayOffsetMap[row[config.properties.xTickLabel]] !== undefined; + }); } // create a sequential index to use for the x-axis value and keep a map from that index to the tick label @@ -2329,10 +2352,7 @@ boxPlot.render(); }; if (timeBasedXTick) { - index = dayOffsetMap[row[config.properties.xTickLabel]]; - if (index === undefined) { - index = uniqueXAxisLabels.indexOf(row[config.properties.xTick]); - } + index = dayOffsetMap[row[config.properties.xTickLabel]]; // unmapped rows were filtered out above } else if (config.properties.groupMatchingXTick) { index = uniqueXAxisLabels.indexOf(row[config.properties.xTick]); } else { @@ -2455,6 +2475,7 @@ boxPlot.render(); 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 + config.scales.x.minDayGapOffsets = minDayGapOffsets; // closest spacing between two plotted days, caps that slot width config.scales.x.dayNumberOffsetMap = dayNumberOffsetMap; // day number -> x offset, for annotation overlay alignment config.scales.x.calendarBreakOffset = calendarBreakOffset; // x offset of the guide-set/window break, or null when there is no split // Anchor endpoints like the per-date scale (min 10 slots); space the interior by elapsed time. From ce68b67300ab37e2ecc3e409c7e7546a796a2911 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 28 Jul 2026 06:05:26 -0700 Subject: [PATCH 7/8] time-scale the guide set block in calendar mode, drop the break rule --- core/webapp/vis/src/internal/D3Renderer.js | 6 ++---- core/webapp/vis/src/plot.js | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 3e263a7731a..11dd54c9875 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -962,7 +962,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { } }; - // Mark where the calendar x-axis jumps from the ordinal guide-set block to the time-scaled date window. + // Mark where the calendar x-axis jumps the dead span between the guide-set block and the date window. var renderCalendarAxisBreak = function() { this.canvas.selectAll('g.calendar-axis-break').remove(); @@ -971,10 +971,8 @@ LABKEY.vis.internal.D3Renderer = function(plot) { return; } - var x = xScale.scale(xScale.calendarBreakOffset), top = plot.grid.topEdge, bottom = plot.grid.bottomEdge; + var x = xScale.scale(xScale.calendarBreakOffset), bottom = plot.grid.bottomEdge; var g = this.canvas.append('g').attr('class', 'calendar-axis-break'); - g.append('line').attr('x1', x).attr('x2', x).attr('y1', top).attr('y2', bottom) - .attr('stroke', '#B0B0B0').attr('stroke-width', 1).style('stroke-dasharray', '2,3'); // the "//" glyph straddling the axis line [-3, 1].forEach(function(dx) { g.append('line').attr('x1', x + dx - 3).attr('x2', x + dx + 3) diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index 1d959cd9686..c0411b7405d 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -1928,17 +1928,27 @@ boxPlot.render(); }; if (prefixLabels.length > 0 && recentLabels.length > 0) { - // Prefix days -> ordinal slots 0..p-1, then one blank slot, then the window time-spaced from its first day. + // Prefix block, then a blank gap, then the window time-spaced from its first day. ordinalPrefixApplied = true; prefixLabels.sort(byDn); recentLabels.sort(byDn); + + const firstPrefixDn = labelInfo[prefixLabels[0]].dn; + const firstRecentDn = labelInfo[recentLabels[0]].dn; + const prefixSpan = labelInfo[prefixLabels[prefixLabels.length - 1]].dn - firstPrefixDn; + const windowSpan = labelInfo[recentLabels[recentLabels.length - 1]].dn - firstRecentDn; + + // Space the guide-set block by elapsed days as well, unless it would out-span the window badly enough + // to crowd it out (an old, sparse guide set) - then fall back to one ordinal slot per day. + const timeScalePrefix = prefixSpan <= windowSpan * 2; for (let k = 0; k < prefixLabels.length; k++) { - putOffset(prefixLabels[k], k); + putOffset(prefixLabels[k], timeScalePrefix ? labelInfo[prefixLabels[k]].dn - firstPrefixDn : k); } - const gapSlots = 1; - calendarBreakOffset = prefixLabels.length - 0.5 + (gapSlots / 2); // midpoint of the blank slot - const firstRecentDn = labelInfo[recentLabels[0]].dn; - const windowStartOffset = prefixLabels.length + gapSlots; + + const gapSlots = 2; + const prefixEndOffset = timeScalePrefix ? prefixSpan : prefixLabels.length - 1; + calendarBreakOffset = prefixEndOffset + (gapSlots / 2); // midpoint of the blank gap + const windowStartOffset = prefixEndOffset + gapSlots; for (let k = 0; k < recentLabels.length; k++) { putOffset(recentLabels[k], windowStartOffset + (labelInfo[recentLabels[k]].dn - firstRecentDn)); } From 8ac823c08feab23dcf52d2f212a5bdf4a825d291 Mon Sep 17 00:00:00 2001 From: ankurjuneja Date: Tue, 28 Jul 2026 06:15:55 -0700 Subject: [PATCH 8/8] remove calendar break glyph, tick at regular calendar intervals per block --- core/webapp/vis/src/internal/D3Renderer.js | 20 ------- core/webapp/vis/src/plot.js | 70 ++++++++++++++-------- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 11dd54c9875..5975685ba28 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -962,25 +962,6 @@ LABKEY.vis.internal.D3Renderer = function(plot) { } }; - // Mark where the calendar x-axis jumps the dead span between the guide-set block and the date window. - var renderCalendarAxisBreak = function() { - this.canvas.selectAll('g.calendar-axis-break').remove(); - - var xScale = plot.scales.x; - if (!xScale || !xScale.scale || xScale.calendarBreakOffset === null || xScale.calendarBreakOffset === undefined) { - return; - } - - var x = xScale.scale(xScale.calendarBreakOffset), bottom = plot.grid.bottomEdge; - var g = this.canvas.append('g').attr('class', 'calendar-axis-break'); - // the "//" glyph straddling the axis line - [-3, 1].forEach(function(dx) { - g.append('line').attr('x1', x + dx - 3).attr('x2', x + dx + 3) - .attr('y1', bottom + 5).attr('y2', bottom - 5) - .attr('stroke', '#666666').attr('stroke-width', 1.5); - }); - }; - var renderXTopAxis = function() { if (!xTopAxis) { xTopAxis = LABKEY.vis.internal.Axis().orient('top'); @@ -1644,7 +1625,6 @@ LABKEY.vis.internal.D3Renderer = function(plot) { if (!plot.disableAxis.xTop) { renderXTopAxis.call(this); } if (!plot.disableAxis.yLeft) { renderYLeftAxis.call(this); } if (!plot.disableAxis.yRight) { renderYRightAxis.call(this); } - renderCalendarAxisBreak.call(this); addBrush.call(this); }; diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js index c0411b7405d..1fe77b78138 100644 --- a/core/webapp/vis/src/plot.js +++ b/core/webapp/vis/src/plot.js @@ -1858,13 +1858,16 @@ boxPlot.render(); } uniqueXAxisLabels = Object.keys(uniqueXAxisKeys).sort(); - // Calendar (time-based) x-axis: space each day by elapsed time; with an ordinal prefix (guide-set "always show") the prefix rows collapse to ordinal slots and only the recent window is time-spaced. + // Calendar (time-based) x-axis: space each day by elapsed time; with a prefix block (guide-set "always show") the dead span between it and the date window collapses to a fixed gap. const timeBasedXTick = config.properties.timeBasedXTick === true; const prefixField = config.properties.calendarPrefixField; const prefixValue = config.properties.calendarPrefixValue; const dayOffsetMap = {}, dayOffsetLabelMap = {}, dayNumberOffsetMap = {}; - let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null, ordinalPrefixApplied = false, + let uniqueDayOffsets = [], maxDayOffset = 0, minDayNumber = null, calendarBreakOffset = null, minDayGapOffsets = 0; + // Contiguous stretches of the axis, each linear in day number (the collapsed gap sits between them): + // {startOffset, startDayNumber, span, linear}. Ticks are chosen per block so labels stay evenly spaced. + let calendarBlocks = []; if (timeBasedXTick) { // One entry per distinct date label: day number, whether any row on it is tagged, whether any row on it is plottable. const labelInfo = {}, orderedLabels = []; @@ -1929,7 +1932,6 @@ boxPlot.render(); if (prefixLabels.length > 0 && recentLabels.length > 0) { // Prefix block, then a blank gap, then the window time-spaced from its first day. - ordinalPrefixApplied = true; prefixLabels.sort(byDn); recentLabels.sort(byDn); @@ -1952,12 +1954,17 @@ boxPlot.render(); for (let k = 0; k < recentLabels.length; k++) { putOffset(recentLabels[k], windowStartOffset + (labelInfo[recentLabels[k]].dn - firstRecentDn)); } + calendarBlocks = [ + { startOffset: 0, startDayNumber: firstPrefixDn, span: prefixEndOffset, linear: timeScalePrefix }, + { startOffset: windowStartOffset, startDayNumber: firstRecentDn, span: maxDayOffset - windowStartOffset, linear: true } + ]; } else { // No prefix split: every day spaced by its offset from the earliest day. for (let k = 0; k < orderedLabels.length; k++) { putOffset(orderedLabels[k], labelInfo[orderedLabels[k]].dn - minDayNumber); } + calendarBlocks = [{ startOffset: 0, startDayNumber: minDayNumber, span: maxDayOffset, linear: true }]; } uniqueDayOffsets = Object.keys(dayOffsetLabelMap).map(Number).sort(function(a, b) { return a - b; }); for (let k = 1; k < uniqueDayOffsets.length; k++) { @@ -2519,32 +2526,43 @@ boxPlot.render(); return dayOffsetLabelMap[offset] !== undefined ? dayOffsetLabelMap[offset] : ''; }; } - else if (ordinalPrefixApplied) { - // Piecewise axis: offsets aren't linear in day number, so tick only on data days (thinned to fit) and label from the offset->date map. - const picked = [uniqueDayOffsets[0]]; - for (let i = 1; i < uniqueDayOffsets.length; i++) { - if (uniqueDayOffsets[i] - picked[picked.length - 1] >= minLabelGapOffsets) { - picked.push(uniqueDayOffsets[i]); + else { + // Crowded: ticks at nice calendar intervals within each block, so labels stay evenly spaced and a + // reader can interpolate the unlabelled days rather than seeing an arbitrary subset of data days. + const tickValues = []; + let totalSpan = 0; + calendarBlocks.forEach(function(block) { totalSpan += block.span; }); + calendarBlocks.forEach(function(block) { + const blockTickMax = Math.max(Math.round(tickMax * block.span / (totalSpan || 1)), 2); + if (block.linear) { + LABKEY.vis.calendarTickOffsets(block.startDayNumber, block.span, blockTickMax, minLabelGapOffsets) + .forEach(function(off) { tickValues.push(block.startOffset + off); }); } - } - let thinned = picked; - if (picked.length > tickMax) { - thinned = []; - const step = Math.ceil(picked.length / tickMax); - for (let i = 0; i < picked.length; i += step) { - thinned.push(picked[i]); + else { + // ordinal block: offsets aren't linear in day number, so tick on its data days, thinned to fit + let last = null; + for (let i = 0; i < uniqueDayOffsets.length; i++) { + const off = uniqueDayOffsets[i]; + if (off >= block.startOffset && off <= block.startOffset + block.span + && (last === null || off - last >= minLabelGapOffsets)) { + tickValues.push(off); + last = off; + } + } } - } - config.scales.x.tickValues = thinned; - 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.tickValues = tickValues; config.scales.x.tickFormat = function(offset) { - return LABKEY.vis.dayNumberToDateLabel(offset + minDayNumber); + if (dayOffsetLabelMap[offset] !== undefined) { + return dayOffsetLabelMap[offset]; + } + for (let i = 0; i < calendarBlocks.length; i++) { + const block = calendarBlocks[i]; + if (block.linear && offset >= block.startOffset && offset <= block.startOffset + block.span) { + return LABKEY.vis.dayNumberToDateLabel(block.startDayNumber + offset - block.startOffset); + } + } + return ''; }; } }