jirametrics 2.14 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +96 -96
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +3 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +139 -3
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +42 -31
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +117 -100
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +244 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +302 -98
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +108 -9
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. metadata +60 -6
@@ -0,0 +1,503 @@
1
+ <%= seam_start %>
2
+ <div class="chart">
3
+ <label style="font-size:0.85em;display:block;text-align:right;margin-bottom:2px">
4
+ <input type="checkbox" id="<%= chart_id %>_triangle_toggle" checked>
5
+ Show flow metrics triangle
6
+ </label>
7
+ <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
8
+ </div>
9
+ <script>
10
+ if (!Chart.Tooltip.positioners.legendItem) {
11
+ Chart.Tooltip.positioners.legendItem = function(items) {
12
+ return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
13
+ };
14
+ }
15
+ (function() {
16
+ const hatchWindows = <%= hatch_windows.to_json %>;
17
+
18
+ // Custom plugin: draws diagonal hatching over correction windows in the affected band.
19
+ // Uses createDiagonalPattern() defined in index.js.
20
+ const cfdHatchPlugin = {
21
+ id: 'cfdHatch',
22
+ afterDatasetsDraw: function(chart) {
23
+ // Redraw each correction-window border line so that the gaps between
24
+ // dashes show the band fill colour rather than being transparent.
25
+ // Strategy: draw a solid line in the fill colour first, then the dashed
26
+ // border line on top. afterDraw (hatching) fires after this, so the
27
+ // hatch pattern still appears over both lines.
28
+ const ctx = chart.ctx;
29
+ const ca = chart.chartArea;
30
+ hatchWindows.forEach(function(win) {
31
+ const meta = chart.getDatasetMeta(win.dataset_index);
32
+ if (!meta || !meta.data.length) return;
33
+
34
+ const startX = chart.scales.x.getPixelForValue(new Date(win.start_date).getTime());
35
+ const endX = chart.scales.x.getPixelForValue(new Date(win.end_date).getTime());
36
+ const lw = chart.data.datasets[win.dataset_index].borderWidth || 2;
37
+ const points = meta.data.filter(function(p) { return p.x >= startX - 1 && p.x <= endX + 1; });
38
+ if (points.length < 2) return;
39
+
40
+ function drawSegment(color, dash) {
41
+ ctx.save();
42
+ ctx.beginPath();
43
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
44
+ ctx.clip();
45
+ ctx.strokeStyle = color;
46
+ ctx.setLineDash(dash);
47
+ ctx.lineWidth = lw;
48
+ ctx.beginPath();
49
+ ctx.moveTo(points[0].x, points[0].y);
50
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
51
+ ctx.stroke();
52
+ ctx.restore();
53
+ }
54
+
55
+ drawSegment(win.fill_color, []); // solid fill colour fills the gaps
56
+ drawSegment(win.color, [6, 4]); // dashed border colour on top
57
+ });
58
+ },
59
+ afterDraw: function(chart) {
60
+ const ctx = chart.ctx;
61
+ const ca = chart.chartArea;
62
+ hatchWindows.forEach(function(win) {
63
+ const meta = chart.getDatasetMeta(win.dataset_index);
64
+ if (!meta || !meta.data.length) return;
65
+
66
+ const startX = chart.scales.x.getPixelForValue(new Date(win.start_date).getTime());
67
+ const endX = chart.scales.x.getPixelForValue(new Date(win.end_date).getTime());
68
+
69
+ // Draw hatched slices over the correction window.
70
+ // For stacked line charts, PointElement has no .base — derive the band bottom from the
71
+ // dataset directly below in the visual stack (dataset_index - 1, since datasets are
72
+ // stored reversed), or chart.chartArea.bottom for the lowest dataset.
73
+ // Use a trapezoid clip path per slice so hatching stays within the actual band boundary
74
+ // even when band height changes between data points.
75
+ const belowMeta = win.dataset_index > 0 ? chart.getDatasetMeta(win.dataset_index - 1) : null;
76
+ meta.data.forEach(function(point, i) {
77
+ if (point.x < startX || point.x > endX) return;
78
+ const prev = i > 0 ? meta.data[i - 1] : null;
79
+ const sliceLeft = Math.max(prev ? prev.x : startX, startX);
80
+ const sliceRight = Math.min(point.x, endX);
81
+ if (sliceLeft >= sliceRight) return;
82
+
83
+ const topLeft = prev ? prev.y : point.y;
84
+ const topRight = point.y;
85
+ const bottomLeft = belowMeta && prev && belowMeta.data[i - 1]
86
+ ? belowMeta.data[i - 1].y : chart.chartArea.bottom;
87
+ const bottomRight = belowMeta && belowMeta.data[i]
88
+ ? belowMeta.data[i].y : chart.chartArea.bottom;
89
+
90
+ if (Math.min(topLeft, topRight) >= Math.max(bottomLeft, bottomRight)) return;
91
+
92
+ ctx.save();
93
+ ctx.beginPath();
94
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
95
+ ctx.clip();
96
+ ctx.beginPath();
97
+ ctx.moveTo(sliceLeft, topLeft);
98
+ ctx.lineTo(sliceRight, topRight);
99
+ ctx.lineTo(sliceRight, bottomRight);
100
+ ctx.lineTo(sliceLeft, bottomLeft);
101
+ ctx.closePath();
102
+ ctx.clip();
103
+ ctx.fillStyle = createDiagonalPattern(win.color);
104
+ ctx.fillRect(sliceLeft, Math.min(topLeft, topRight),
105
+ sliceRight - sliceLeft, Math.max(bottomLeft, bottomRight) - Math.min(topLeft, topRight));
106
+ ctx.restore();
107
+ });
108
+ });
109
+ }
110
+ };
111
+
112
+ const cfdFlowMetricsPlugin = (function () {
113
+ const triangleColor = <%= @triangle_color.to_json %>;
114
+ const arrivalColor = <%= @arrival_rate_line_color.nil? ? 'null' : @arrival_rate_line_color.to_json %>;
115
+ const departureColor = <%= @departure_rate_line_color.nil? ? 'null' : @departure_rate_line_color.to_json %>;
116
+ function buildArrays(chart) {
117
+ const ds = chart.data.datasets;
118
+ const n = ds[0].data.length;
119
+ const arrivals = [], departures = [];
120
+ for (let j = 0; j < n; j++) {
121
+ arrivals[j] = ds.reduce((s, d) => s + (d.data[j]?.y || 0), 0);
122
+ // ds[0] is the Done (rightmost) column after dataset reversal.
123
+ // Its marginal equals its cumulative count (no column to its right),
124
+ // so this directly gives total departures. Assumes Done is not ignored via column_rules.
125
+ departures[j] = ds[0].data[j]?.y || 0;
126
+ }
127
+ return { arrivals, departures };
128
+ }
129
+
130
+ function linearRegression(yValues) {
131
+ const n = yValues.length;
132
+ const sumX = n * (n - 1) / 2;
133
+ const sumX2 = n * (n - 1) * (2 * n - 1) / 6;
134
+ const sumY = yValues.reduce((s, y) => s + y, 0);
135
+ const sumXY = yValues.reduce((s, y, i) => s + i * y, 0);
136
+ const denom = n * sumX2 - sumX * sumX;
137
+ if (denom === 0) return { slope: 0, intercept: sumY / n };
138
+ const slope = (n * sumXY - sumX * sumY) / denom;
139
+ return { slope, intercept: (sumY - slope * sumX) / n };
140
+ }
141
+
142
+ function trendPixelY(chart, reg, dayIndex) {
143
+ return chart.scales.y.getPixelForValue(reg.slope * dayIndex + reg.intercept);
144
+ }
145
+
146
+ function drawTrendLines(chart, fm) {
147
+ const ctx = chart.ctx;
148
+ const ca = chart.chartArea;
149
+ const ds = chart.data.datasets;
150
+ const n = ds[0].data.length;
151
+ const x0 = chart.scales.x.getPixelForValue(new Date(ds[0].data[0].x).getTime());
152
+ const x1 = chart.scales.x.getPixelForValue(new Date(ds[0].data[n - 1].x).getTime());
153
+
154
+ function drawLine(reg, color) {
155
+ const y0 = trendPixelY(chart, reg, 0);
156
+ const y1 = trendPixelY(chart, reg, n - 1);
157
+ ctx.save();
158
+ ctx.beginPath();
159
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
160
+ ctx.clip();
161
+ ctx.setLineDash([6, 4]);
162
+ ctx.lineWidth = 1.5;
163
+ ctx.strokeStyle = color;
164
+ ctx.beginPath();
165
+ ctx.moveTo(x0, y0);
166
+ ctx.lineTo(x1, y1);
167
+ ctx.stroke();
168
+ ctx.restore();
169
+ }
170
+
171
+ if (arrivalColor !== null) drawLine(fm.arrivalReg, arrivalColor);
172
+ if (departureColor !== null) drawLine(fm.departureReg, departureColor);
173
+
174
+ // Edge labels (inside chart area, right-aligned to avoid canvas clipping)
175
+ if (arrivalColor !== null || departureColor !== null) {
176
+ ctx.save();
177
+ ctx.font = '10px sans-serif';
178
+ ctx.textAlign = 'right';
179
+ ctx.textBaseline = 'middle';
180
+ const labelX = ca.right - 4;
181
+ if (arrivalColor !== null) {
182
+ ctx.fillStyle = arrivalColor;
183
+ ctx.fillText('Arrivals', labelX, trendPixelY(chart, fm.arrivalReg, n - 1));
184
+ }
185
+ if (departureColor !== null) {
186
+ ctx.fillStyle = departureColor;
187
+ ctx.fillText('Departures', labelX, trendPixelY(chart, fm.departureReg, n - 1));
188
+ }
189
+ ctx.restore();
190
+ }
191
+ }
192
+
193
+ function bgLabel(ctx, text, cx, cy) {
194
+ ctx.save();
195
+ ctx.font = '11px sans-serif';
196
+ const w = ctx.measureText(text).width;
197
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
198
+ ctx.fillRect(cx - w / 2 - 3, cy - 9, w + 6, 14);
199
+ ctx.fillStyle = '#ffffff';
200
+ ctx.textAlign = 'center';
201
+ ctx.textBaseline = 'middle';
202
+ ctx.fillText(text, cx, cy - 2);
203
+ ctx.restore();
204
+ }
205
+
206
+ function drawTriangle(ctx, chart, fm) {
207
+ const ca = chart.chartArea;
208
+ const ds = chart.data.datasets;
209
+ const n = ds[0].data.length;
210
+ const { arrivals, departures, dates } = fm;
211
+
212
+ // Locate cursor data index j
213
+ const cursorMs = chart.scales.x.getValueForPixel(fm.mouseX);
214
+ const j = dates.reduce((best, t, i) =>
215
+ Math.abs(t - cursorMs) < Math.abs(dates[best] - cursorMs) ? i : best, 0);
216
+
217
+ const wip = arrivals[j] - departures[j];
218
+ if (wip === 0) return;
219
+
220
+ // Find j_c: first index > j where departures[k] >= arrivals[j] (fixed threshold)
221
+ const threshold = arrivals[j];
222
+ let j_c = -1;
223
+ for (let k = j + 1; k < n; k++) {
224
+ if (departures[k] >= threshold) { j_c = k; break; }
225
+ }
226
+
227
+ const xA = chart.scales.x.getPixelForValue(dates[j]);
228
+ // Use Chart.js's own rendered pixel positions so the triangle edges
229
+ // align exactly with the drawn band edges rather than relying on
230
+ // getPixelForValue(arrivals/departures[j]), which can differ slightly
231
+ // from the internal stacked totals Chart.js uses when drawing.
232
+ const yA = chart.getDatasetMeta(ds.length - 1).data[j].y; // top of full stack
233
+ const yB = chart.getDatasetMeta(0).data[j].y; // top of done band
234
+ const xC = j_c >= 0 ? chart.scales.x.getPixelForValue(dates[j_c]) : null;
235
+
236
+ ctx.save();
237
+ ctx.beginPath();
238
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
239
+ ctx.clip();
240
+
241
+ // Triangle fill (only when C is within range)
242
+ if (xC !== null) {
243
+ ctx.beginPath();
244
+ ctx.moveTo(xA, yA);
245
+ ctx.lineTo(xA, yB);
246
+ ctx.lineTo(xC, yA);
247
+ ctx.closePath();
248
+ ctx.fillStyle = 'rgba(255,255,255,0.06)';
249
+ ctx.fill();
250
+ }
251
+
252
+ // AB: vertical (WIP)
253
+ ctx.setLineDash([]);
254
+ ctx.lineWidth = 2;
255
+ ctx.strokeStyle = triangleColor;
256
+ ctx.beginPath();
257
+ ctx.moveTo(xA, yA);
258
+ ctx.lineTo(xA, yB);
259
+ ctx.stroke();
260
+
261
+ if (xC !== null) {
262
+ // AC: horizontal (cycle time)
263
+ ctx.beginPath();
264
+ ctx.moveTo(xA, yA);
265
+ ctx.lineTo(xC, yA);
266
+ ctx.stroke();
267
+ // BC: dashed hypotenuse (throughput)
268
+ ctx.setLineDash([4, 2]);
269
+ ctx.lineWidth = 1.5;
270
+ ctx.beginPath();
271
+ ctx.moveTo(xA, yB);
272
+ ctx.lineTo(xC, yA);
273
+ ctx.stroke();
274
+ }
275
+
276
+ ctx.restore();
277
+
278
+ // Labels
279
+ const abMidY = (yA + yB) / 2;
280
+ // WIP: right-aligned, left of AB
281
+ ctx.save();
282
+ ctx.font = '11px sans-serif';
283
+ const wipText = 'WIP: ' + wip;
284
+ const wipW = ctx.measureText(wipText).width;
285
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
286
+ ctx.fillRect(xA - wipW - 8, abMidY - 9, wipW + 6, 14);
287
+ ctx.fillStyle = '#ffffff';
288
+ ctx.textAlign = 'right';
289
+ ctx.textBaseline = 'middle';
290
+ ctx.fillText(wipText, xA - 5, abMidY - 2);
291
+ ctx.restore();
292
+
293
+ if (xC !== null) {
294
+ const cycleTime = j_c - j;
295
+ const throughput = (wip / cycleTime).toFixed(2);
296
+ bgLabel(ctx, '~CT: ' + cycleTime + ' days', (xA + xC) / 2, yA - 10);
297
+ bgLabel(ctx, '~TP: ' + throughput + '/day', (xA + xC) / 2, (yA + yB) / 2 + 12);
298
+ }
299
+ }
300
+
301
+ return {
302
+ id: 'cfdFlowMetrics',
303
+
304
+ afterInit(chart) {
305
+ // Guard against being applied to non-CFD charts (Chart.js may fire
306
+ // inline plugin hooks on other charts when plugins share an id with
307
+ // a cached entry).
308
+ const ds = chart.data.datasets;
309
+ if (!ds || !ds[0] || !ds[0].data || !ds[0].data.length) return;
310
+
311
+ const { arrivals, departures } = buildArrays(chart);
312
+ const dates = chart.data.datasets[0].data.map(d => new Date(d.x).getTime());
313
+ chart._flowMetrics = {
314
+ mouseX: null,
315
+ arrivals,
316
+ departures,
317
+ dates,
318
+ arrivalReg: linearRegression(arrivals),
319
+ departureReg: linearRegression(departures)
320
+ };
321
+ const fm = chart._flowMetrics;
322
+ const canvas = chart.canvas;
323
+
324
+ // The triangle is drawn on a separate overlay canvas using native DOM
325
+ // events, completely bypassing Chart.js's render/event cycle (which
326
+ // behaves unreliably in Safari). The triangle is hidden immediately on
327
+ // mouse move and redrawn after a short pause (debounce), preventing the
328
+ // browser from being overwhelmed by rapid redraws.
329
+ const dpr = window.devicePixelRatio || 1;
330
+ const parent = canvas.parentNode;
331
+ if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
332
+ // Read canvas.offsetTop/Left after parent is positioned so they are
333
+ // relative to parent — this accounts for the label/checkbox above the
334
+ // canvas that would otherwise shift the overlay upward.
335
+ const overlay = document.createElement('canvas');
336
+ overlay.width = canvas.width;
337
+ overlay.height = canvas.height;
338
+ overlay.style.cssText = 'position:absolute;top:' + canvas.offsetTop + 'px;' +
339
+ 'left:' + canvas.offsetLeft + 'px;pointer-events:none;' +
340
+ 'width:' + (canvas.style.width || (canvas.width / dpr) + 'px') + ';' +
341
+ 'height:' + (canvas.style.height || (canvas.height / dpr) + 'px') + ';';
342
+ parent.insertBefore(overlay, canvas.nextSibling);
343
+
344
+ const octx = overlay.getContext('2d');
345
+ octx.scale(dpr, dpr);
346
+ const cssW = canvas.width / dpr;
347
+ const cssH = canvas.height / dpr;
348
+ fm._overlay = overlay;
349
+
350
+ // Store overlay state on fm so afterDraw can reach it.
351
+ fm._overlayCtx = octx;
352
+ fm._cssW = cssW;
353
+ fm._cssH = cssH;
354
+ fm._triangleEnabled = true;
355
+ fm._rafId = null;
356
+
357
+ // Checkbox toggles between triangle mode and tooltip mode. They are
358
+ // mutually exclusive: in triangle mode, Chart.js event processing is
359
+ // disabled entirely so its internal hover/tooltip logic cannot interfere
360
+ // with the overlay canvas rAF (which hangs Safari at certain positions).
361
+ // Our native canvas mousemove handler is unaffected by this setting.
362
+ const defaultEvents = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
363
+
364
+ function setTriangleMode(enabled) {
365
+ fm._triangleEnabled = enabled;
366
+ if (enabled) {
367
+ chart.options.events = [];
368
+ chart.options.plugins.tooltip.enabled = false;
369
+ } else {
370
+ chart.options.events = defaultEvents;
371
+ chart.options.plugins.tooltip.enabled = true;
372
+ octx.clearRect(0, 0, cssW, cssH);
373
+ }
374
+ chart.update('none');
375
+ }
376
+
377
+ const checkbox = document.getElementById('<%= chart_id %>_triangle_toggle');
378
+ if (checkbox) {
379
+ checkbox.addEventListener('change', function () { setTriangleMode(this.checked); });
380
+ }
381
+ // Apply initial triangle mode without triggering a redundant update
382
+ // (tooltip is already disabled in the chart config; just silence events).
383
+ chart.options.events = [];
384
+
385
+ function scheduleOverlayRedraw() {
386
+ if (fm._rafId !== null) return;
387
+ fm._rafId = requestAnimationFrame(function () {
388
+ fm._rafId = null;
389
+ if (fm._triangleEnabled) {
390
+ octx.clearRect(0, 0, cssW, cssH);
391
+ if (fm.mouseX !== null) drawTriangle(octx, chart, fm);
392
+ }
393
+ });
394
+ }
395
+
396
+ function onMouseMove(e) {
397
+ const rect = canvas.getBoundingClientRect();
398
+ const x = e.clientX - rect.left;
399
+ const y = e.clientY - rect.top;
400
+ const ca = chart.chartArea;
401
+ if (!ca) return;
402
+ fm.mouseX = (x >= ca.left && x <= ca.right && y >= ca.top && y <= ca.bottom) ? x : null;
403
+ scheduleOverlayRedraw();
404
+ }
405
+
406
+ function onMouseLeave() {
407
+ fm.mouseX = null;
408
+ scheduleOverlayRedraw();
409
+ }
410
+
411
+ canvas.addEventListener('mousemove', onMouseMove);
412
+ canvas.addEventListener('mouseleave', onMouseLeave);
413
+ fm._onMouseMove = onMouseMove;
414
+ fm._onMouseLeave = onMouseLeave;
415
+ },
416
+
417
+ destroy(chart) {
418
+ const fm = chart._flowMetrics;
419
+ if (!fm) return;
420
+ chart.canvas.removeEventListener('mousemove', fm._onMouseMove);
421
+ chart.canvas.removeEventListener('mouseleave', fm._onMouseLeave);
422
+ if (fm._overlay && fm._overlay.parentNode) fm._overlay.parentNode.removeChild(fm._overlay);
423
+ },
424
+
425
+ afterDraw(chart) {
426
+ const fm = chart._flowMetrics;
427
+ if (!fm) return;
428
+ drawTrendLines(chart, fm);
429
+ // Triangle is on the overlay canvas — no drawing needed here.
430
+ }
431
+ };
432
+ })();
433
+
434
+ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
435
+ type: 'line',
436
+ plugins: [cfdHatchPlugin, cfdFlowMetricsPlugin],
437
+ data: {
438
+ datasets: <%= JSON.generate(data_sets) %>
439
+ },
440
+ options: {
441
+ responsive: <%= canvas_responsive? %>,
442
+ scales: {
443
+ x: {
444
+ type: 'time',
445
+ time: { format: 'YYYY-MM-DD' },
446
+ min: "<%= date_range.begin.to_s %>",
447
+ max: "<%= (date_range.end + 1).to_s %>",
448
+ grid: { color: <%= CssVariable['--grid-line-color'].to_json %> }
449
+ },
450
+ y: {
451
+ stacked: true,
452
+ min: 0,
453
+ title: { display: true, text: 'Number of items' },
454
+ grid: { color: <%= CssVariable['--grid-line-color'].to_json %> }
455
+ }
456
+ },
457
+ elements: {
458
+ point: { radius: 0 }
459
+ },
460
+ plugins: {
461
+ tooltip: {
462
+ enabled: false,
463
+ position: 'legendItem',
464
+ callbacks: {
465
+ title: function(contexts) {
466
+ if (contexts[0]?.chart._legendHoverIndex != null) return '';
467
+ },
468
+ label: function(context) {
469
+ if (context.chart._legendHoverIndex != null) {
470
+ return context.dataset.label_hint || '';
471
+ }
472
+ }
473
+ }
474
+ },
475
+ legend: {
476
+ reverse: true,
477
+ onHover: function(event, legendItem, legend) {
478
+ const chart = legend.chart;
479
+ const dataset = chart.data.datasets[legendItem.datasetIndex];
480
+ if (!dataset?.label_hint) return;
481
+ chart._legendHoverIndex = legendItem.datasetIndex;
482
+ chart._legendHoverPosition = { x: event.x, y: event.y };
483
+ const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
484
+ if (firstNonZero === -1) return;
485
+ chart.tooltip.setActiveElements(
486
+ [{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
487
+ { x: event.x, y: event.y }
488
+ );
489
+ chart.update();
490
+ },
491
+ onLeave: function(event, legendItem, legend) {
492
+ legend.chart._legendHoverIndex = null;
493
+ legend.chart._legendHoverPosition = null;
494
+ legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
495
+ legend.chart.update();
496
+ }
497
+ }
498
+ }
499
+ }
500
+ });
501
+ })();
502
+ </script>
503
+ <%= seam_end %>
@@ -1,7 +1,13 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
4
5
  <script>
6
+ if (!Chart.Tooltip.positioners.legendItem) {
7
+ Chart.Tooltip.positioners.legendItem = function(items) {
8
+ return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
9
+ };
10
+ }
5
11
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
6
12
  {
7
13
  type: 'bar',
@@ -20,7 +26,10 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
20
26
  time: {
21
27
  unit: 'day'
22
28
  },
29
+ min: "<%= date_range.begin.to_s %>",
30
+ max: "<%= (date_range.end + 1).to_s %>",
23
31
  stacked: true,
32
+ <%= render_axis_title :x %>
24
33
  grid: {
25
34
  color: <%= CssVariable['--grid-line-color'].to_json %>
26
35
  },
@@ -31,10 +40,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
31
40
  display: true,
32
41
  labelString: 'WIP'
33
42
  },
34
- title: {
35
- display: true,
36
- text: 'Count of items'
37
- },
43
+ <%= render_axis_title :y %>
38
44
  grid: {
39
45
  color: <%= CssVariable['--grid-line-color'].to_json %>
40
46
  },
@@ -42,18 +48,46 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
42
48
  },
43
49
  plugins: {
44
50
  tooltip: {
51
+ position: 'legendItem',
45
52
  callbacks: {
53
+ title: function(contexts) {
54
+ if (contexts[0]?.chart._legendHoverIndex != null) return '';
55
+ },
46
56
  label: function(context) {
47
- return context.dataset.data[context.dataIndex].title
57
+ if (context.chart._legendHoverIndex != null) {
58
+ return context.dataset.label_hint || '';
59
+ }
60
+ return context.dataset.data[context.dataIndex].title;
48
61
  }
49
62
  }
50
63
  },
51
64
  annotation: {
52
65
  annotations: {
53
66
  <%= working_days_annotation %>
67
+ <%= date_annotation %>
54
68
  }
55
69
  },
56
70
  legend: {
71
+ onHover: function(event, legendItem, legend) {
72
+ const chart = legend.chart;
73
+ const dataset = chart.data.datasets[legendItem.datasetIndex];
74
+ if (!dataset?.label_hint) return;
75
+ chart._legendHoverIndex = legendItem.datasetIndex;
76
+ chart._legendHoverPosition = { x: event.x, y: event.y };
77
+ const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
78
+ if (firstNonZero === -1) return;
79
+ chart.tooltip.setActiveElements(
80
+ [{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
81
+ { x: event.x, y: event.y }
82
+ );
83
+ chart.update();
84
+ },
85
+ onLeave: function(event, legendItem, legend) {
86
+ legend.chart._legendHoverIndex = null;
87
+ legend.chart._legendHoverPosition = null;
88
+ legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
89
+ legend.chart.update();
90
+ },
57
91
  labels: {
58
92
  filter: function(item, chart) {
59
93
  // Logic to remove a particular legend item goes here
@@ -65,3 +99,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
65
99
  }
66
100
  });
67
101
  </script>
102
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -8,10 +9,6 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
8
9
  datasets: <%= JSON.generate(data_sets) %>
9
10
  },
10
11
  options: {
11
- title: {
12
- display: true,
13
- text: "Sprint Burndown"
14
- },
15
12
  responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
16
13
  scales: {
17
14
  x: {
@@ -20,10 +17,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
20
17
  display: true,
21
18
  labelString: 'Date'
22
19
  },
23
- title: {
24
- display: true,
25
- text: "Cycletime (days)"
26
- },
20
+ <%= render_axis_title :x %>
27
21
  min: 0,
28
22
  grid: {
29
23
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -38,10 +32,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
38
32
  display: true,
39
33
  labelString: 'Items remaining'
40
34
  },
41
- title: {
42
- display: true,
43
- text: "<%= @y_axis_label %>"
44
- },
35
+ <%= render_axis_title :y %>
45
36
  min: 0.0,
46
37
  grid: {
47
38
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -60,3 +51,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
60
51
  }
61
52
  });
62
53
  </script>
54
+ <%= seam_end %>
@@ -1,3 +1,4 @@
1
+ <%= seam_start %>
1
2
  <div class="chart">
2
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
4
  </div>
@@ -19,25 +20,15 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
19
20
  scales: {
20
21
  x: {
21
22
  type: "time",
22
- scaleLabel: {
23
- display: true,
24
- labelString: 'Date Completed'
25
- },
23
+ <%= render_axis_title :x %>
26
24
  min: "<%= date_range.begin.to_s %>",
27
- max: "<%= date_range.end.to_s %>",
25
+ max: "<%= (date_range.end + 1).to_s %>",
28
26
  grid: {
29
27
  color: <%= CssVariable['--grid-line-color'].to_json %>
30
28
  },
31
29
  },
32
30
  y: {
33
- scaleLabel: {
34
- display: true,
35
- labelString: 'Days'
36
- },
37
- title: {
38
- display: true,
39
- text: 'Age in days'
40
- },
31
+ <%= render_axis_title :y %>
41
32
  min: 0,
42
33
  grid: {
43
34
  color: <%= CssVariable['--grid-line-color'].to_json %>
@@ -61,4 +52,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
61
52
  }
62
53
  }
63
54
  });
64
- </script>
55
+ </script>
56
+ <%= seam_end %>