jirametrics 3.2 → 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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +111 -10
- data/lib/jirametrics/aging_work_in_progress_chart.rb +10 -6
- data/lib/jirametrics/aging_work_table.rb +22 -7
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +15 -2
- data/lib/jirametrics/board_movement_calculator.rb +18 -7
- data/lib/jirametrics/chart_base.rb +39 -5
- data/lib/jirametrics/color_palette.rb +61 -0
- data/lib/jirametrics/cumulative_flow_diagram.rb +9 -6
- data/lib/jirametrics/cycletime_scatterplot.rb +67 -8
- data/lib/jirametrics/daily_wip_chart.rb +1 -1
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/exporter.rb +77 -11
- data/lib/jirametrics/groupable_issue_chart.rb +27 -2
- data/lib/jirametrics/grouping_rules.rb +13 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +16 -5
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +1 -1
- data/lib/jirametrics/html/index.css +32 -5
- data/lib/jirametrics/html/index.erb +6 -2
- data/lib/jirametrics/html/index.js +16 -0
- data/lib/jirametrics/html/time_based_histogram.erb +26 -14
- data/lib/jirametrics/html/time_based_scatterplot.erb +29 -9
- data/lib/jirametrics/html_generator.rb +20 -1
- data/lib/jirametrics/html_report_config.rb +3 -3
- data/lib/jirametrics/percentile_validation.rb +26 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +1 -0
- data/lib/jirametrics/settings.json +1 -0
- data/lib/jirametrics/time_based_histogram.rb +33 -15
- data/lib/jirametrics/time_based_scatterplot.rb +96 -14
- data/lib/jirametrics/trend_line_calculator.rb +5 -2
- data/lib/jirametrics/wip_by_column_chart.rb +1 -1
- metadata +3 -1
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
matches << [project,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
<%
|
|
44
|
-
|
|
43
|
+
<% @percentage_lines.each do |percentage_line| %>
|
|
44
|
+
<%= percentage_line[:id].to_json %>: {
|
|
45
45
|
type: 'line',
|
|
46
46
|
scaleID: 'x',
|
|
47
|
-
value: '<%=
|
|
47
|
+
value: '<%= percentage_line[:x] %>',
|
|
48
48
|
borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
|
|
49
49
|
borderWidth: 1,
|
|
50
|
-
|
|
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
|
|
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
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
--cycletime-scatterplot-cap-rule-color: #555555;
|
|
9
9
|
--cycletime-scatterplot-cap-gutter-color: rgba(0, 0, 0, 0.05);
|
|
10
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 */
|
|
24
|
+
|
|
11
25
|
--non-working-days-color: #F0F0F0;
|
|
12
26
|
--expedited-color: #D55E00; /* Okabe-Ito vermilion */
|
|
13
27
|
--blocked-color: #D55E00; /* Okabe-Ito vermilion */
|
|
@@ -254,7 +268,14 @@ html[data-theme="dark"] {
|
|
|
254
268
|
--wip-by-column-chart-limit-line-color: #E69F00; /* Okabe-Ito orange */
|
|
255
269
|
--wip-by-column-chart-recommendation-color: #2DCB9A; /* lighter bluish green for dark bg */
|
|
256
270
|
--wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
|
|
257
|
-
|
|
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;
|
|
258
279
|
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
259
280
|
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
260
281
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
@@ -266,7 +287,10 @@ html[data-theme="dark"] {
|
|
|
266
287
|
--sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
|
|
267
288
|
--sprint-burndown-sprint-color-5: #F0E442; /* yellow */
|
|
268
289
|
--sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
|
|
269
|
-
--sprint-burndown-sprint-color-7: #
|
|
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. */
|
|
270
294
|
--daily-view-selected-issue-background: #474747;
|
|
271
295
|
--daily-view-issue-border: #2DCB9A;
|
|
272
296
|
--daily-view-selected-issue-border: #E69F00;
|
|
@@ -405,12 +429,12 @@ html[data-theme="light"] {
|
|
|
405
429
|
--hierarchy-table-inactive-item-text-color: #939393;
|
|
406
430
|
|
|
407
431
|
--wip-chart-completed-color: #2DCB9A; /* lighter bluish green */
|
|
408
|
-
--wip-chart-completed-but-not-started-color: #
|
|
432
|
+
--wip-chart-completed-but-not-started-color: #00A062; /* see jirametrics-3fg */
|
|
409
433
|
--wip-chart-duration-less-than-day-color: #56B4E9;
|
|
410
434
|
--wip-chart-duration-week-or-less-color: #F0E442;
|
|
411
435
|
--wip-chart-duration-two-weeks-or-less-color: #E69F00;
|
|
412
436
|
--wip-chart-duration-four-weeks-or-less-color: #D55E00;
|
|
413
|
-
--wip-chart-duration-more-than-four-weeks-color: #
|
|
437
|
+
--wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
|
|
414
438
|
|
|
415
439
|
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
416
440
|
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
@@ -425,7 +449,10 @@ html[data-theme="light"] {
|
|
|
425
449
|
--sprint-burndown-sprint-color-4: #CC79A7; /* reddish purple (vermilion → orange conflicts with color-2) */
|
|
426
450
|
--sprint-burndown-sprint-color-5: #F0E442; /* yellow */
|
|
427
451
|
--sprint-burndown-sprint-color-6: #D55E00; /* vermilion (sky blue conflicts with color-1) */
|
|
428
|
-
--sprint-burndown-sprint-color-7: #
|
|
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. */
|
|
429
456
|
|
|
430
457
|
--daily-view-selected-issue-background: #474747;
|
|
431
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
|
-
|
|
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://
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
content:
|
|
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 |
|
|
87
|
-
<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 |
|
|
98
|
-
<td style="text-align: right;"><%= v[:percentiles][
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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>
|
|
@@ -129,15 +129,30 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
129
129
|
},
|
|
130
130
|
<% end %>
|
|
131
131
|
|
|
132
|
-
<% @percentage_lines.
|
|
133
|
-
|
|
134
|
-
line<%= index %>: {
|
|
132
|
+
<% @percentage_lines.each do |line| %>
|
|
133
|
+
<%= line[:id].to_json %>: {
|
|
135
134
|
type: 'line',
|
|
136
|
-
yMin: <%=
|
|
137
|
-
yMax: <%=
|
|
138
|
-
borderColor: <%= color.to_json %>,
|
|
135
|
+
yMin: <%= line[:value] %>,
|
|
136
|
+
yMax: <%= line[:value] %>,
|
|
137
|
+
borderColor: <%= line[:color].to_json %>,
|
|
139
138
|
borderWidth: 1,
|
|
140
|
-
|
|
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(); }
|
|
141
156
|
},
|
|
142
157
|
<% end %>
|
|
143
158
|
}
|
|
@@ -151,8 +166,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
151
166
|
}
|
|
152
167
|
nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
|
|
153
168
|
|
|
154
|
-
// Hide/show
|
|
155
|
-
|
|
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
|
+
});
|
|
156
176
|
|
|
157
177
|
// Hide/show the trendline for this dataset, if they were enabled. The trendline is always
|
|
158
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)
|
|
@@ -106,9 +106,8 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
106
106
|
@file_config.project_config.exporter.timezone_offset
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
ChartBase::OKABE_ITO_PALETTE[@palette_index % ChartBase::OKABE_ITO_PALETTE.size]
|
|
109
|
+
def next_palette_color
|
|
110
|
+
color_palette.next_color
|
|
112
111
|
end
|
|
113
112
|
|
|
114
113
|
def html string, type: :body
|
|
@@ -137,6 +136,7 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
137
136
|
project_config = @file_config.project_config
|
|
138
137
|
|
|
139
138
|
chart.file_system = file_system
|
|
139
|
+
chart.color_palette = color_palette
|
|
140
140
|
chart.issues = issues
|
|
141
141
|
chart.time_range = project_config.time_range
|
|
142
142
|
chart.timezone_offset = timezone_offset
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Percentile lists arrive from two different places in the config DSL: the chart level
|
|
4
|
+
# "percentiles [50, 85]" setter and "rule.percentiles = [...]" inside a grouping_rules block.
|
|
5
|
+
# Both are user supplied and both end up as JavaScript annotation ids in the rendered chart, so
|
|
6
|
+
# both need the same guard rails. Including this module gives you a private validate_percentiles.
|
|
7
|
+
module PercentileValidation
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns the cleaned up list. Raises ArgumentError, naming the offending value, for anything
|
|
11
|
+
# that isn't an Integer between 0 and 100.
|
|
12
|
+
def validate_percentiles list
|
|
13
|
+
list.each do |percentile|
|
|
14
|
+
raise ArgumentError, "percentile #{percentile} must be an integer" unless percentile.is_a? Integer
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "percentile #{percentile} must be between 0 and 100" unless percentile.between?(0, 100)
|
|
17
|
+
end
|
|
18
|
+
list.uniq.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# For the places where exactly one value is meaningful, such as a forecast that has to produce a
|
|
22
|
+
# single number of days. Returns the value; raises the same errors as the list form.
|
|
23
|
+
def validate_percentile value
|
|
24
|
+
validate_percentiles([value]).first
|
|
25
|
+
end
|
|
26
|
+
end
|