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')