jirametrics 3.1.1 → 3.3

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +111 -10
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +10 -6
  4. data/lib/jirametrics/aging_work_table.rb +22 -7
  5. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +15 -2
  6. data/lib/jirametrics/board_movement_calculator.rb +18 -7
  7. data/lib/jirametrics/chart_base.rb +39 -5
  8. data/lib/jirametrics/color_palette.rb +61 -0
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +9 -6
  10. data/lib/jirametrics/cycletime_scatterplot.rb +67 -8
  11. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  12. data/lib/jirametrics/dependency_chart.rb +1 -1
  13. data/lib/jirametrics/exporter.rb +77 -11
  14. data/lib/jirametrics/groupable_issue_chart.rb +27 -2
  15. data/lib/jirametrics/grouping_rules.rb +13 -1
  16. data/lib/jirametrics/html/aging_work_bar_chart.erb +16 -5
  17. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +1 -1
  18. data/lib/jirametrics/html/index.css +40 -5
  19. data/lib/jirametrics/html/index.erb +6 -2
  20. data/lib/jirametrics/html/index.js +16 -0
  21. data/lib/jirametrics/html/time_based_histogram.erb +26 -14
  22. data/lib/jirametrics/html/time_based_scatterplot.erb +102 -11
  23. data/lib/jirametrics/html_generator.rb +20 -1
  24. data/lib/jirametrics/html_report_config.rb +3 -3
  25. data/lib/jirametrics/percentile_validation.rb +26 -0
  26. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +1 -0
  27. data/lib/jirametrics/settings.json +1 -0
  28. data/lib/jirametrics/time_based_histogram.rb +33 -15
  29. data/lib/jirametrics/time_based_scatterplot.rb +153 -16
  30. data/lib/jirametrics/trend_line_calculator.rb +5 -2
  31. data/lib/jirametrics/wip_by_column_chart.rb +1 -1
  32. metadata +3 -1
@@ -257,35 +257,89 @@ class Exporter
257
257
 
258
258
  def info key, name_filter:
259
259
  selected = []
260
+ searched = []
260
261
  file_system.log_only = true
261
262
  each_project_config(name_filter: name_filter) do |project|
262
263
  project.evaluate_next_level
263
264
 
264
265
  project.run load_only: true
265
- selected.concat matching_issues_in(project, key)
266
+ matches = matching_issues_in(project, key)
267
+ searched << describe_search(project: project, matches: matches.size)
268
+ selected.concat matches
266
269
  rescue => e # rubocop:disable Style/RescueStandardError
267
270
  # This happens when we're attempting to load an aggregated project because it hasn't been
268
271
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
269
272
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
273
+
274
+ searched << " #{project.name.inspect}: skipped, this is an aggregated project"
270
275
  end
271
276
  file_system.log_only = false
272
277
 
278
+ report_info_results key: key, selected: selected, where_we_looked: describe_where_we_looked(key, searched)
279
+ end
280
+
281
+ # Verbose in the log, sparse in the terminal. Where we looked is almost always what you need when
282
+ # an issue you know was downloaded cannot be found, and it is noise the rest of the time.
283
+ def describe_where_we_looked key, searched
284
+ return "Searched these projects for #{key.inspect}:\n#{searched.join "\n"}" unless searched.empty?
285
+
286
+ 'No project configurations were searched at all. Either none are defined in the config file ' \
287
+ 'or none of them matched the name filter.'
288
+ end
289
+
290
+ def report_info_results key:, selected:, where_we_looked:
273
291
  if selected.empty?
274
- file_system.log "No issues found to match #{key.inspect}"
275
- else
276
- selected.each do |project, issue|
277
- file_system.log "\nProject #{project.name}", also_write_to_stderr: true
278
- file_system.log issue.dump, also_write_to_stderr: true
279
- end
292
+ file_system.log(
293
+ "No issues found to match #{key.inspect}", more: where_we_looked, also_write_to_stderr: true
294
+ )
295
+ return
296
+ end
297
+
298
+ file_system.log where_we_looked
299
+ selected.each do |project, issue, ignored|
300
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
301
+ file_system.log(IGNORED_ISSUE_NOTE, also_write_to_stderr: true) if ignored
302
+ file_system.log issue.dump, also_write_to_stderr: true
280
303
  end
281
304
  end
282
305
 
306
+ # Only the unexpected case is announced. Not being filtered is the common one, and saying so
307
+ # every time would be noise on top of the dump that was actually asked for. Note the claim is
308
+ # about the filter, not about charts: an issue that survives filtering may still be absent from
309
+ # a given chart for its own reasons, so there is no matching note for the other case.
310
+ IGNORED_ISSUE_NOTE = 'IGNORED by a filter such as ignore_types or ignore_issues. No chart uses it.'
311
+
312
+ # One line per project saying where its issues were read from and what was there, so that a
313
+ # missing issue can be traced to the wrong directory rather than guessed at.
314
+ def describe_search project:, matches:
315
+ prefix = project.get_file_prefix raise_if_not_set: false
316
+ path = prefix.nil? ? '(no file_prefix set)' : File.join(project.target_path.to_s, "#{prefix}_issues")
317
+ counts =
318
+ begin
319
+ collection = project.issues
320
+ excluded = collection.hidden.size
321
+ detail = "#{collection.size} issues loaded"
322
+ detail << ", #{excluded} excluded by filters" if excluded.positive?
323
+ "#{detail}, #{matches} matched"
324
+ rescue StandardError
325
+ 'could not be read'
326
+ end
327
+ " #{project.name.inspect}: #{path} (#{counts})"
328
+ end
329
+
330
+ # Asking about a specific issue should always show that issue, whether or not the report used it.
331
+ # An issue removed by something like ignore_types is exactly when somebody runs info to find out
332
+ # what happened, and reporting it as missing sends them hunting for a download problem that is
333
+ # not there. The hidden list is searched first. Returns [project, issue, ignored] triples.
283
334
  def matching_issues_in project, key
284
335
  matches = []
285
- project.issues.each do |issue|
286
- matches << [project, issue] if key == issue.key
287
- issue.subtasks.each do |subtask|
288
- matches << [project, subtask] if key == subtask.key
336
+ collection = project.issues
337
+ [[collection.hidden, true], [collection, false]].each do |issues, ignored|
338
+ issues.each do |issue|
339
+ matches << [project, issue, ignored] if key == issue.key
340
+ issue.subtasks.each do |subtask|
341
+ matches << [project, subtask, ignored] if key == subtask.key
342
+ end
289
343
  end
290
344
  end
291
345
  matches
@@ -317,9 +371,21 @@ class Exporter
317
371
 
318
372
  def target_path path = nil
319
373
  unless path.nil?
374
+ previous = @target_path
320
375
  @target_path = path
321
376
  @target_path += File::SEPARATOR unless @target_path.end_with? File::SEPARATOR
322
377
  FileUtils.mkdir_p @target_path
378
+ # A config is allowed to switch target directories partway through, and when it does, an
379
+ # issue can be downloaded into one and looked for in another. Recording every change makes
380
+ # that visible rather than something to guess at. See GitHub issue 77.
381
+ # The constructor seeds this with a bare '.', and the setter always appends a separator, so
382
+ # an unseparated '.' is the untouched default rather than something the config chose. Setting
383
+ # the same path twice is not a change and saying so would be actively misleading.
384
+ if previous == '.'
385
+ file_system.diagnostic "target_path set to #{@target_path.inspect}"
386
+ elsif previous != @target_path
387
+ file_system.diagnostic "target_path changed from #{previous.inspect} to #{@target_path.inspect}"
388
+ end
323
389
  end
324
390
  @target_path
325
391
  end
@@ -31,14 +31,39 @@ module GroupableIssueChart
31
31
 
32
32
  @issue_hints[issue] = rules.issue_hint
33
33
  @issue_periods[issue] = rules.last_day_of_period
34
- (result[rules] ||= []) << issue
34
+ accumulate_issue_for_group result, rules, issue
35
35
  end
36
36
 
37
37
  completed_issues.reject! { |issue| ignored_issues.include? issue }
38
38
 
39
39
  result.each_key do |rules|
40
- rules.color = random_color if rules.color.nil?
40
+ rules.color = next_palette_color if rules.color.nil?
41
41
  end
42
42
  result
43
43
  end
44
+
45
+ # Ruby's Hash keeps whichever key object it saw first, so a later issue whose rules object
46
+ # has different percentiles needs to be reconciled against that retained key rather than
47
+ # just appended under its own (discarded) key.
48
+ def accumulate_issue_for_group result, rules, issue
49
+ existing_key = result.keys.find { |key| key.eql? rules }
50
+ reconcile_percentiles existing_key, rules if existing_key
51
+ (result[existing_key || rules] ||= []) << issue
52
+ end
53
+
54
+ # The retained hash key is whichever rules object arrived first, so a later issue setting a
55
+ # different list would silently lose. That can only be a config error, so say so.
56
+ def reconcile_percentiles existing_key, rules
57
+ incoming = rules.percentiles
58
+ return if incoming.nil?
59
+
60
+ existing = existing_key.percentiles
61
+ if existing.nil?
62
+ existing_key.percentiles = incoming
63
+ elsif existing != incoming
64
+ raise ArgumentError,
65
+ "group #{existing_key.label.inspect} was given conflicting percentiles: " \
66
+ "#{existing.inspect} and #{incoming.inspect}"
67
+ end
68
+ end
44
69
  end
@@ -1,8 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'jirametrics/percentile_validation'
4
+
3
5
  class GroupingRules < Rules
6
+ include PercentileValidation
7
+
4
8
  attr_accessor :label, :issue_hint, :label_hint
5
- attr_reader :color, :last_day_of_period
9
+ attr_reader :color, :last_day_of_period, :percentiles
10
+
11
+ # nil means "inherit whatever the chart is configured with" and an empty list means "draw no
12
+ # lines for this group", so neither is validated. Everything else gets the same treatment as
13
+ # the chart level setter, because these numbers become JavaScript identifiers downstream and a
14
+ # bad one takes the whole chart out with a syntax error.
15
+ def percentiles= list
16
+ @percentiles = list.nil? ? nil : validate_percentiles(list)
17
+ end
6
18
 
7
19
  def last_day_of_period= value
8
20
  @last_day_of_period = value.is_a?(String) ? Date.parse(value) : value
@@ -40,15 +40,26 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
40
40
  annotations: {
41
41
  <%= working_days_annotation %>
42
42
 
43
- <% if percentage_line_x %>
44
- line: {
43
+ <% @percentage_lines.each do |percentage_line| %>
44
+ <%= percentage_line[:id].to_json %>: {
45
45
  type: 'line',
46
46
  scaleID: 'x',
47
- value: '<%= percentage_line_x %>',
47
+ value: '<%= percentage_line[:x] %>',
48
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
49
49
  borderWidth: 1,
50
- drawTime: 'afterDraw'
51
- }
50
+ hitTolerance: 6,
51
+ drawTime: 'afterDraw',
52
+ label: {
53
+ display: false,
54
+ content: <%= "#{ordinal percentage_line[:percentile]} percentile of completed work".to_json %>,
55
+ position: 'start',
56
+ backgroundColor: 'rgba(0,0,0,0.85)',
57
+ color: '#fff',
58
+ font: { size: 11 }
59
+ },
60
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
61
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
62
+ },
52
63
  <% end %>
53
64
  }
54
65
  },
@@ -57,7 +57,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
57
57
  }
58
58
  nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
59
59
 
60
- // Hide/show the 85% line for that dataset
60
+ // Hide/show the percentile lines for that dataset
61
61
  legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
62
62
 
63
63
  // Hide/show the trendline for this dataset, if they were enabled. The trendline is always
@@ -5,6 +5,22 @@
5
5
  --warning-banner: yellow;
6
6
 
7
7
  --cycletime-scatterplot-overall-trendline-color: gray;
8
+ --cycletime-scatterplot-cap-rule-color: #555555;
9
+ --cycletime-scatterplot-cap-gutter-color: rgba(0, 0, 0, 0.05);
10
+
11
+ /* Palette for things that need telling apart where no specific colour is wanted. Okabe-Ito,
12
+ chosen because it stays distinguishable under the common forms of colour vision deficiency.
13
+ If you need a SPECIFIC colour, configure it rather than relying on whichever slot comes up.
14
+ Add a slot by defining the next number; the count is read from this file, not hardcoded.
15
+ These deliberately have no dark mode overrides yet: lightening them for dark backgrounds
16
+ collapses blue into sky blue, and getting it right needs measuring. See jirametrics-60z. */
17
+ --palette-color-1: #0072B2; /* Okabe-Ito blue */
18
+ --palette-color-2: #E69F00; /* Okabe-Ito orange */
19
+ --palette-color-3: #009E73; /* Okabe-Ito bluish green */
20
+ --palette-color-4: #56B4E9; /* Okabe-Ito sky blue */
21
+ --palette-color-5: #D55E00; /* Okabe-Ito vermilion */
22
+ --palette-color-6: #CC79A7; /* Okabe-Ito reddish purple */
23
+ --palette-color-7: #F0E442; /* Okabe-Ito yellow */
8
24
 
9
25
  --non-working-days-color: #F0F0F0;
10
26
  --expedited-color: #D55E00; /* Okabe-Ito vermilion */
@@ -238,6 +254,8 @@ html[data-theme="dark"] {
238
254
  --body-background: #343434;
239
255
  --default-text-color: #aaa;
240
256
  --grid-line-color: #424242;
257
+ --cycletime-scatterplot-cap-rule-color: #b8b8b8;
258
+ --cycletime-scatterplot-cap-gutter-color: rgba(255, 255, 255, 0.08);
241
259
  --expedited-color: #E69F00; /* Okabe-Ito orange for dark bg */
242
260
  --blocked-color: #E69F00; /* Okabe-Ito orange for dark bg */
243
261
  --stalled-color: #F0E442; /* Okabe-Ito yellow - distinct from blocked */
@@ -250,7 +268,14 @@ html[data-theme="dark"] {
250
268
  --wip-by-column-chart-limit-line-color: #E69F00; /* Okabe-Ito orange */
251
269
  --wip-by-column-chart-recommendation-color: #2DCB9A; /* lighter bluish green for dark bg */
252
270
  --wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
253
- --wip-chart-duration-more-than-four-weeks-color: #DE9AC4; /* lighter reddish purple */
271
+ /* Was inheriting the light #92D9C0, which sat too close to the completed colour above for
272
+ anyone with protanopia or deuteranopia. Those two mean "completed" and "completed but not
273
+ started", so they are exactly the pair a reader must separate. See jirametrics-3fg. */
274
+ --wip-chart-completed-but-not-started-color: #00A062;
275
+ /* Was #DE9AC4, a lightened reddish purple that collided with the sky blue used for the fastest
276
+ band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
277
+ stays in the same warm family so the ramp still reads cool to warm as work ages. */
278
+ --wip-chart-duration-more-than-four-weeks-color: #B16BAE;
254
279
  --estimate-accuracy-chart-completed-border-color: #2DCB9A;
255
280
  --estimate-accuracy-chart-active-border-color: #E69F00;
256
281
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
@@ -262,7 +287,10 @@ html[data-theme="dark"] {
262
287
  --sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
263
288
  --sprint-burndown-sprint-color-5: #F0E442; /* yellow */
264
289
  --sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
265
- --sprint-burndown-sprint-color-7: #92D9C0; /* light teal (yellow conflicts with color-5) */
290
+ --sprint-burndown-sprint-color-7: #388CF3; /* lightened blue. Was #92D9C0 light teal, which
291
+ sat too close to color-3 for anyone with protanopia or deuteranopia. Blue is the one
292
+ Okabe-Ito hue the dark set was missing, since the original is too dark on this
293
+ background. See jirametrics-60z for the measurements. */
266
294
  --daily-view-selected-issue-background: #474747;
267
295
  --daily-view-issue-border: #2DCB9A;
268
296
  --daily-view-selected-issue-border: #E69F00;
@@ -294,6 +322,8 @@ html[data-theme="light"] {
294
322
  --body-background: white;
295
323
  --default-text-color: black;
296
324
  --grid-line-color: lightgray;
325
+ --cycletime-scatterplot-cap-rule-color: #555555;
326
+ --cycletime-scatterplot-cap-gutter-color: rgba(0, 0, 0, 0.05);
297
327
  --expedited-color: #D55E00; /* Okabe-Ito vermilion */
298
328
  --blocked-color: #D55E00; /* Okabe-Ito vermilion */
299
329
  --stalled-color: #E69F00; /* Okabe-Ito orange */
@@ -377,6 +407,8 @@ html[data-theme="light"] {
377
407
  --body-background: #343434;
378
408
  --default-text-color: #aaa;
379
409
  --grid-line-color: #424242;
410
+ --cycletime-scatterplot-cap-rule-color: #b8b8b8;
411
+ --cycletime-scatterplot-cap-gutter-color: rgba(255, 255, 255, 0.08);
380
412
 
381
413
  --expedited-color: #E69F00; /* Okabe-Ito orange for dark bg */
382
414
  --blocked-color: #E69F00; /* Okabe-Ito orange for dark bg */
@@ -397,12 +429,12 @@ html[data-theme="light"] {
397
429
  --hierarchy-table-inactive-item-text-color: #939393;
398
430
 
399
431
  --wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
400
- --wip-chart-completed-but-not-started-color: #92D9C0;
432
+ --wip-chart-completed-but-not-started-color: #00A062; /* see jirametrics-3fg */
401
433
  --wip-chart-duration-less-than-day-color: #56B4E9;
402
434
  --wip-chart-duration-week-or-less-color: #F0E442;
403
435
  --wip-chart-duration-two-weeks-or-less-color: #E69F00;
404
436
  --wip-chart-duration-four-weeks-or-less-color: #D55E00;
405
- --wip-chart-duration-more-than-four-weeks-color: #DE9AC4; /* lighter reddish purple */
437
+ --wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
406
438
 
407
439
  --estimate-accuracy-chart-completed-border-color: #2DCB9A;
408
440
  --estimate-accuracy-chart-active-border-color: #E69F00;
@@ -417,7 +449,10 @@ html[data-theme="light"] {
417
449
  --sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
418
450
  --sprint-burndown-sprint-color-5: #F0E442; /* yellow */
419
451
  --sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
420
- --sprint-burndown-sprint-color-7: #92D9C0; /* light teal (yellow conflicts with color-5) */
452
+ --sprint-burndown-sprint-color-7: #388CF3; /* lightened blue. Was #92D9C0 light teal, which
453
+ sat too close to color-3 for anyone with protanopia or deuteranopia. Blue is the one
454
+ Okabe-Ito hue the dark set was missing, since the original is too dark on this
455
+ background. See jirametrics-60z for the measurements. */
421
456
 
422
457
  --daily-view-selected-issue-background: #474747;
423
458
  --daily-view-issue-border: #2DCB9A;
@@ -4,9 +4,13 @@
4
4
  <title><%= project_name.empty? ? 'JiraMetrics' : "JiraMetrics - #{project_name}" %></title>
5
5
  <link rel="icon" type="image/png" href="https://github.com/mikebowler/jirametrics/blob/main/favicon.png?raw=true" />
6
6
  <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
7
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
+ <!-- Pinned to a major version, not to an exact one: patch and minor releases still arrive
8
+ automatically, but a new major cannot land in someone's report unannounced. The charts
9
+ depend on specific annotation plugin behaviour (hitTolerance, enter/leave, the label's own
10
+ drawTime), and nothing in the test suite reaches browser behaviour to catch a break. -->
11
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
8
12
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
9
- <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
13
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script>
10
14
  <script type="text/javascript">
11
15
  <%= javascript %>
12
16
  </script>
@@ -141,6 +141,22 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', eve
141
141
 
142
142
  // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
143
143
  // https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
144
+ // Apply an alpha to a colour that may only be resolvable in the browser, such as one that came
145
+ // from a CSS variable. Ruby cannot do this arithmetic any more now that palette colours are
146
+ // variables rather than literals.
147
+ function withAlpha(color, alpha) {
148
+ const probe = document.createElement('canvas').getContext('2d')
149
+ probe.fillStyle = color
150
+ const resolved = probe.fillStyle // normalised by the browser to #rrggbb or rgba(...)
151
+ if (resolved.startsWith('#')) {
152
+ const r = parseInt(resolved.substr(1, 2), 16)
153
+ const g = parseInt(resolved.substr(3, 2), 16)
154
+ const b = parseInt(resolved.substr(5, 2), 16)
155
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
156
+ }
157
+ return resolved.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `, ${alpha})`)
158
+ }
159
+
144
160
  function createDiagonalPattern(color = 'black') {
145
161
  // create a 5x5 px canvas for the pattern's base shape
146
162
  let shape = document.createElement('canvas')
@@ -36,20 +36,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
36
36
  annotation: {
37
37
  annotations: {
38
38
  <%
39
- results = the_stats[:all][:percentiles]
40
- results.each do |percentile, value|
39
+ # Guard the nil value: an unlabelled annotation would emit "value:" with nothing after
40
+ # it, which is a syntax error that takes the whole chart script out.
41
+ (the_stats.dig(:all, :percentiles) || {}).each do |percentile, value|
42
+ next if value.nil?
41
43
  %>
42
- percentile<%= percentile.to_s %>: {
44
+ <%= "percentile#{percentile}".to_json %>: {
43
45
  type: 'line',
44
46
  scaleID: 'x',
45
47
  value: <%= value %>,
46
48
  borderWidth: 1,
49
+ hitTolerance: 6,
47
50
  drawTime: 'beforeDatasetsDraw',
48
51
  label: {
49
- enabled: true,
50
- content: '<%= "#{percentile}%" %>',
52
+ display: false,
53
+ content: <%= "#{ordinal percentile} percentile at #{label_cycletime value}".to_json %>,
51
54
  position: 'start',
52
- }
55
+ // Drawn late so the label sits on top of the bars rather than behind them.
56
+ drawTime: 'afterDraw',
57
+ backgroundColor: 'rgba(0,0,0,0.85)',
58
+ color: '#fff',
59
+ font: { size: 11 }
60
+ },
61
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
62
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
53
63
  },
54
64
  <% end %>
55
65
  },
@@ -83,8 +93,8 @@ if show_stats
83
93
  <th>Max</th>
84
94
  <th>Avg</th>
85
95
  <th>Mode</th>
86
- <% percentiles.each do |p| %>
87
- <th><%= p %>th</th>
96
+ <% percentiles.each do |percentile| %>
97
+ <th><%= ordinal percentile %></th>
88
98
  <% end %>
89
99
  </tr>
90
100
  <% the_stats.each do |k, v| %>
@@ -94,8 +104,8 @@ if show_stats
94
104
  <td style="text-align: right;"><%= v[:max] %></td>
95
105
  <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
96
106
  <td><%= v[:mode].join(', ') %></td>
97
- <% percentiles.each do |p| %>
98
- <td style="text-align: right;"><%= v[:percentiles][p] %></td>
107
+ <% percentiles.each do |percentile| %>
108
+ <td style="text-align: right;"><%= v[:percentiles][percentile] %></td>
99
109
  <% end %>
100
110
  </tr>
101
111
  <% end %>
@@ -107,12 +117,14 @@ if show_stats
107
117
  <li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
108
118
  <li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
109
119
  <li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
110
- <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of values are X or less. Typical percentiles of interest are:</li>
120
+ <% unless percentiles.empty? %>
121
+ <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of values are X or less. The ones in the table above:</li>
111
122
  <ul>
112
- <li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
113
- <li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
114
- <li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
123
+ <% percentiles.each do |percentile| %>
124
+ <li><b><%= percentile %>%</b>: <%= percentile_explanation percentile %></li>
125
+ <% end %>
115
126
  </ul>
127
+ <% end %>
116
128
  </ul>
117
129
  </div>
118
130
  </div>
@@ -5,8 +5,52 @@
5
5
  <script>
6
6
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
7
7
  type: 'scatter',
8
+ <% if @cap %>
9
+ plugins: [{
10
+ id: 'capRule<%= chart_id %>',
11
+ afterDatasetsDraw: function(chart) {
12
+ const yPixel = chart.scales.y.getPixelForValue(<%= @cap[:sep] %>);
13
+ const area = chart.chartArea;
14
+ const pen = chart.ctx;
15
+ pen.save();
16
+ pen.strokeStyle = <%= CssVariable['--cycletime-scatterplot-cap-rule-color'].to_json %>;
17
+ pen.lineWidth = 1.5;
18
+ [0, 3].forEach(function(offset) {
19
+ pen.beginPath();
20
+ pen.moveTo(area.left, yPixel - offset);
21
+ pen.lineTo(area.right, yPixel - offset);
22
+ pen.stroke();
23
+ });
24
+ pen.restore();
25
+ }
26
+ }],
27
+ <% end %>
8
28
  data: {
9
- datasets: <%= JSON.generate(data_sets) %>
29
+ datasets: (function() {
30
+ const dataSets = <%= JSON.generate(data_sets) %>;
31
+ <% if @cap %>
32
+ // draw an up-arrow glyph in the given colour, usable as a Chart.js pointStyle.
33
+ // The arrow says "the real value continues upward beyond the cap".
34
+ function capArrow(color) {
35
+ const size = 18, glyph = document.createElement('canvas');
36
+ glyph.width = size; glyph.height = size;
37
+ const pen = glyph.getContext('2d');
38
+ pen.strokeStyle = color; pen.lineWidth = 2.5; pen.lineCap = 'round'; pen.lineJoin = 'round';
39
+ pen.beginPath();
40
+ pen.moveTo(size / 2, size - 3); pen.lineTo(size / 2, 3);
41
+ pen.moveTo(size / 2 - 5, 8); pen.lineTo(size / 2, 3); pen.lineTo(size / 2 + 5, 8);
42
+ pen.stroke();
43
+ return glyph;
44
+ }
45
+ dataSets.forEach(function(dataSet) {
46
+ if (dataSet.type === 'line') return; // skip the interleaved trend lines
47
+ const arrow = capArrow(dataSet.backgroundColor);
48
+ dataSet.pointStyle = dataSet.data.map(function(point) { return point.over ? arrow : 'circle'; });
49
+ dataSet.pointRadius = dataSet.data.map(function(point) { return point.over ? 9 : 3; });
50
+ });
51
+ <% end %>
52
+ return dataSets;
53
+ })()
10
54
  },
11
55
  options: {
12
56
  title: {
@@ -29,7 +73,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
29
73
  },
30
74
  y: {
31
75
  min: 0,
32
- max: <%= (@highest_y_value * 1.1).ceil %>,
76
+ max: <%= @cap ? @cap[:axis_max] : (@highest_y_value * 1.1).ceil %>,
33
77
  scaleLabel: {
34
78
  display: true
35
79
  },
@@ -39,6 +83,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
39
83
  },
40
84
  ticks: {
41
85
  callback: function(value, index, ticks) {
86
+ <% if @cap %>if (value > <%= @cap[:cutoff] %>) return null;<% end %>
42
87
  return index === ticks.length - 1 ? null : value;
43
88
  }
44
89
  }
@@ -58,15 +103,56 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
58
103
  <%= working_days_annotation %>
59
104
  <%= date_annotation %>
60
105
 
61
- <% @percentage_lines.each_with_index do |args, index| %>
62
- <% percent, color = args %>
63
- line<%= index %>: {
106
+ <% if @cap %>
107
+ capGutter: {
108
+ type: 'box',
109
+ yMin: <%= @cap[:sep] %>,
110
+ yMax: <%= @cap[:axis_max] %>,
111
+ backgroundColor: <%= CssVariable['--cycletime-scatterplot-cap-gutter-color'].to_json %>,
112
+ borderWidth: 0
113
+ },
114
+ capLabel: {
115
+ type: 'line',
116
+ yMin: <%= @cap[:sep] %>,
117
+ yMax: <%= @cap[:sep] %>,
118
+ borderWidth: 0,
119
+ drawTime: 'afterDraw',
120
+ label: {
121
+ display: true,
122
+ content: '<%= @cap[:label] %>',
123
+ position: 'start',
124
+ yAdjust: 0,
125
+ backgroundColor: 'rgba(0,0,0,0.85)',
126
+ color: '#fff',
127
+ font: { size: 11 }
128
+ }
129
+ },
130
+ <% end %>
131
+
132
+ <% @percentage_lines.each do |line| %>
133
+ <%= line[:id].to_json %>: {
64
134
  type: 'line',
65
- yMin: <%= percent %>,
66
- yMax: <%= percent %>,
67
- borderColor: <%= color.to_json %>,
135
+ yMin: <%= line[:value] %>,
136
+ yMax: <%= line[:value] %>,
137
+ borderColor: <%= line[:color].to_json %>,
68
138
  borderWidth: 1,
69
- drawTime: 'beforeDraw'
139
+ hitTolerance: 6,
140
+ drawTime: 'beforeDraw',
141
+ label: {
142
+ display: false,
143
+ <%# to_json rather than quoting by hand: the group label comes from the user's %>
144
+ <%# grouping_rules block and an apostrophe in it would otherwise end the string. %>
145
+ content: <%= "#{line[:label]} #{line[:percentile]}% at #{label_days line[:value]}".to_json %>,
146
+ position: 'end',
147
+ // The line itself draws early so it sits under the dots, but the hover label has to
148
+ // draw last or the non-working-day hatching (also beforeDraw) covers it.
149
+ drawTime: 'afterDraw',
150
+ backgroundColor: 'rgba(0,0,0,0.85)',
151
+ color: '#fff',
152
+ font: { size: 11 }
153
+ },
154
+ enter(ctx) { ctx.element.label.options.display = true; ctx.chart.draw(); },
155
+ leave(ctx) { ctx.element.label.options.display = false; ctx.chart.draw(); }
70
156
  },
71
157
  <% end %>
72
158
  }
@@ -80,8 +166,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
80
166
  }
81
167
  nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
82
168
 
83
- // Hide/show the 85% line for that dataset
84
- legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
169
+ // Hide/show every percentile line belonging to this group. The map is keyed by the
170
+ // dataset index we just worked out, which is unambiguous even when two groups share a
171
+ // label. A group with no lines of its own simply isn't in the map.
172
+ const annotationMap = <%= legend_annotation_map.to_json %>;
173
+ (annotationMap[i] || []).forEach((id) => {
174
+ legend.chart.options.plugins.annotation.annotations[id].display = nextVisibility;
175
+ });
85
176
 
86
177
  // Hide/show the trendline for this dataset, if they were enabled. The trendline is always
87
178
  // there but not always visible.
@@ -6,13 +6,32 @@ class HtmlGenerator
6
6
  def create_html output_filename:, settings:, project_name: ''
7
7
  @settings = settings
8
8
  project_name = project_name.to_s
9
- html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
10
9
  css = load_css html_directory: html_directory
11
10
  javascript = file_system.load(File.join(html_directory, 'index.js'))
12
11
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
13
12
  file_system.save_file content: erb.result(binding), filename: output_filename
14
13
  end
15
14
 
15
+ # Charts allocate colours while they run, which happens before create_html is reached, so the
16
+ # palette cannot wait for the CSS that create_html loads. It reads the files directly instead.
17
+ # Deliberately not going through file_system: the shipped stylesheet is part of the gem rather
18
+ # than user data, and the palette only needs to count slots in it.
19
+ def color_palette
20
+ @color_palette ||= ColorPalette.new css: palette_css
21
+ end
22
+
23
+ def palette_css
24
+ base = File.read File.join(html_directory, 'index.css')
25
+ extra = settings && settings['include_css']
26
+ return base unless extra && File.exist?(extra)
27
+
28
+ "#{base}\n\n#{File.read extra}"
29
+ end
30
+
31
+ def html_directory
32
+ "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
33
+ end
34
+
16
35
  def load_css html_directory:
17
36
  base_css_filename = File.join(html_directory, 'index.css')
18
37
  base_css = file_system.load(base_css_filename)