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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f7292394330330ca10f89a28f02fab3e95cff385736c423ca3602c23551e4ca
4
- data.tar.gz: 4690b5ce8cc47d3e980ee026e68f6f245e564ec51aa914914dd7a0db32f3c0b2
3
+ metadata.gz: 056e9e0149e1e7f1d03968b91d5a62937d82a5f529ceba4917b6b23c74f33103
4
+ data.tar.gz: e883680589519bb9c527873d607755dbca2d4082053a5747a9bd7de2506305f4
5
5
  SHA512:
6
- metadata.gz: 6f32fbe86ecfb9ecc5ce7ace7d0a2971b6777c7a61e4513d41d2444a976699487ba4da415b6e84484cad72a45ee6af4a822f14da430018e6b40b39bb48046a70
7
- data.tar.gz: 2b4af2fe6c95834710901e3aacccc1d260ceb7c28020a853f1a643be1faf3c48a0663488839c522249e2a14a2d3c6e4665425a3c7bf5175354a0311493679ee6
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
- def date_annotation
202
- annotations = settings['date_annotations'] || []
203
- in_range = annotations
204
- .map { |a| [a, normalize_annotation_datetime(a['date'])] }
205
- .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
206
- .sort_by { |(_, dt)| dt }
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. afterDraw (hatching) fires after this, so the
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
- afterDraw: function(chart) {
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). 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.
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
- if (fm._triangleEnabled) {
390
- octx.clearRect(0, 0, cssW, cssH);
391
- if (fm.mouseX !== null) drawTriangle(octx, chart, fm);
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
- fm.mouseX = (x >= ca.left && x <= ca.right && y >= ca.top && y <= ca.bottom) ? x : null;
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '3.4'
4
+ version: '3.5'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler