Skip to content

Commit 251ff52

Browse files
authored
Merge pull request #81 from pfizer-opensource/basis-lines-process-signals
Add basis lines and process signals
2 parents 1af4fe2 + f8eb62c commit 251ff52

File tree

4 files changed

+325
-99
lines changed

4 files changed

+325
-99
lines changed

src/graphs/control-chart/ControlRenderer.js

Lines changed: 176 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,63 +6,187 @@ export class ControlRenderer extends ScatterplotRenderer {
66
timeScale = 'logarithmic';
77
connectDots = false;
88

9-
constructor(data, avgMovingRangeFunc, chartName, workTicketsURL) {
9+
constructor(data, chartName, workTicketsURL) {
1010
super(data);
1111
this.chartName = chartName;
1212
this.chartType = 'CONTROL';
1313
this.workTicketsURL = workTicketsURL;
14-
this.avgMovingRangeFunc = avgMovingRangeFunc;
1514
this.dotClass = 'control-dot';
1615
this.yAxisLabel = 'Days';
16+
this.limitData = {};
17+
this.processSignalsData = {};
18+
this.visibleLimits = {};
19+
this.activeProcessSignal = null;
1720
}
1821

19-
renderGraph(graphElementSelector) {
20-
this.drawSvg(graphElementSelector);
21-
this.drawAxes();
22-
this.drawArea();
23-
this.computeGraphLimits();
24-
this.drawGraphLimits(this.y);
25-
this.setupMouseLeaveHandler();
22+
setLimitData(limitData) {
23+
this.limitData = {
24+
naturalProcessLimits: limitData?.naturalProcessLimits || null,
25+
twoSigma: limitData?.twoSigma || null,
26+
oneSigma: limitData?.oneSigma || null,
27+
averageCycleTime: limitData?.averageCycleTime || null,
28+
};
29+
this.topLimit = limitData?.naturalProcessLimits?.upper;
30+
this.drawLimits();
31+
}
32+
33+
setProcessSignalsData(signalsData) {
34+
this.processSignalsData = {
35+
largeChange: signalsData?.largeChange || null,
36+
moderateChange: signalsData?.moderateChange || null,
37+
moderateSustainedShift: signalsData?.moderateSustainedShift || null,
38+
smallSustainedShift: signalsData?.smallSustainedShift || null,
39+
};
2640
}
2741

28-
drawGraphLimits(yScale) {
29-
this.drawHorizontalLine(yScale, this.topLimit, 'purple', 'top-pb', `UPL=${this.topLimit}`);
30-
this.drawHorizontalLine(yScale, this.avgLeadTime, 'orange', 'mid-pb', `Avg=${this.avgLeadTime}`);
42+
setVisibleLimits(limitConfig) {
43+
this.visibleLimits = { ...limitConfig };
44+
this.updateLimitVisibility();
45+
}
3146

32-
if (this.bottomLimit > 0) {
33-
this.drawHorizontalLine(yScale, this.bottomLimit, 'purple', 'bottom-pb', `LPL=${this.bottomLimit}`);
34-
} else {
35-
console.warn('The bottom limit is:', this.bottomLimit);
47+
setActiveProcessSignal(signalType) {
48+
this.hideSignals();
49+
this.activeProcessSignal = signalType;
50+
this.showActiveSignal();
51+
}
52+
53+
drawLimits() {
54+
// Remove existing limits first
55+
this.svg.selectAll('[id^="line-"], [id^="text-"]').remove();
56+
57+
// Draw new limits
58+
Object.entries(this.limitData).forEach(([limitType, limitValue]) => {
59+
if (limitValue) {
60+
this.drawLimit(limitType, limitValue);
61+
}
62+
});
63+
64+
this.updateLimitVisibility();
65+
}
66+
67+
drawLimit(limitType, limitValue) {
68+
const limitConfig = {
69+
naturalProcessLimits: { dash: '3 2', text: 'NPL', color: 'orange' },
70+
twoSigma: { dash: '12 8', text: '2s', color: 'orange' },
71+
oneSigma: { dash: '20 10', text: '1s', color: 'orange' },
72+
averageCycleTime: { dash: '7', text: 'Avg', color: 'purple' },
73+
};
74+
75+
const config = limitConfig[limitType];
76+
if (!config) return;
77+
78+
if (typeof limitValue === 'number') {
79+
this.drawHorizontalLine(this.currentYScale, limitValue, config.color, limitType, `${config.text}=${limitValue}`, config.dash);
80+
} else if (limitValue && typeof limitValue === 'object') {
81+
if (limitValue.upper !== undefined) {
82+
this.drawHorizontalLine(
83+
this.currentYScale,
84+
limitValue.upper,
85+
config.color,
86+
`${limitType}-upper`,
87+
`${config.text}U=${limitValue.upper}`,
88+
config.dash
89+
);
90+
}
91+
if (limitValue.lower !== undefined && limitValue.lower > 0) {
92+
this.drawHorizontalLine(
93+
this.currentYScale,
94+
limitValue.lower,
95+
config.color,
96+
`${limitType}-lower`,
97+
`${config.text}L=${limitValue.lower}`,
98+
config.dash
99+
);
100+
}
101+
}
102+
}
103+
104+
updateLimitVisibility() {
105+
Object.entries(this.visibleLimits).forEach(([limitType, isVisible]) => {
106+
const display = isVisible ? 'block' : 'none';
107+
// Handle both single limits and upper/lower pairs
108+
this.svg.select(`#line-${limitType}`).style('display', display);
109+
this.svg.select(`#text-${limitType}`).style('display', display);
110+
this.svg.select(`#line-${limitType}-upper`).style('display', display);
111+
this.svg.select(`#text-${limitType}-upper`).style('display', display);
112+
this.svg.select(`#line-${limitType}-lower`).style('display', display);
113+
this.svg.select(`#text-${limitType}-lower`).style('display', display);
114+
});
115+
}
116+
117+
showActiveSignal() {
118+
if (!this.activeProcessSignal || !this.processSignalsData[this.activeProcessSignal]) {
119+
return;
36120
}
121+
122+
const signals = this.processSignalsData[this.activeProcessSignal];
123+
this.drawSignals(signals);
37124
}
38125

39-
computeGraphLimits() {
40-
this.avgLeadTime = this.getAvgLeadTime();
41-
const avgMovingRange = this.avgMovingRangeFunc(this.baselineStartDate, this.baselineEndDate);
42-
this.topLimit = Math.ceil(this.avgLeadTime + avgMovingRange * 2.66);
126+
hideSignals() {
127+
this.svg.selectAll('.signal-point').classed('signal-point', false).attr('fill', this.color);
128+
}
43129

44-
this.bottomLimit = Math.ceil(this.avgLeadTime - avgMovingRange * 2.66);
45-
const maxY = this.y.domain()[1] > this.topLimit ? this.y.domain()[1] : this.topLimit + 5;
46-
let minY = this.y.domain()[0];
47-
if (this.bottomLimit > 5) {
48-
minY = this.y.domain()[0] < this.bottomLimit ? this.y.domain()[0] : this.bottomLimit - 5;
130+
drawSignals(signals) {
131+
if (signals.upper && signals.lower) {
132+
[...signals.upper, ...signals.lower].forEach((id) => {
133+
this.svg.select(`#control-${id}`).classed('signal-point', true).transition().duration(200).attr('fill', 'orange');
134+
});
49135
}
50-
this.y.domain([minY, maxY]);
136+
}
137+
138+
renderGraph(graphElementSelector) {
139+
this.drawSvg(graphElementSelector);
140+
this.drawAxes();
141+
this.drawArea();
142+
this.drawLimits();
143+
this.showActiveSignal();
51144
}
52145

53146
populateTooltip(event) {
54-
this.tooltip
55-
.style('pointer-events', 'auto')
56-
.style('opacity', 0.9)
57-
.append('div')
58-
.append('a')
59-
.style('text-decoration', 'underline')
60-
.attr('href', `${this.workTicketsURL}/${event.ticketId}`)
61-
.text(event.ticketId)
62-
.attr('target', '_blank')
63-
.on('click', () => {
64-
this.hideTooltip();
147+
this.tooltip.style('pointer-events', 'auto').style('opacity', 0.9);
148+
149+
if (event.overlappingTickets && event.overlappingTickets.length > 1) {
150+
// Add header for multiple tickets
151+
this.tooltip
152+
.append('div')
153+
.style('font-weight', 'bold')
154+
.style('margin-bottom', '8px')
155+
.text(`${event.overlappingTickets.length} tickets at this point:`);
156+
157+
event.overlappingTickets.forEach((ticket) => {
158+
const ticketDiv = this.tooltip.append('div').style('margin-bottom', '4px');
159+
160+
ticketDiv
161+
.append('a')
162+
.style('text-decoration', 'underline')
163+
.attr('href', `${this.workTicketsURL}/${ticket.ticketId}`)
164+
.text(ticket.ticketId)
165+
.attr('target', '_blank')
166+
.on('click', () => {
167+
this.hideTooltip();
168+
});
65169
});
170+
171+
// Optionally add shared information (date, lead time)
172+
if (event.date && event.metrics) {
173+
this.tooltip.append('div').style('margin-top', '8px').style('font-size', '12px').style('color', '#666').html(`
174+
<div><strong>Date:</strong> ${event.date}</div>
175+
<div><strong>Lead Time:</strong> ${event.metrics.leadTime} days</div>
176+
`);
177+
}
178+
} else {
179+
this.tooltip
180+
.append('div')
181+
.append('a')
182+
.style('text-decoration', 'underline')
183+
.attr('href', `${this.workTicketsURL}/${event.ticketId}`)
184+
.text(event.ticketId)
185+
.attr('target', '_blank')
186+
.on('click', () => {
187+
this.hideTooltip();
188+
});
189+
}
66190
}
67191

68192
drawScatterplot(chartArea, data, x, y) {
@@ -74,7 +198,12 @@ export class ControlRenderer extends ScatterplotRenderer {
74198
.attr('class', this.dotClass)
75199
.attr('id', (d) => `control-${d.ticketId}`)
76200
.attr('data-date', (d) => d.deliveredDate)
77-
.attr('r', 5)
201+
.attr('r', (d) => {
202+
const overlapping = data.filter(
203+
(item) => item.deliveredDate.getTime() === d.deliveredDate.getTime() && item.leadTime === d.leadTime
204+
);
205+
return overlapping.length > 1 ? 7 : 5;
206+
})
78207
.attr('cx', (d) => x(d.deliveredDate))
79208
.attr('cy', (d) => this.applyYScale(y, d.leadTime))
80209
.style('cursor', 'pointer')
@@ -83,11 +212,6 @@ export class ControlRenderer extends ScatterplotRenderer {
83212
this.connectDots && this.generateLines(chartArea, data, x, y);
84213
}
85214

86-
getAvgLeadTime() {
87-
const filteredData = this.data.filter((d) => d.deliveredDate >= this.baselineStartDate && d.deliveredDate <= this.baselineEndDate);
88-
return Math.ceil(filteredData.reduce((acc, curr) => acc + curr.leadTime, 0) / filteredData.length);
89-
}
90-
91215
generateLines(chartArea, data, x, y) {
92216
// Define the line generator
93217
const line = d3
@@ -108,7 +232,6 @@ export class ControlRenderer extends ScatterplotRenderer {
108232
}
109233

110234
updateGraph(domain) {
111-
this.computeGraphLimits();
112235
this.updateChartArea(domain);
113236
if (this.connectDots) {
114237
const line = d3
@@ -117,7 +240,15 @@ export class ControlRenderer extends ScatterplotRenderer {
117240
.y((d) => this.applyYScale(this.currentYScale, d.leadTime));
118241
this.chartArea.selectAll('.dot-line').attr('d', line);
119242
}
120-
this.drawGraphLimits(this.currentYScale);
243+
this.drawLimits();
244+
this.showActiveSignal();
121245
this.displayObservationMarkers(this.observations);
122246
}
247+
248+
cleanup() {
249+
this.limitData = {};
250+
this.processSignalsData = {};
251+
this.visibleLimits = {};
252+
this.activeProcessSignal = null;
253+
}
123254
}

0 commit comments

Comments
 (0)