jirametrics 3.4 → 3.5
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.
- checksums.yaml +4 -4
- data/lib/jirametrics/chart_base.rb +101 -12
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +78 -10
- data/lib/jirametrics/project_config.rb +24 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 056e9e0149e1e7f1d03968b91d5a62937d82a5f529ceba4917b6b23c74f33103
|
|
4
|
+
data.tar.gz: e883680589519bb9c527873d607755dbca2d4082053a5747a9bd7de2506305f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd257eb627a31909dcb389ec7d66739d9d5d3aebf6d8301af76c41c4f989ebce899ca8fa543d2557dfd3a4ffd4af0110eb3f8bea7872bdf6ae4931fb15b6fc13
|
|
7
|
+
data.tar.gz: be1fee3d08642e25db8a998803921ef385cef5dd5cef99b074808553c7619c6af7069833df5fce644a95976ef7e7ffc4ca1cb9ccc0047b066ce8e85900a7ca85
|
|
@@ -198,12 +198,24 @@ class ChartBase
|
|
|
198
198
|
|
|
199
199
|
LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
201
|
+
# chartjs-plugin-annotation renders each array element as its own line and does no wrapping of
|
|
202
|
+
# its own, so anything long has to arrive already split.
|
|
203
|
+
ANNOTATION_DESCRIPTION_WIDTH = 60
|
|
204
|
+
|
|
205
|
+
# The descriptions a chart needs to draw its own hover reveal, with the dates already normalized
|
|
206
|
+
# and the text already wrapped, so the JavaScript only has to decide when to show them.
|
|
207
|
+
def date_annotation_hover_data
|
|
208
|
+
annotations_in_range
|
|
209
|
+
.select { |(a, _)| a['description'] }
|
|
210
|
+
.map { |(a, normalized)| { 'date' => normalized, 'lines' => wrap_annotation_description(a['description']) } }
|
|
211
|
+
.to_json
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# hover: false leaves out the reveal callbacks and the widened hit target, for a chart that
|
|
215
|
+
# draws its own hover reveal. The CFD does, because its flow metrics triangle switches Chart.js
|
|
216
|
+
# events off and the plugin's hover cannot fire while that is on.
|
|
217
|
+
def date_annotation hover: true
|
|
218
|
+
in_range = annotations_in_range
|
|
207
219
|
|
|
208
220
|
positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
|
|
209
221
|
|
|
@@ -214,17 +226,94 @@ class ChartBase
|
|
|
214
226
|
xMin: #{normalized.to_json},
|
|
215
227
|
xMax: #{normalized.to_json},
|
|
216
228
|
borderColor: 'rgba(0,0,0,0.7)',
|
|
217
|
-
borderWidth: 1,
|
|
218
|
-
label: {
|
|
219
|
-
display: true,
|
|
220
|
-
content: #{a['label'].to_json},
|
|
221
|
-
position: #{positions[index].to_json}
|
|
222
|
-
}
|
|
229
|
+
borderWidth: 1#{annotation_hit_tolerance(a) if hover}#{annotation_label a, positions[index]}#{annotation_hover_callbacks(a, index) if hover}
|
|
223
230
|
},
|
|
224
231
|
TEXT
|
|
225
232
|
end.join
|
|
226
233
|
end
|
|
227
234
|
|
|
235
|
+
def annotations_in_range
|
|
236
|
+
(settings['date_annotations'] || [])
|
|
237
|
+
.map { |a| [a, normalize_annotation_datetime(a['date'])] }
|
|
238
|
+
.select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
|
|
239
|
+
.sort_by { |(_, dt)| dt }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The label block for one annotation, or nothing at all when there is neither a label nor a
|
|
243
|
+
# description to put in it.
|
|
244
|
+
def annotation_label annotation, position
|
|
245
|
+
# drawTime: the CFD hatches its correction windows over the chart and the scatterplot draws
|
|
246
|
+
# dots and percentile lines. Drawing the label last keeps it readable on both.
|
|
247
|
+
|
|
248
|
+
label = annotation['label']
|
|
249
|
+
description = annotation['description']
|
|
250
|
+
return '' if label.nil? && description.nil?
|
|
251
|
+
|
|
252
|
+
",\n" \
|
|
253
|
+
" label: {\n" \
|
|
254
|
+
" display: #{!label.nil?},\n" \
|
|
255
|
+
" content: #{annotation_content(annotation).to_json},\n" \
|
|
256
|
+
" position: #{position.to_json},\n" \
|
|
257
|
+
" drawTime: 'afterDraw'\n" \
|
|
258
|
+
' }'
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A label on its own stays a plain string, exactly as it always was. Paired with a description it
|
|
262
|
+
# becomes a single element array so that hovering can append the description lines below it. With
|
|
263
|
+
# no label at all the description is the whole content, hidden until the line is hovered.
|
|
264
|
+
def annotation_content annotation
|
|
265
|
+
label = annotation['label']
|
|
266
|
+
return wrap_annotation_description annotation['description'] if label.nil?
|
|
267
|
+
|
|
268
|
+
annotation['description'] ? [label] : label
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The line is a single pixel wide, so without widening the hit target there is nothing the
|
|
272
|
+
# mouse can realistically land on. Only annotations that react to hovering need it.
|
|
273
|
+
def annotation_hit_tolerance annotation
|
|
274
|
+
annotation['description'] ? ",\n hitTolerance: 6" : ''
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def wrap_annotation_description description
|
|
278
|
+
description.split.each_with_object([]) do |word, lines|
|
|
279
|
+
if lines.empty? || "#{lines.last} #{word}".length > ANNOTATION_DESCRIPTION_WIDTH
|
|
280
|
+
lines << word
|
|
281
|
+
else
|
|
282
|
+
lines[-1] = "#{lines.last} #{word}"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Two things here are easy to get wrong, and both were, so they are worth spelling out.
|
|
288
|
+
#
|
|
289
|
+
# update('none') rather than draw(): the label's box is measured during the update, so a plain
|
|
290
|
+
# redraw paints the new lines at the old box size and they spill across the chart as white text
|
|
291
|
+
# on nothing. 'none' skips the animation.
|
|
292
|
+
#
|
|
293
|
+
# Writing to the chart config rather than to ctx.element: update() re-resolves every annotation
|
|
294
|
+
# from the config, so anything written to the element is discarded before it can be drawn. The
|
|
295
|
+
# percentile lines elsewhere can write to the element because they call draw(), not update().
|
|
296
|
+
def annotation_hover_callbacks annotation, index
|
|
297
|
+
description = annotation['description']
|
|
298
|
+
return '' unless description
|
|
299
|
+
return annotation_reveal_callbacks if annotation['label'].nil?
|
|
300
|
+
|
|
301
|
+
expanded = [annotation['label'], *wrap_annotation_description(description)]
|
|
302
|
+
target = "ctx.chart.options.plugins.annotation.annotations.dateAnnotation#{index}.label.content"
|
|
303
|
+
",\n" \
|
|
304
|
+
" enter(ctx) { #{target} = #{expanded.to_json}; ctx.chart.update('none'); },\n" \
|
|
305
|
+
" leave(ctx) { #{target} = #{annotation_content(annotation).to_json}; ctx.chart.update('none'); }"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# With no label there is nothing on screen at rest, so hovering only has to switch the hidden
|
|
309
|
+
# label on. The content never changes, so its box is already measured and draw() is enough.
|
|
310
|
+
# That avoids the update() dance the case above needs, and the element can be written directly.
|
|
311
|
+
def annotation_reveal_callbacks
|
|
312
|
+
",\n" \
|
|
313
|
+
' enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },' \
|
|
314
|
+
"\n leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }"
|
|
315
|
+
end
|
|
316
|
+
|
|
228
317
|
def stagger_label_positions datetimes
|
|
229
318
|
return [] if datetimes.empty?
|
|
230
319
|
|
|
@@ -14,6 +14,8 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
14
14
|
}
|
|
15
15
|
(function() {
|
|
16
16
|
const hatchWindows = <%= hatch_windows.to_json %>;
|
|
17
|
+
const annotationDescriptions = <%= date_annotation_hover_data %>;
|
|
18
|
+
const ANNOTATION_HOVER_TOLERANCE = 6;
|
|
17
19
|
|
|
18
20
|
// Custom plugin: draws diagonal hatching over correction windows in the affected band.
|
|
19
21
|
// Uses createDiagonalPattern() defined in index.js.
|
|
@@ -23,7 +25,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
23
25
|
// Redraw each correction-window border line so that the gaps between
|
|
24
26
|
// dashes show the band fill colour rather than being transparent.
|
|
25
27
|
// Strategy: draw a solid line in the fill colour first, then the dashed
|
|
26
|
-
// border line on top.
|
|
28
|
+
// border line on top. The hatching runs straight after this, so the
|
|
27
29
|
// hatch pattern still appears over both lines.
|
|
28
30
|
const ctx = chart.ctx;
|
|
29
31
|
const ca = chart.chartArea;
|
|
@@ -55,8 +57,15 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
55
57
|
drawSegment(win.fill_color, []); // solid fill colour fills the gaps
|
|
56
58
|
drawSegment(win.color, [6, 4]); // dashed border colour on top
|
|
57
59
|
});
|
|
60
|
+
|
|
61
|
+
// The hatching used to run in afterDraw, which put it above the annotation labels no
|
|
62
|
+
// matter what drawTime those asked for. Running it here, immediately after the borders,
|
|
63
|
+
// lets a label drawn at afterDraw land on top of it. The trend lines still draw over the
|
|
64
|
+
// hatching, since cfdFlowMetricsPlugin.afterDraw runs later than this either way.
|
|
65
|
+
cfdHatchPlugin.drawHatching(chart);
|
|
58
66
|
},
|
|
59
|
-
|
|
67
|
+
// Not a Chart.js hook. Called explicitly from afterDatasetsDraw above.
|
|
68
|
+
drawHatching: function(chart) {
|
|
60
69
|
const ctx = chart.ctx;
|
|
61
70
|
const ca = chart.chartArea;
|
|
62
71
|
hatchWindows.forEach(function(win) {
|
|
@@ -203,6 +212,44 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
203
212
|
ctx.restore();
|
|
204
213
|
}
|
|
205
214
|
|
|
215
|
+
// The description box for whichever annotation the cursor is near. This lives on the overlay
|
|
216
|
+
// rather than going through chartjs-plugin-annotation's own enter/leave, because those need
|
|
217
|
+
// Chart.js events and triangle mode switches those off.
|
|
218
|
+
function drawAnnotationDescription(ctx, chart, fm) {
|
|
219
|
+
const lines = fm._hoveredAnnotation.lines;
|
|
220
|
+
if (!lines.length) return;
|
|
221
|
+
|
|
222
|
+
const ca = chart.chartArea;
|
|
223
|
+
ctx.save();
|
|
224
|
+
ctx.font = '11px sans-serif';
|
|
225
|
+
const pad = 6, lineHeight = 14;
|
|
226
|
+
const w = Math.max.apply(null, lines.map(function (l) { return ctx.measureText(l).width; })) + pad * 2;
|
|
227
|
+
const h = lines.length * lineHeight + pad * 2;
|
|
228
|
+
// Sit below and right of the cursor, flipping when that would cross the edge of the plot.
|
|
229
|
+
let x = fm.mouseX + 12;
|
|
230
|
+
let y = fm.mouseY + 12;
|
|
231
|
+
if (x + w > ca.right) x = fm.mouseX - 12 - w;
|
|
232
|
+
if (y + h > ca.bottom) y = fm.mouseY - 12 - h;
|
|
233
|
+
ctx.fillStyle = 'rgba(0,0,0,0.85)';
|
|
234
|
+
ctx.fillRect(x, y, w, h);
|
|
235
|
+
ctx.fillStyle = '#ffffff';
|
|
236
|
+
ctx.textAlign = 'left';
|
|
237
|
+
ctx.textBaseline = 'top';
|
|
238
|
+
lines.forEach(function (line, i) { ctx.fillText(line, x + pad, y + pad + i * lineHeight); });
|
|
239
|
+
ctx.restore();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function annotationNear(chart, x) {
|
|
243
|
+
if (x === null) return null;
|
|
244
|
+
|
|
245
|
+
let best = null, bestDistance = ANNOTATION_HOVER_TOLERANCE;
|
|
246
|
+
annotationDescriptions.forEach(function (a) {
|
|
247
|
+
const distance = Math.abs(chart.scales.x.getPixelForValue(new Date(a.date).getTime()) - x);
|
|
248
|
+
if (distance <= bestDistance) { bestDistance = distance; best = a; }
|
|
249
|
+
});
|
|
250
|
+
return best;
|
|
251
|
+
}
|
|
252
|
+
|
|
206
253
|
function drawTriangle(ctx, chart, fm) {
|
|
207
254
|
const ca = chart.chartArea;
|
|
208
255
|
const ds = chart.data.datasets;
|
|
@@ -312,6 +359,8 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
312
359
|
const dates = chart.data.datasets[0].data.map(d => new Date(d.x).getTime());
|
|
313
360
|
chart._flowMetrics = {
|
|
314
361
|
mouseX: null,
|
|
362
|
+
mouseY: null,
|
|
363
|
+
_hoveredAnnotation: null,
|
|
315
364
|
arrivals,
|
|
316
365
|
departures,
|
|
317
366
|
dates,
|
|
@@ -323,9 +372,9 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
323
372
|
|
|
324
373
|
// The triangle is drawn on a separate overlay canvas using native DOM
|
|
325
374
|
// events, completely bypassing Chart.js's render/event cycle (which
|
|
326
|
-
// behaves unreliably in Safari).
|
|
327
|
-
//
|
|
328
|
-
//
|
|
375
|
+
// behaves unreliably in Safari). Redraws are coalesced into a single
|
|
376
|
+
// requestAnimationFrame (see scheduleOverlayRedraw) so a fast mouse move
|
|
377
|
+
// cannot queue up more redraws than the browser can paint.
|
|
329
378
|
const dpr = window.devicePixelRatio || 1;
|
|
330
379
|
const parent = canvas.parentNode;
|
|
331
380
|
if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
|
|
@@ -359,6 +408,12 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
359
408
|
// disabled entirely so its internal hover/tooltip logic cannot interfere
|
|
360
409
|
// with the overlay canvas rAF (which hangs Safari at certain positions).
|
|
361
410
|
// Our native canvas mousemove handler is unaffected by this setting.
|
|
411
|
+
//
|
|
412
|
+
// Switching events off wholesale looks heavy handed, and it is. We never found
|
|
413
|
+
// the root cause of the Safari hang. Other fixes we tried worked in some
|
|
414
|
+
// browsers but not all; this is the only one that was reliable across Safari,
|
|
415
|
+
// Chrome, Edge and Firefox. Treat it as load bearing: anything that re-enables
|
|
416
|
+
// events here has to be retested in all four.
|
|
362
417
|
const defaultEvents = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
|
|
363
418
|
|
|
364
419
|
function setTriangleMode(enabled) {
|
|
@@ -386,10 +441,9 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
386
441
|
if (fm._rafId !== null) return;
|
|
387
442
|
fm._rafId = requestAnimationFrame(function () {
|
|
388
443
|
fm._rafId = null;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
444
|
+
octx.clearRect(0, 0, cssW, cssH);
|
|
445
|
+
if (fm._triangleEnabled && fm.mouseX !== null) drawTriangle(octx, chart, fm);
|
|
446
|
+
if (fm._hoveredAnnotation) drawAnnotationDescription(octx, chart, fm);
|
|
393
447
|
});
|
|
394
448
|
}
|
|
395
449
|
|
|
@@ -399,12 +453,17 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
399
453
|
const y = e.clientY - rect.top;
|
|
400
454
|
const ca = chart.chartArea;
|
|
401
455
|
if (!ca) return;
|
|
402
|
-
|
|
456
|
+
const inside = x >= ca.left && x <= ca.right && y >= ca.top && y <= ca.bottom;
|
|
457
|
+
fm.mouseX = inside ? x : null;
|
|
458
|
+
fm.mouseY = inside ? y : null;
|
|
459
|
+
fm._hoveredAnnotation = annotationNear(chart, fm.mouseX);
|
|
403
460
|
scheduleOverlayRedraw();
|
|
404
461
|
}
|
|
405
462
|
|
|
406
463
|
function onMouseLeave() {
|
|
407
464
|
fm.mouseX = null;
|
|
465
|
+
fm.mouseY = null;
|
|
466
|
+
fm._hoveredAnnotation = null;
|
|
408
467
|
scheduleOverlayRedraw();
|
|
409
468
|
}
|
|
410
469
|
|
|
@@ -458,6 +517,15 @@ if (!Chart.Tooltip.positioners.legendItem) {
|
|
|
458
517
|
point: { radius: 0 }
|
|
459
518
|
},
|
|
460
519
|
plugins: {
|
|
520
|
+
// The plugin draws the lines and their static labels. It does NOT get the hover callbacks,
|
|
521
|
+
// because triangle mode switches Chart.js events off and they would fire only when the
|
|
522
|
+
// triangle was hidden. The overlay canvas reveals descriptions instead, from the native
|
|
523
|
+
// mousemove handler, so it behaves the same in both modes.
|
|
524
|
+
annotation: {
|
|
525
|
+
annotations: {
|
|
526
|
+
<%= date_annotation hover: false %>
|
|
527
|
+
}
|
|
528
|
+
},
|
|
461
529
|
tooltip: {
|
|
462
530
|
enabled: false,
|
|
463
531
|
position: 'legendItem',
|
|
@@ -44,6 +44,7 @@ class ProjectConfig
|
|
|
44
44
|
load_fix_versions
|
|
45
45
|
load_users
|
|
46
46
|
resolve_blocked_stalled_status_settings
|
|
47
|
+
report_unusable_date_annotations
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def run load_only: false
|
|
@@ -79,6 +80,29 @@ class ProjectConfig
|
|
|
79
80
|
# passed through but that no longer exists in Jira). It fires often -- any workflow rework leaves
|
|
80
81
|
# these behind -- so we say the explanation once and hand over a single ready-to-paste block of
|
|
81
82
|
# mappings rather than one repetitive paragraph per status.
|
|
83
|
+
# Print one consolidated warning covering every date annotation that has neither a label nor a
|
|
84
|
+
# description. Such an annotation draws a bare line with nothing to say what it marks, which is
|
|
85
|
+
# almost always a typo in the config rather than a choice. One warning per project, because the
|
|
86
|
+
# same settings hash is usually shared across several projects and every chart re-reads it.
|
|
87
|
+
def report_unusable_date_annotations
|
|
88
|
+
return if @unusable_date_annotations_reported
|
|
89
|
+
|
|
90
|
+
@unusable_date_annotations_reported = true
|
|
91
|
+
|
|
92
|
+
unusable = (settings['date_annotations'] || []).reject do |annotation|
|
|
93
|
+
annotation['label'] || annotation['description']
|
|
94
|
+
end
|
|
95
|
+
return if unusable.empty?
|
|
96
|
+
|
|
97
|
+
dates = unusable.map { |annotation| annotation['date'].inspect }.join(', ')
|
|
98
|
+
file_system.warning(
|
|
99
|
+
"These date annotations have neither a label nor a description: #{dates}. Each will draw " \
|
|
100
|
+
'a bare line with nothing to say what it marks, which is almost certainly a mistake. Give ' \
|
|
101
|
+
'each one a label, a description, or both. If a bare line really is what you wanted, they ' \
|
|
102
|
+
'will work as they are.'
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
82
106
|
def report_fabricated_statuses
|
|
83
107
|
statuses = possible_statuses.fabricated_statuses
|
|
84
108
|
return if statuses.empty? || @fabricated_statuses_reported
|