From a61dfc6df6ce6ef4168f7f97aa8adae4eb55f277 Mon Sep 17 00:00:00 2001 From: ClaudiaGivan Date: Thu, 11 Dec 2025 18:12:33 +0200 Subject: [PATCH] Data update for renderers --- src/graphs/Renderer.js | 1 - src/graphs/UIControlsRenderer.js | 126 +++++++++++++++--- src/graphs/cfd/CFDRenderer.js | 47 +++++-- src/graphs/control-chart/ControlRenderer.js | 12 +- .../moving-range/MovingRangeRenderer.js | 18 ++- src/graphs/pbc/PBCRenderer.js | 20 ++- src/graphs/scatterplot/ScatterplotRenderer.js | 101 ++++++++++++-- 7 files changed, 277 insertions(+), 48 deletions(-) diff --git a/src/graphs/Renderer.js b/src/graphs/Renderer.js index b25ac5e..2a31fc9 100644 --- a/src/graphs/Renderer.js +++ b/src/graphs/Renderer.js @@ -31,7 +31,6 @@ export class Renderer { * @returns {d3.Selection} The created SVG element. */ createSvg(selector, height = this.height, width = this.width) { - this.graphElementSelector = selector; const htmlElement = document.querySelector(selector); if (htmlElement) { htmlElement.innerHTML = ''; diff --git a/src/graphs/UIControlsRenderer.js b/src/graphs/UIControlsRenderer.js index 873ad38..0d5eb18 100644 --- a/src/graphs/UIControlsRenderer.js +++ b/src/graphs/UIControlsRenderer.js @@ -135,13 +135,16 @@ export class UIControlsRenderer extends Renderer { * @param {number} noOfDays - The number of days for the reporting range. * @returns {Array} The computed start and end dates of the reporting range. */ + computeReportingRange(noOfDays) { - const finalDate = this.data[this.data.length - 1][this.datePropertyName]; + // Ensure finalDate is a Date object + const finalDateRaw = this.data[this.data.length - 1][this.datePropertyName]; + const finalDate = finalDateRaw instanceof Date ? finalDateRaw : new Date(finalDateRaw); let endDate = new Date(finalDate); let startDate = addDaysToDate(finalDate, -Number(noOfDays)); if (this.selectedTimeRange) { - endDate = new Date(this.selectedTimeRange[1]); - startDate = new Date(this.selectedTimeRange[0]); + endDate = this.selectedTimeRange[1] instanceof Date ? new Date(this.selectedTimeRange[1]) : new Date(this.selectedTimeRange[1]); + startDate = this.selectedTimeRange[0] instanceof Date ? new Date(this.selectedTimeRange[0]) : new Date(this.selectedTimeRange[0]); const diffDays = Number(noOfDays) - calculateDaysBetweenDates(startDate, endDate).roundedDays; if (diffDays < 0) { startDate = addDaysToDate(startDate, -Number(diffDays)); @@ -154,8 +157,11 @@ export class UIControlsRenderer extends Renderer { } } } - if (startDate < this.data[0][this.datePropertyName]) { - startDate = this.data[0][this.datePropertyName]; + // Ensure startDate and endDate are not before/after data bounds + const firstDateRaw = this.data[0][this.datePropertyName]; + const firstDate = firstDateRaw instanceof Date ? firstDateRaw : new Date(firstDateRaw); + if (startDate < firstDate) { + startDate = firstDate; } if (endDate < this.x.domain()[1]) { endDate = this.x.domain()[1]; @@ -173,20 +179,102 @@ export class UIControlsRenderer extends Renderer { createXAxis(x, timeInterval = this.timeInterval) { let axis; switch (timeInterval) { - case 'days': + case 'days': { axis = d3 .axisBottom(x) - .ticks(d3.timeDay.every(1)) // label every 2 days + .ticks(d3.timeDay.every(1)) .tickFormat((d, i) => { - return i % 2 === 0 ? d3.timeFormat('%b %d')(d) : ''; + const dayFormat = d3.timeFormat('%b %d'); + const yearFormat = d3.timeFormat('%Y'); + if (i === 0) return `${dayFormat(d)} ${yearFormat(d)}`; + return i % 2 === 0 ? dayFormat(d) : ''; }); break; - case 'weeks': - axis = d3.axisBottom(x).ticks(d3.timeWeek); + } + case 'weeks': { + const ticks = x.ticks(d3.timeDay); + // Find the first tick that is a Monday + let firstMonday = -1; + for (let i = 0; i < ticks.length; i++) { + if (ticks[i].getDay() === 1) { + firstMonday = i; + break; + } + } + axis = d3 + .axisBottom(x) + .ticks(d3.timeDay) + .tickFormat((d, i) => { + const dayFormat = d3.timeFormat('%b %d'); + const yearFormat = d3.timeFormat('%Y'); + if (i === firstMonday) return `${dayFormat(d)} ${yearFormat(d)}`; + return d.getDay() === 1 && i > firstMonday ? dayFormat(d) : ''; + }); break; - case 'months': - axis = d3.axisBottom(x).ticks(d3.timeMonth); + } + case 'months': { + const ticks = x.ticks(d3.timeWeek); + // Find the first tick that is the first week of a month + let firstMonthWeek = -1; + for (let i = 0; i < ticks.length; i++) { + if (ticks[i].getDate() <= 7) { + firstMonthWeek = i; + break; + } + } + const weeks = d3.timeWeek.range(x.domain()[0], x.domain()[1]); + axis = d3 + .axisBottom(x) + .ticks(d3.timeWeek) + .tickFormat((d, i) => { + const monthFormat = d3.timeFormat('%b'); + const yearFormat = d3.timeFormat('%Y'); + if (i === firstMonthWeek) return `${monthFormat(d)} ${yearFormat(d)}`; + if (d.getDate() <= 7 && i > firstMonthWeek) { + if (i > 0 && d.getFullYear() !== weeks[i - 1].getFullYear()) { + return `${monthFormat(d)} ${yearFormat(d)}`; + } + return monthFormat(d); + } + return ''; + }); break; + } + case 'bimonthly': { + const ticks = x.ticks(d3.timeMonth); + // Find the first tick that is the first month + let firstQuarterMonth = -1; + for (let i = 0; i < ticks.length; i++) { + if (ticks[i].getMonth() % 2 === 0) { + firstQuarterMonth = i; + break; + } + } + const months = d3.timeMonth.range(x.domain()[0], x.domain()[1]); + axis = d3 + .axisBottom(x) + .ticks(d3.timeMonth) + .tickFormat((d, i) => { + const monthFormat = d3.timeFormat('%b'); + const yearFormat = d3.timeFormat('%Y'); + if (i === firstQuarterMonth) return `${monthFormat(d)} ${yearFormat(d)}`; + if (d.getMonth() % 2 === 0 && i > firstQuarterMonth) { + // Show year if year changes from previous quarter tick + const prevQuarterIndex = (() => { + for (let j = i - 1; j >= 0; j--) { + if (months[j].getMonth() % 2 === 0) return j; + } + return -1; + })(); + if (prevQuarterIndex >= 0 && d.getFullYear() !== months[prevQuarterIndex].getFullYear()) { + return `${monthFormat(d)} ${yearFormat(d)}`; + } + return monthFormat(d); + } + return ''; + }); + break; + } default: return d3.axisBottom(x); } @@ -210,6 +298,9 @@ export class UIControlsRenderer extends Renderer { case 'days': this.timeInterval = 'weeks'; break; + case 'bimonthly': + this.timeInterval = 'weeks'; + break; default: this.timeInterval = 'weeks'; } @@ -220,14 +311,17 @@ export class UIControlsRenderer extends Renderer { this.eventBus?.emitEvents(`change-time-interval-${this.chartName}`, this.timeInterval); } - determineTheAppropriateAxisLabels() { - if (this.reportingRangeDays <= 31) { + determineTheAppropriateAxisLabels(noOfDays = this.reportingRangeDays) { + if (noOfDays <= 31) { return 'days'; } - if (this.reportingRangeDays > 31 && this.reportingRangeDays <= 124) { + if (noOfDays > 31 && noOfDays <= 150) { return 'weeks'; } - return 'months'; + if (noOfDays > 150 && noOfDays <= 750) { + return 'months'; + } + return 'bimonthly'; } /** diff --git a/src/graphs/cfd/CFDRenderer.js b/src/graphs/cfd/CFDRenderer.js index 9551251..2794cba 100644 --- a/src/graphs/cfd/CFDRenderer.js +++ b/src/graphs/cfd/CFDRenderer.js @@ -133,17 +133,43 @@ export class CFDRenderer extends UIControlsRenderer { } /** - * Clears the CFD graph and brush from the specified DOM elements. - * @param {string} graphElementSelector - Selector of the DOM element to clear the graph. - * @param {string} cfdBrushElementSelector - Selector of the DOM element to clear the brush. + * Updates the renderer's data and re-renders the graph, preserving persistent props. + * @param {Array} newData - The new data to render. + */ + updateData(newData) { + this.data = newData; + const dataDates = this.data.map((d) => new Date(d.date)); + const minDate = d3.min(dataDates); + const maxDate = d3.max(dataDates); + + if (this.selectedTimeRange) { + let [selectedStart, selectedEnd] = this.selectedTimeRange; + selectedStart = selectedStart < minDate ? minDate : selectedStart; + selectedEnd = selectedEnd > maxDate ? maxDate : selectedEnd; + this.selectedTimeRange = [selectedStart, selectedEnd]; + } else { + this.selectedTimeRange = [minDate, maxDate]; + } + if (this.graphElementSelector) { + this.clearGraph(this.graphElementSelector, this.brushSelector); + this.renderGraph(this.graphElementSelector); + this.renderBrush(this.brushSelector); + } + } + + /** + * Clears the graph and brush SVGs, removes listeners, and handles missing event bus gracefully. */ clearGraph(graphElementSelector, cfdBrushElementSelector) { - this.eventBus.removeAllListeners('change-time-range-scatterplot'); - this.eventBus.removeAllListeners('scatterplot-mousemove'); - this.eventBus.removeAllListeners('scatterplot-mouseleave'); - this.eventBus.removeAllListeners('change-time-interval-scatterplot'); - this.#drawBrushSvg(cfdBrushElementSelector); - this.#drawSvg(graphElementSelector); + if (this.eventBus && typeof this.eventBus.removeAllListeners === 'function') { + this.eventBus.removeAllListeners('change-time-range-scatterplot'); + this.eventBus.removeAllListeners('scatterplot-mousemove'); + this.eventBus.removeAllListeners('scatterplot-mouseleave'); + this.eventBus.removeAllListeners('change-time-interval-scatterplot'); + } + // Remove all children from the SVG elements + d3.select(graphElementSelector).selectAll('*').remove(); + d3.select(cfdBrushElementSelector).selectAll('*').remove(); } /** @@ -372,7 +398,8 @@ export class CFDRenderer extends UIControlsRenderer { g.selectAll('text').attr('y', 30).style('fill', 'black'); g.attr('clip-path', `url(#${clipId})`); } else { - axis = this.createXAxis(x, 'months'); + const noOfDataDays = calculateDaysBetweenDates(this.x.domain()[0], this.x.domain()[1]).roundedDays; + axis = this.createXAxis(x, this.determineTheAppropriateAxisLabels(noOfDataDays)); g.call(axis).attr('transform', `translate(0, ${height})`); } } diff --git a/src/graphs/control-chart/ControlRenderer.js b/src/graphs/control-chart/ControlRenderer.js index 4424b28..f405198 100644 --- a/src/graphs/control-chart/ControlRenderer.js +++ b/src/graphs/control-chart/ControlRenderer.js @@ -136,6 +136,7 @@ export class ControlRenderer extends ScatterplotRenderer { } renderGraph(graphElementSelector) { + this.graphElementSelector = graphElementSelector; this.drawSvg(graphElementSelector); this.drawAxes(); this.drawArea(); @@ -190,16 +191,21 @@ export class ControlRenderer extends ScatterplotRenderer { } drawScatterplot(chartArea, data, x, y) { + // Ensure deliveredDate is a Date object + const safeData = data.map((d) => ({ + ...d, + deliveredDate: d.deliveredDate instanceof Date ? d.deliveredDate : new Date(d.deliveredDate), + })); chartArea .selectAll(`.${this.dotClass}`) - .data(data) + .data(safeData) .enter() .append('circle') .attr('class', this.dotClass) .attr('id', (d) => `control-${d.sourceId}`) .attr('data-date', (d) => d.deliveredDate) .attr('r', (d) => { - const overlapping = data.filter((item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.value === d.value); + const overlapping = safeData.filter((item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.value === d.value); return overlapping.length > 1 ? 7 : 5; }) .attr('cx', (d) => x(d.deliveredDate)) @@ -207,7 +213,7 @@ export class ControlRenderer extends ScatterplotRenderer { .style('cursor', 'pointer') .attr('fill', this.color) .on('click', (event, d) => this.handleMouseClickEvent(event, d)); - this.connectDots && this.generateLines(chartArea, data, x, y); + this.connectDots && this.generateLines(chartArea, safeData, x, y); } generateLines(chartArea, data, x, y) { diff --git a/src/graphs/moving-range/MovingRangeRenderer.js b/src/graphs/moving-range/MovingRangeRenderer.js index cd3ce53..ac48bc7 100644 --- a/src/graphs/moving-range/MovingRangeRenderer.js +++ b/src/graphs/moving-range/MovingRangeRenderer.js @@ -18,6 +18,7 @@ export class MovingRangeRenderer extends ScatterplotRenderer { } renderGraph(graphElementSelector) { + this.graphElementSelector = graphElementSelector; this.drawSvg(graphElementSelector); this.drawAxes(); this.drawArea(); @@ -74,7 +75,7 @@ export class MovingRangeRenderer extends ScatterplotRenderer { } drawLimits() { - // Remove existing limits + // Remove existing limits first this.svg.selectAll('[id^="line-"], [id^="text-"]').remove(); // Draw new limits Object.entries(this.limitData).forEach(([limitType, limitValue]) => { @@ -185,15 +186,20 @@ export class MovingRangeRenderer extends ScatterplotRenderer { } drawScatterplot(chartArea, data, x, y) { + // Ensure deliveredDate is a Date object + const safeData = data.map((d) => ({ + ...d, + deliveredDate: d.deliveredDate instanceof Date ? d.deliveredDate : new Date(d.deliveredDate), + })); chartArea .selectAll(`.${this.dotClass}`) - .data(data) + .data(safeData) .enter() .append('circle') .attr('class', this.dotClass) .attr('id', (d) => `mr-${d.fromSourceId}-${d.toSourceId}`) .attr('r', (d) => { - const overlapping = data.filter((item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.value === d.value); + const overlapping = safeData.filter((item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.value === d.value); return overlapping.length > 1 ? 7 : 5; }) .attr('cx', (d) => x(d.deliveredDate)) @@ -209,7 +215,7 @@ export class MovingRangeRenderer extends ScatterplotRenderer { .y((d) => this.applyYScale(y, d.value)); chartArea .selectAll('dot-line') - .data([data]) + .data([safeData]) .enter() .append('path') .attr('class', 'dot-line') @@ -231,6 +237,10 @@ export class MovingRangeRenderer extends ScatterplotRenderer { this.drawSignals(); } + clearGraph(graphElementSelector) { + this.svg.select(graphElementSelector).selectAll('*').remove(); + } + cleanup() { this.limitData = {}; this.visibleLimits = {}; diff --git a/src/graphs/pbc/PBCRenderer.js b/src/graphs/pbc/PBCRenderer.js index 3f7eb62..34657c6 100644 --- a/src/graphs/pbc/PBCRenderer.js +++ b/src/graphs/pbc/PBCRenderer.js @@ -84,8 +84,8 @@ export class PBCRenderer extends UIControlsRenderer { */ syncChartProperties() { if (!this.controlRenderer || !this.movingRangeRenderer) return; - this.movingRangeRenderer.reportingRangeDays = 30; - this.controlRenderer.reportingRangeDays = 30; + this.movingRangeRenderer.reportingRangeDays = 90; + this.controlRenderer.reportingRangeDays = 90; this.movingRangeRenderer.timeInterval = 'months'; this.controlRenderer.timeInterval = 'months'; this.movingRangeRenderer.timeScale = 'logarithmic'; @@ -134,6 +134,22 @@ export class PBCRenderer extends UIControlsRenderer { }); } + /** + * Updates the renderer's data and re-renders the graph, preserving persistent props. + * @param {Array} newControlData - The new control chart data. + * @param {Array} newMovingRangeData - The new moving range chart data. + */ + updateData(newControlData, newMovingRangeData) { + this.controlData = newControlData; + this.movingRangeData = newMovingRangeData; + if (this.controlRenderer && this.movingRangeRenderer) { + this.controlRenderer.updateData(newControlData); + this.movingRangeRenderer.updateData(newMovingRangeData); + this.syncChartProperties(); + this.setupBrush(this.brushSelector); + } + } + /** * Clear both charts */ diff --git a/src/graphs/scatterplot/ScatterplotRenderer.js b/src/graphs/scatterplot/ScatterplotRenderer.js index c4de051..795771d 100644 --- a/src/graphs/scatterplot/ScatterplotRenderer.js +++ b/src/graphs/scatterplot/ScatterplotRenderer.js @@ -114,16 +114,70 @@ export class ScatterplotRenderer extends UIControlsRenderer { ); } + /** + * Updates the renderer's data and re-renders the graph, preserving persistent props. + * @param {Array} newData - The new data to render. + */ + updateData(newData) { + this.data = newData; + this.computeXScale(); + //Use the x scale domain (which includes buffer) for min/max dates + const [minDate, maxDate] = this.x.domain(); + // SelectedTimeRange edge cases + let shouldResetRange = false; + + if (!this.selectedTimeRange || this.selectedTimeRange[1] < minDate || this.selectedTimeRange[0] > maxDate) { + shouldResetRange = true; + } + + if (shouldResetRange) { + // Reset to default time range (full data range with buffer) + this.selectedTimeRange = [minDate, maxDate]; + } else { + // Adjust selectedTimeRange to fit within new data bounds + let [selectedStart, selectedEnd] = this.selectedTimeRange; + selectedStart = selectedStart < minDate ? minDate : selectedStart; + selectedEnd = selectedEnd > maxDate ? maxDate : selectedEnd; + this.selectedTimeRange = [selectedStart, selectedEnd]; + } + + if (this.graphElementSelector) { + this.clearGraph(this.graphElementSelector, this.brushSelector); + this.renderGraph(this.graphElementSelector); + const brushElement = document.querySelector(this.brushSelector); + if (brushElement) { + this.renderBrush(this.brushSelector); + } + } + } + /** * Clears the Scatterplot graph from specified DOM elements. * @param {string} graphElementSelector - The selector of the graph element to clear. * @param {string} brushElementSelector - The selector of the brush element to clear. */ clearGraph(graphElementSelector, brushElementSelector) { - this.eventBus?.removeAllListeners('change-time-interval-cfd'); - this.eventBus?.removeAllListeners('change-time-range-cfd'); - this.drawBrushSvg(brushElementSelector); - this.drawSvg(graphElementSelector); + // Only remove listeners if eventBus exists and has removeAllListeners + if (this.eventBus && typeof this.eventBus.removeAllListeners === 'function') { + this.eventBus.removeAllListeners('change-time-interval-cfd'); + this.eventBus.removeAllListeners('change-time-range-cfd'); + } + // Only clear brush SVG if it exists + const brushElement = brushElementSelector ? document.querySelector(brushElementSelector) : null; + if (brushElement && brushElement.firstChild) { + while (brushElement.firstChild) { + brushElement.removeChild(brushElement.firstChild); + } + this.drawBrushSvg(brushElementSelector); + } + // Only clear main SVG if it exists + const graphElement = graphElementSelector ? document.querySelector(graphElementSelector) : null; + if (graphElement && graphElement.firstChild) { + while (graphElement.firstChild) { + graphElement.removeChild(graphElement.firstChild); + } + this.drawSvg(graphElementSelector); + } } /** @@ -135,7 +189,12 @@ export class ScatterplotRenderer extends UIControlsRenderer { } updateChartArea(domain) { - const maxValue = d3.max(this.data, (d) => (d.deliveredDate <= domain[1] && d.deliveredDate >= domain[0] ? d.value : -1)); + // Filter data within the domain first, ensuring deliveredDate is a Date object + const filteredData = this.data.filter((d) => { + const date = d.deliveredDate instanceof Date ? d.deliveredDate : new Date(d.deliveredDate); + return date <= domain[1] && date >= domain[0]; + }); + const maxValue = filteredData.length > 0 ? d3.max(filteredData, (d) => d.value) : 1; const minYValue = 128; const maxY = this.topLimit ? Math.max(maxValue, this.topLimit + 2, minYValue) : Math.max(maxValue + 2, minYValue); this.reportingRangeDays = calculateDaysBetweenDates(domain[0], domain[1]).roundedDays; @@ -144,7 +203,10 @@ export class ScatterplotRenderer extends UIControlsRenderer { if (this.timeScale === 'logarithmic') { this.currentYScale = this.y.copy().domain([0.5, maxY]).nice(); } - const focusData = this.data.filter((d) => d.deliveredDate <= domain[1] && d.deliveredDate >= domain[0]); + const focusData = this.data.filter((d) => { + const date = d.deliveredDate instanceof Date ? d.deliveredDate : new Date(d.deliveredDate); + return date <= domain[1] && date >= domain[0]; + }); this.changeTimeInterval(false); this.drawXAxis(this.gx, this.currentXScale, this.height, true); this.drawYAxis(this.gy, this.currentYScale); @@ -200,9 +262,14 @@ export class ScatterplotRenderer extends UIControlsRenderer { * @param {d3.Scale} y - The Y-axis scale. */ drawScatterplot(chartArea, data, x, y) { + // Ensure deliveredDate is a Date object + const safeData = data.map((d) => ({ + ...d, + deliveredDate: d.deliveredDate instanceof Date ? d.deliveredDate : new Date(d.deliveredDate), + })); chartArea .selectAll(`.${this.dotClass}`) - .data(data) + .data(safeData) .enter() .append('circle') .attr('class', this.dotClass) @@ -338,7 +405,7 @@ export class ScatterplotRenderer extends UIControlsRenderer { } computeXScale() { - const bufferDays = 5; + const bufferDays = 2; const xExtent = d3.extent(this.data, (d) => d.deliveredDate); const minDate = new Date(xExtent[0]); const maxDate = new Date(xExtent[1]); @@ -405,7 +472,8 @@ export class ScatterplotRenderer extends UIControlsRenderer { d3.select(this).transition().duration(300).attr('height', axisHeight); }); } else { - const axis = this.createXAxis(x, 'months'); + const noOfDataDays = calculateDaysBetweenDates(this.x.domain()[0], this.x.domain()[1]).roundedDays; + const axis = this.createXAxis(x, this.determineTheAppropriateAxisLabels(noOfDataDays)); g.call(axis).attr('transform', `translate(0, ${height})`); const outerXAxisTicks = g.append('g').attr('class', 'outer-ticks').call(axis?.tickSize(-height).tickFormat('')); outerXAxisTicks.selectAll('.tick line').attr('opacity', 0.1); @@ -558,13 +626,22 @@ export class ScatterplotRenderer extends UIControlsRenderer { * @private */ handleMouseClickEvent(event, d) { + // Ensure deliveredDate is a Date object for d and for all tickets + const safeD = { + ...d, + deliveredDate: d.deliveredDate instanceof Date ? d.deliveredDate : new Date(d.deliveredDate), + }; + const safeData = this.data.map((ticket) => ({ + ...ticket, + deliveredDate: ticket.deliveredDate instanceof Date ? ticket.deliveredDate : new Date(ticket.deliveredDate), + })); // Find all tickets with the same values - const overlappingTickets = this.data.filter( - (ticket) => ticket.deliveredDate.getTime() === d.deliveredDate.getTime() && ticket.value === d.value + const overlappingTickets = safeData.filter( + (ticket) => ticket.deliveredDate.getTime() === safeD.deliveredDate.getTime() && ticket.value === safeD.value ); let data = { - ...d, + ...safeD, tooltipLeft: event.pageX, tooltipTop: event.pageY, overlappingTickets: overlappingTickets,