Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/graphs/Renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
126 changes: 110 additions & 16 deletions src/graphs/UIControlsRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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];
Expand All @@ -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);
}
Expand All @@ -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';
}
Expand All @@ -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';
}

/**
Expand Down
47 changes: 37 additions & 10 deletions src/graphs/cfd/CFDRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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})`);
}
}
Expand Down
12 changes: 9 additions & 3 deletions src/graphs/control-chart/ControlRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export class ControlRenderer extends ScatterplotRenderer {
}

renderGraph(graphElementSelector) {
this.graphElementSelector = graphElementSelector;
this.drawSvg(graphElementSelector);
this.drawAxes();
this.drawArea();
Expand Down Expand Up @@ -190,24 +191,29 @@ 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))
.attr('cy', (d) => this.applyYScale(y, d.value))
.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) {
Expand Down
18 changes: 14 additions & 4 deletions src/graphs/moving-range/MovingRangeRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class MovingRangeRenderer extends ScatterplotRenderer {
}

renderGraph(graphElementSelector) {
this.graphElementSelector = graphElementSelector;
this.drawSvg(graphElementSelector);
this.drawAxes();
this.drawArea();
Expand Down Expand Up @@ -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]) => {
Expand Down Expand Up @@ -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))
Expand All @@ -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')
Expand All @@ -231,6 +237,10 @@ export class MovingRangeRenderer extends ScatterplotRenderer {
this.drawSignals();
}

clearGraph(graphElementSelector) {
this.svg.select(graphElementSelector).selectAll('*').remove();
}

cleanup() {
this.limitData = {};
this.visibleLimits = {};
Expand Down
20 changes: 18 additions & 2 deletions src/graphs/pbc/PBCRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down
Loading