diff --git a/src/graphs/control-chart/ControlRenderer.js b/src/graphs/control-chart/ControlRenderer.js
index 9c59cc9..ae6dd2c 100644
--- a/src/graphs/control-chart/ControlRenderer.js
+++ b/src/graphs/control-chart/ControlRenderer.js
@@ -6,63 +6,187 @@ export class ControlRenderer extends ScatterplotRenderer {
timeScale = 'logarithmic';
connectDots = false;
- constructor(data, avgMovingRangeFunc, chartName, workTicketsURL) {
+ constructor(data, chartName, workTicketsURL) {
super(data);
this.chartName = chartName;
this.chartType = 'CONTROL';
this.workTicketsURL = workTicketsURL;
- this.avgMovingRangeFunc = avgMovingRangeFunc;
this.dotClass = 'control-dot';
this.yAxisLabel = 'Days';
+ this.limitData = {};
+ this.processSignalsData = {};
+ this.visibleLimits = {};
+ this.activeProcessSignal = null;
}
- renderGraph(graphElementSelector) {
- this.drawSvg(graphElementSelector);
- this.drawAxes();
- this.drawArea();
- this.computeGraphLimits();
- this.drawGraphLimits(this.y);
- this.setupMouseLeaveHandler();
+ setLimitData(limitData) {
+ this.limitData = {
+ naturalProcessLimits: limitData?.naturalProcessLimits || null,
+ twoSigma: limitData?.twoSigma || null,
+ oneSigma: limitData?.oneSigma || null,
+ averageCycleTime: limitData?.averageCycleTime || null,
+ };
+ this.topLimit = limitData?.naturalProcessLimits?.upper;
+ this.drawLimits();
+ }
+
+ setProcessSignalsData(signalsData) {
+ this.processSignalsData = {
+ largeChange: signalsData?.largeChange || null,
+ moderateChange: signalsData?.moderateChange || null,
+ moderateSustainedShift: signalsData?.moderateSustainedShift || null,
+ smallSustainedShift: signalsData?.smallSustainedShift || null,
+ };
}
- drawGraphLimits(yScale) {
- this.drawHorizontalLine(yScale, this.topLimit, 'purple', 'top-pb', `UPL=${this.topLimit}`);
- this.drawHorizontalLine(yScale, this.avgLeadTime, 'orange', 'mid-pb', `Avg=${this.avgLeadTime}`);
+ setVisibleLimits(limitConfig) {
+ this.visibleLimits = { ...limitConfig };
+ this.updateLimitVisibility();
+ }
- if (this.bottomLimit > 0) {
- this.drawHorizontalLine(yScale, this.bottomLimit, 'purple', 'bottom-pb', `LPL=${this.bottomLimit}`);
- } else {
- console.warn('The bottom limit is:', this.bottomLimit);
+ setActiveProcessSignal(signalType) {
+ this.hideSignals();
+ this.activeProcessSignal = signalType;
+ this.showActiveSignal();
+ }
+
+ drawLimits() {
+ // Remove existing limits first
+ this.svg.selectAll('[id^="line-"], [id^="text-"]').remove();
+
+ // Draw new limits
+ Object.entries(this.limitData).forEach(([limitType, limitValue]) => {
+ if (limitValue) {
+ this.drawLimit(limitType, limitValue);
+ }
+ });
+
+ this.updateLimitVisibility();
+ }
+
+ drawLimit(limitType, limitValue) {
+ const limitConfig = {
+ naturalProcessLimits: { dash: '3 2', text: 'NPL', color: 'orange' },
+ twoSigma: { dash: '12 8', text: '2s', color: 'orange' },
+ oneSigma: { dash: '20 10', text: '1s', color: 'orange' },
+ averageCycleTime: { dash: '7', text: 'Avg', color: 'purple' },
+ };
+
+ const config = limitConfig[limitType];
+ if (!config) return;
+
+ if (typeof limitValue === 'number') {
+ this.drawHorizontalLine(this.currentYScale, limitValue, config.color, limitType, `${config.text}=${limitValue}`, config.dash);
+ } else if (limitValue && typeof limitValue === 'object') {
+ if (limitValue.upper !== undefined) {
+ this.drawHorizontalLine(
+ this.currentYScale,
+ limitValue.upper,
+ config.color,
+ `${limitType}-upper`,
+ `${config.text}U=${limitValue.upper}`,
+ config.dash
+ );
+ }
+ if (limitValue.lower !== undefined && limitValue.lower > 0) {
+ this.drawHorizontalLine(
+ this.currentYScale,
+ limitValue.lower,
+ config.color,
+ `${limitType}-lower`,
+ `${config.text}L=${limitValue.lower}`,
+ config.dash
+ );
+ }
+ }
+ }
+
+ updateLimitVisibility() {
+ Object.entries(this.visibleLimits).forEach(([limitType, isVisible]) => {
+ const display = isVisible ? 'block' : 'none';
+ // Handle both single limits and upper/lower pairs
+ this.svg.select(`#line-${limitType}`).style('display', display);
+ this.svg.select(`#text-${limitType}`).style('display', display);
+ this.svg.select(`#line-${limitType}-upper`).style('display', display);
+ this.svg.select(`#text-${limitType}-upper`).style('display', display);
+ this.svg.select(`#line-${limitType}-lower`).style('display', display);
+ this.svg.select(`#text-${limitType}-lower`).style('display', display);
+ });
+ }
+
+ showActiveSignal() {
+ if (!this.activeProcessSignal || !this.processSignalsData[this.activeProcessSignal]) {
+ return;
}
+
+ const signals = this.processSignalsData[this.activeProcessSignal];
+ this.drawSignals(signals);
}
- computeGraphLimits() {
- this.avgLeadTime = this.getAvgLeadTime();
- const avgMovingRange = this.avgMovingRangeFunc(this.baselineStartDate, this.baselineEndDate);
- this.topLimit = Math.ceil(this.avgLeadTime + avgMovingRange * 2.66);
+ hideSignals() {
+ this.svg.selectAll('.signal-point').classed('signal-point', false).attr('fill', this.color);
+ }
- this.bottomLimit = Math.ceil(this.avgLeadTime - avgMovingRange * 2.66);
- const maxY = this.y.domain()[1] > this.topLimit ? this.y.domain()[1] : this.topLimit + 5;
- let minY = this.y.domain()[0];
- if (this.bottomLimit > 5) {
- minY = this.y.domain()[0] < this.bottomLimit ? this.y.domain()[0] : this.bottomLimit - 5;
+ drawSignals(signals) {
+ if (signals.upper && signals.lower) {
+ [...signals.upper, ...signals.lower].forEach((id) => {
+ this.svg.select(`#control-${id}`).classed('signal-point', true).transition().duration(200).attr('fill', 'orange');
+ });
}
- this.y.domain([minY, maxY]);
+ }
+
+ renderGraph(graphElementSelector) {
+ this.drawSvg(graphElementSelector);
+ this.drawAxes();
+ this.drawArea();
+ this.drawLimits();
+ this.showActiveSignal();
}
populateTooltip(event) {
- this.tooltip
- .style('pointer-events', 'auto')
- .style('opacity', 0.9)
- .append('div')
- .append('a')
- .style('text-decoration', 'underline')
- .attr('href', `${this.workTicketsURL}/${event.ticketId}`)
- .text(event.ticketId)
- .attr('target', '_blank')
- .on('click', () => {
- this.hideTooltip();
+ this.tooltip.style('pointer-events', 'auto').style('opacity', 0.9);
+
+ if (event.overlappingTickets && event.overlappingTickets.length > 1) {
+ // Add header for multiple tickets
+ this.tooltip
+ .append('div')
+ .style('font-weight', 'bold')
+ .style('margin-bottom', '8px')
+ .text(`${event.overlappingTickets.length} tickets at this point:`);
+
+ event.overlappingTickets.forEach((ticket) => {
+ const ticketDiv = this.tooltip.append('div').style('margin-bottom', '4px');
+
+ ticketDiv
+ .append('a')
+ .style('text-decoration', 'underline')
+ .attr('href', `${this.workTicketsURL}/${ticket.ticketId}`)
+ .text(ticket.ticketId)
+ .attr('target', '_blank')
+ .on('click', () => {
+ this.hideTooltip();
+ });
});
+
+ // Optionally add shared information (date, lead time)
+ if (event.date && event.metrics) {
+ this.tooltip.append('div').style('margin-top', '8px').style('font-size', '12px').style('color', '#666').html(`
+
Date: ${event.date}
+ Lead Time: ${event.metrics.leadTime} days
+ `);
+ }
+ } else {
+ this.tooltip
+ .append('div')
+ .append('a')
+ .style('text-decoration', 'underline')
+ .attr('href', `${this.workTicketsURL}/${event.ticketId}`)
+ .text(event.ticketId)
+ .attr('target', '_blank')
+ .on('click', () => {
+ this.hideTooltip();
+ });
+ }
}
drawScatterplot(chartArea, data, x, y) {
@@ -74,7 +198,12 @@ export class ControlRenderer extends ScatterplotRenderer {
.attr('class', this.dotClass)
.attr('id', (d) => `control-${d.ticketId}`)
.attr('data-date', (d) => d.deliveredDate)
- .attr('r', 5)
+ .attr('r', (d) => {
+ const overlapping = data.filter(
+ (item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.leadTime === d.leadTime
+ );
+ return overlapping.length > 1 ? 7 : 5;
+ })
.attr('cx', (d) => x(d.deliveredDate))
.attr('cy', (d) => this.applyYScale(y, d.leadTime))
.style('cursor', 'pointer')
@@ -83,11 +212,6 @@ export class ControlRenderer extends ScatterplotRenderer {
this.connectDots && this.generateLines(chartArea, data, x, y);
}
- getAvgLeadTime() {
- const filteredData = this.data.filter((d) => d.deliveredDate >= this.baselineStartDate && d.deliveredDate <= this.baselineEndDate);
- return Math.ceil(filteredData.reduce((acc, curr) => acc + curr.leadTime, 0) / filteredData.length);
- }
-
generateLines(chartArea, data, x, y) {
// Define the line generator
const line = d3
@@ -108,7 +232,6 @@ export class ControlRenderer extends ScatterplotRenderer {
}
updateGraph(domain) {
- this.computeGraphLimits();
this.updateChartArea(domain);
if (this.connectDots) {
const line = d3
@@ -117,7 +240,15 @@ export class ControlRenderer extends ScatterplotRenderer {
.y((d) => this.applyYScale(this.currentYScale, d.leadTime));
this.chartArea.selectAll('.dot-line').attr('d', line);
}
- this.drawGraphLimits(this.currentYScale);
+ this.drawLimits();
+ this.showActiveSignal();
this.displayObservationMarkers(this.observations);
}
+
+ cleanup() {
+ this.limitData = {};
+ this.processSignalsData = {};
+ this.visibleLimits = {};
+ this.activeProcessSignal = null;
+ }
}
diff --git a/src/graphs/moving-range/MovingRangeRenderer.js b/src/graphs/moving-range/MovingRangeRenderer.js
index a7197e1..2a8274b 100644
--- a/src/graphs/moving-range/MovingRangeRenderer.js
+++ b/src/graphs/moving-range/MovingRangeRenderer.js
@@ -5,60 +5,143 @@ export class MovingRangeRenderer extends ScatterplotRenderer {
color = '#0ea5e9';
timeScale = 'logarithmic';
- constructor(data, avgMovingRangeFunc, workTicketsURL, chartName) {
+ constructor(data, workTicketsURL, chartName) {
super(data);
- this.avgMovingRangeFunc = avgMovingRangeFunc;
this.workTicketsURL = workTicketsURL;
this.chartName = chartName;
this.chartType = 'MOVING_RANGE';
this.dotClass = 'moving-range-dot';
this.yAxisLabel = 'Moving Range';
+ this.limitData = {};
+ this.visibleLimits = {};
}
renderGraph(graphElementSelector) {
this.drawSvg(graphElementSelector);
this.drawAxes();
this.drawArea();
- this.computeGraphLimits();
- this.drawGraphLimits(this.y);
this.setupMouseLeaveHandler();
+ this.drawLimits();
}
- drawGraphLimits(yScale) {
- const avgMovingRange = this.avgMovingRangeFunc(this.baselineStartDate, this.baselineEndDate);
- this.drawHorizontalLine(yScale, this.topLimit, 'purple', 'top-mr', `UPL=${this.topLimit}`);
- this.drawHorizontalLine(yScale, avgMovingRange, 'orange', 'mid-mr', `Avg=${avgMovingRange}`);
+ setLimitData(limitData) {
+ this.limitData = {
+ averageMR: limitData?.averageMR || null,
+ url: limitData?.URL || null,
+ };
+ this.topLimit = limitData?.URL;
+ this.drawLimits();
}
- computeGraphLimits() {
- this.topLimit = 3.27 * this.avgMovingRangeFunc(this.baselineStartDate, this.baselineEndDate);
- const maxY = this.y.domain()[1] > this.topLimit ? this.y.domain()[1] : this.topLimit + 5;
- this.y.domain([this.y.domain()[0], maxY]);
+ drawLimits() {
+ // Remove existing limits
+ this.svg.selectAll('[id^="line-"], [id^="text-"]').remove();
+ // Draw new limits
+ Object.entries(this.limitData).forEach(([limitType, limitValue]) => {
+ if (limitValue) {
+ this.drawLimit(limitType, limitValue);
+ }
+ });
+ this.updateLimitVisibility();
+ }
+
+ drawLimit(limitType, limitValue) {
+ const limitConfig = {
+ averageMR: { dash: '3 2', text: 'Avg', color: 'purple' },
+ url: { dash: '12 8', text: 'URL', color: 'orange' },
+ };
+
+ const config = limitConfig[limitType];
+ if (config) {
+ this.drawHorizontalLine(this.currentYScale, limitValue, config.color, limitType, `${config.text}=${limitValue}`, config.dash);
+ }
+ }
+
+ setVisibleLimits(limitConfig) {
+ this.visibleLimits = { ...limitConfig };
+ this.updateLimitVisibility();
+ }
+
+ updateLimitVisibility() {
+ Object.entries(this.visibleLimits).forEach(([limitType, isVisible]) => {
+ const display = isVisible ? 'block' : 'none';
+ this.svg.select(`#line-${limitType}`).style('display', display);
+ this.svg.select(`#text-${limitType}`).style('display', display);
+ });
}
populateTooltip(event) {
- this.tooltip
- .style('pointer-events', 'auto')
- .style('opacity', 0.9)
- .append('div')
- .append('a')
- .style('text-decoration', 'underline')
- .attr('href', `${this.workTicketsURL}/${event.workItem1}`)
- .text(event.workItem1)
- .attr('target', '_blank')
- .on('click', () => {
- this.hideTooltip();
- });
- this.tooltip
- .append('div')
- .append('a')
- .style('text-decoration', 'underline')
- .attr('href', `${this.workTicketsURL}/${event.workItem2}`)
- .text(event.workItem2)
- .attr('target', '_blank')
- .on('click', () => {
- this.hideTooltip();
+ this.tooltip.style('pointer-events', 'auto').style('opacity', 0.9);
+
+ if (event.overlappingTickets && event.overlappingTickets.length > 1) {
+ // Add header for multiple moving range pairs
+ this.tooltip
+ .append('div')
+ .style('font-weight', 'bold')
+ .style('margin-bottom', '8px')
+ .text(`${event.overlappingTickets.length} moving range pairs at this point:`);
+
+ event.overlappingTickets.forEach((ticket) => {
+ const pairDiv = this.tooltip
+ .append('div')
+ .style('margin-bottom', '6px')
+ .style('padding', '4px')
+ .style('border-left', '2px solid #ddd')
+ .style('padding-left', '8px');
+
+ const item1Div = pairDiv.append('div').style('margin-bottom', '2px');
+ item1Div
+ .append('a')
+ .style('text-decoration', 'underline')
+ .attr('href', `${this.workTicketsURL}/${ticket.workItem1}`)
+ .text(ticket.workItem1)
+ .attr('target', '_blank')
+ .on('click', () => {
+ this.hideTooltip();
+ });
+
+ const item2Div = pairDiv.append('div');
+ item2Div
+ .append('a')
+ .style('text-decoration', 'underline')
+ .attr('href', `${this.workTicketsURL}/${ticket.workItem2}`)
+ .text(ticket.workItem2)
+ .attr('target', '_blank')
+ .on('click', () => {
+ this.hideTooltip();
+ });
});
+
+ // Optionally add shared information (moving range value, date, etc.)
+ if (event.date && event.metrics) {
+ this.tooltip.append('div').style('margin-top', '8px').style('font-size', '12px').style('color', '#666').html(`
+ Date: ${event.date}
+ Moving Range: ${event.metrics.movingRange || event.movingRange} days
+ `);
+ }
+ } else {
+ this.tooltip
+ .append('div')
+ .append('a')
+ .style('text-decoration', 'underline')
+ .attr('href', `${this.workTicketsURL}/${event.workItem1}`)
+ .text(event.workItem1)
+ .attr('target', '_blank')
+ .on('click', () => {
+ this.hideTooltip();
+ });
+
+ this.tooltip
+ .append('div')
+ .append('a')
+ .style('text-decoration', 'underline')
+ .attr('href', `${this.workTicketsURL}/${event.workItem2}`)
+ .text(event.workItem2)
+ .attr('target', '_blank')
+ .on('click', () => {
+ this.hideTooltip();
+ });
+ }
}
drawScatterplot(chartArea, data, x, y) {
@@ -68,7 +151,12 @@ export class MovingRangeRenderer extends ScatterplotRenderer {
.enter()
.append('circle')
.attr('class', this.dotClass)
- .attr('r', 5)
+ .attr('r', (d) => {
+ const overlapping = data.filter(
+ (item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.leadTime === d.leadTime
+ );
+ return overlapping.length > 1 ? 7 : 5;
+ })
.attr('cx', (d) => x(d.deliveredDate))
.attr('cy', (d) => this.applyYScale(y, d.leadTime))
.style('cursor', 'pointer')
@@ -86,7 +174,7 @@ export class MovingRangeRenderer extends ScatterplotRenderer {
.enter()
.append('path')
.attr('class', 'dot-line')
- .attr('id', (d) => `line-${d.ticketId}`)
+ .attr('id', (d) => `dot-line-${d.ticketId}`)
.attr('d', line)
.attr('stroke', 'black')
.attr('stroke-width', 2)
@@ -94,13 +182,17 @@ export class MovingRangeRenderer extends ScatterplotRenderer {
}
updateGraph(domain) {
- this.computeGraphLimits();
this.updateChartArea(domain);
const line = d3
.line()
.x((d) => this.currentXScale(d.deliveredDate))
.y((d) => this.applyYScale(this.currentYScale, d.leadTime));
this.chartArea.selectAll('.dot-line').attr('d', line);
- this.drawGraphLimits(this.currentYScale);
+ this.drawLimits();
+ }
+
+ cleanup() {
+ this.limitData = {};
+ this.visibleLimits = {};
}
}
diff --git a/src/graphs/pbc/PBCRenderer.js b/src/graphs/pbc/PBCRenderer.js
index 013d595..3f7eb62 100644
--- a/src/graphs/pbc/PBCRenderer.js
+++ b/src/graphs/pbc/PBCRenderer.js
@@ -1,15 +1,13 @@
import { UIControlsRenderer } from '../UIControlsRenderer.js';
export class PBCRenderer extends UIControlsRenderer {
- constructor(controlData, movingRangeData, avgMovingRangeFunc, workTicketsURL, chartName = 'pbc') {
+ constructor(controlData, movingRangeData, workTicketsURL, chartName = 'pbc') {
super(controlData);
this.controlData = controlData;
this.movingRangeData = movingRangeData;
- this.avgMovingRangeFunc = avgMovingRangeFunc;
this.workTicketsURL = workTicketsURL;
this.chartName = chartName;
this.chartType = 'PBC';
-
// Child renderers
this.controlRenderer = null;
this.movingRangeRenderer = null;
@@ -19,14 +17,8 @@ export class PBCRenderer extends UIControlsRenderer {
* Initialize child renderers with their respective data
*/
initializeRenderers(ControlRenderer, MovingRangeRenderer) {
- this.controlRenderer = new ControlRenderer(this.controlData, this.avgMovingRangeFunc, `${this.chartName}-control`, this.workTicketsURL);
-
- this.movingRangeRenderer = new MovingRangeRenderer(
- this.movingRangeData, // Moving range calculated data
- this.avgMovingRangeFunc,
- this.workTicketsURL,
- `${this.chartName}-moving-range`
- );
+ this.controlRenderer = new ControlRenderer(this.controlData, `${this.chartName}-control`, this.workTicketsURL);
+ this.movingRangeRenderer = new MovingRangeRenderer(this.movingRangeData, this.workTicketsURL, `${this.chartName}-moving-range`);
}
/**
diff --git a/src/graphs/scatterplot/ScatterplotRenderer.js b/src/graphs/scatterplot/ScatterplotRenderer.js
index 39d44ce..928bb41 100644
--- a/src/graphs/scatterplot/ScatterplotRenderer.js
+++ b/src/graphs/scatterplot/ScatterplotRenderer.js
@@ -531,23 +531,34 @@ export class ScatterplotRenderer extends UIControlsRenderer {
* @private
*/
handleMouseClickEvent(event, d) {
+ // Find all tickets with the same values
+ const overlappingTickets = this.data.filter(
+ (ticket) => ticket.deliveredDate.getTime() === d.deliveredDate.getTime() && ticket.leadTime === d.leadTime
+ );
+
let data = {
...d,
tooltipLeft: event.pageX,
tooltipTop: event.pageY,
+ overlappingTickets: overlappingTickets,
+ ticketCount: overlappingTickets.length,
};
- if (this.#areMetricsEnabled) {
- const observation = this.observations?.data?.find((o) => o.work_item === d.ticketId && o.chart_type === this.chartType);
+
+ if (this.#areMetricsEnabled && this.observations) {
+ const observations = overlappingTickets
+ .map((ticket) => this.observations?.data?.find((o) => o.work_item === ticket.ticketId && o.chart_type === this.chartType))
+ .filter((obs) => obs);
+
data = {
...data,
date: d.deliveredDate,
metrics: {
leadTime: d.leadTime,
},
- observationBody: observation?.body,
- observationId: observation?.id,
+ observations: observations,
};
}
+
this.eventBus?.emitEvents(`${this.chartName}-click`, data);
this.showTooltip(data);
}
@@ -585,7 +596,7 @@ export class ScatterplotRenderer extends UIControlsRenderer {
return width;
}
- drawHorizontalLine(yScale, yValue, color, id, text = '') {
+ drawHorizontalLine(yScale, yValue, color, id, text = '', dash = '7') {
let lineEl = this.svg.select('#line-' + id);
let textEl = this.svg.select('#text-' + id);
@@ -597,7 +608,7 @@ export class ScatterplotRenderer extends UIControlsRenderer {
.attr('id', 'line-' + id)
.attr('class', 'average-line')
.attr('stroke-width', 3)
- .attr('stroke-dasharray', '7');
+ .attr('stroke-dasharray', dash);
textEl = this.svg
.append('text')
.attr('text-anchor', 'start')