Class: ChartBase

Inherits:
Object
  • Object
show all
Defined in:
lib/jirametrics/chart_base.rb

Constant Summary collapse

OKABE_ITO_PALETTE =

Okabe-Ito palette - perceptually distinct under the most common forms of colour blindness. Ordered from most- to least-commonly useful for chart series.

%w[
  #0072B2
  #E69F00
  #009E73
  #56B4E9
  #D55E00
  #CC79A7
  #F0E442
].freeze
LABEL_POSITIONS =
%w[5% 25% 45% 65%].freeze
@@chart_counter =
0

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeChartBase

Returns a new instance of ChartBase.



23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/jirametrics/chart_base.rb', line 23

def initialize
  @chart_colors = {
    'Story'  => CssVariable['--type-story-color'],
    'Task'   => CssVariable['--type-task-color'],
    'Bug'    => CssVariable['--type-bug-color'],
    'Defect' => CssVariable['--type-bug-color'],
    'Spike'  => CssVariable['--type-spike-color']
  }
  @canvas_width = 800
  @canvas_height = 200
  @canvas_responsive = true
end

Instance Attribute Details

#aggregated_project=(value) ⇒ Object (writeonly)

Sets the attribute aggregated_project

Parameters:

  • value

    the value to set the attribute aggregated_project to.



18
19
20
# File 'lib/jirametrics/chart_base.rb', line 18

def aggregated_project=(value)
  @aggregated_project = value
end

#all_boardsObject

Returns the value of attribute all_boards.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def all_boards
  @all_boards
end

#atlassian_document_formatObject

Returns the value of attribute atlassian_document_format.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def atlassian_document_format
  @atlassian_document_format
end

#board_idObject

Returns the value of attribute board_id.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def board_id
  @board_id
end

#canvas_heightObject (readonly)

Returns the value of attribute canvas_height.



19
20
21
# File 'lib/jirametrics/chart_base.rb', line 19

def canvas_height
  @canvas_height
end

#canvas_widthObject (readonly)

Returns the value of attribute canvas_width.



19
20
21
# File 'lib/jirametrics/chart_base.rb', line 19

def canvas_width
  @canvas_width
end

#color_paletteObject

Returns the value of attribute color_palette.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def color_palette
  @color_palette
end

#data_qualityObject

Returns the value of attribute data_quality.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def data_quality
  @data_quality
end

#date_rangeObject

Returns the value of attribute date_range.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def date_range
  @date_range
end

#file_systemObject

Returns the value of attribute file_system.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def file_system
  @file_system
end

#fix_versionsObject

Returns the value of attribute fix_versions.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def fix_versions
  @fix_versions
end

#holiday_datesObject

Returns the value of attribute holiday_dates.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def holiday_dates
  @holiday_dates
end

#issuesObject

Returns the value of attribute issues.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def issues
  @issues
end

#settingsObject

Returns the value of attribute settings.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def settings
  @settings
end

#time_rangeObject

Returns the value of attribute time_range.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def time_range
  @time_range
end

#timezone_offsetObject

Returns the value of attribute timezone_offset.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def timezone_offset
  @timezone_offset
end

#x_axis_titleObject

Returns the value of attribute x_axis_title.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def x_axis_title
  @x_axis_title
end

#y_axis_titleObject

Returns the value of attribute y_axis_title.



15
16
17
# File 'lib/jirametrics/chart_base.rb', line 15

def y_axis_title
  @y_axis_title
end

Instance Method Details

#aggregated_project?Boolean

Returns:

  • (Boolean)


44
45
46
# File 'lib/jirametrics/chart_base.rb', line 44

def aggregated_project?
  @aggregated_project
end

#before_runObject



40
41
42
# File 'lib/jirametrics/chart_base.rb', line 40

def before_run
  @call_before_run_procs&.each(&:call)
end

#call_before_run(&proc) ⇒ Object



36
37
38
# File 'lib/jirametrics/chart_base.rb', line 36

def call_before_run &proc
  (@call_before_run_procs ||= []) << proc
end

#canvas(width:, height:, responsive: true) ⇒ Object



366
367
368
369
370
# File 'lib/jirametrics/chart_base.rb', line 366

def canvas width:, height:, responsive: true
  @canvas_width = width
  @canvas_height = height
  @canvas_responsive = responsive
end

#canvas_responsive?Boolean

Returns:

  • (Boolean)


372
373
374
# File 'lib/jirametrics/chart_base.rb', line 372

def canvas_responsive?
  @canvas_responsive
end

#chart_format(object) ⇒ Object



243
244
245
246
247
248
249
250
# File 'lib/jirametrics/chart_base.rb', line 243

def chart_format object
  if object.is_a? Time
    # "2022-04-09T11:38:30-07:00"
    object.strftime '%Y-%m-%dT%H:%M:%S%z'
  else
    object.to_s
  end
end

#collapsible_issues_panel(issue_descriptions, *args) ⇒ Object



129
130
131
132
133
134
135
136
# File 'lib/jirametrics/chart_base.rb', line 129

def collapsible_issues_panel issue_descriptions, *args
  link_id = next_id
  issues_id = next_id

  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
  erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
  erb.result(binding)
end

#color_block(color, title: nil) ⇒ Object



376
377
378
379
380
381
382
383
384
385
# File 'lib/jirametrics/chart_base.rb', line 376

def color_block color, title: nil
  result = +''
  result << "<div class='color_block' style='"
  result << "background: #{CssVariable[color]};" if color
  result << 'visibility: hidden;' unless color
  result << "'"
  result << " title=#{title.inspect}" if title
  result << '></div>'
  result
end

#color_for(type:) ⇒ Object



85
86
87
# File 'lib/jirametrics/chart_base.rb', line 85

def color_for type:
  @chart_colors[type] ||= next_palette_color
end

#comma_and(phrases) ⇒ Object

"a and b" for two, "a, b and c" for more. Used when listing configured percentiles in prose.



276
277
278
279
280
# File 'lib/jirametrics/chart_base.rb', line 276

def comma_and phrases
  return phrases.join ' and ' if phrases.size <= 2

  "#{phrases[0..-2].join ', '} and #{phrases.last}"
end

#completed_issues_in_range(include_unstarted: false) ⇒ Object



232
233
234
235
236
237
238
239
240
241
# File 'lib/jirametrics/chart_base.rb', line 232

def completed_issues_in_range include_unstarted: false
  issues.select do |issue|
    cycletime = issue.board.cycletime
    started_time, stopped_time = cycletime.started_stopped_times(issue)

    stopped_time &&
      date_range.include?(stopped_time.to_date) && # Remove outside range
      (include_unstarted || (started_time && (stopped_time >= started_time)))
  end
end

#current_boardObject

Return only the board columns for the current board.



217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/jirametrics/chart_base.rb', line 217

def current_board
  if @board_id.nil?
    case @all_boards.size
    when 0
      raise 'Couldn\'t find any board configurations. Ensure one is set'
    when 1
      return @all_boards.values[0]
    else
      raise "Must set board_id so we know which to use. Multiple boards found: #{@all_boards.keys.inspect}"
    end
  end

  @all_boards[@board_id]
end

#cycletime(&block) ⇒ Object

Set a cycletime for just this one chart, overriding the one for the report.



397
398
399
400
401
402
403
404
# File 'lib/jirametrics/chart_base.rb', line 397

def cycletime &block
  call_before_run do
    @cycletime = CycleTimeConfig.new(
      possible_statuses: possible_statuses, label: nil, block: block, file_system: file_system,
      settings: settings
    )
  end
end

#cycletime_for_issue(issue) ⇒ Object

Returns the cycletime in use right now, which may be specific to the chart or across the report.



407
408
409
# File 'lib/jirametrics/chart_base.rb', line 407

def cycletime_for_issue issue
  @cycletime || issue.board.cycletime
end

#daily_chart_dataset(date_issues_list:, color:, label:, positive: true) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/jirametrics/chart_base.rb', line 103

def daily_chart_dataset date_issues_list:, color:, label:, positive: true
  {
    type: 'bar',
    label: label,
    data: date_issues_list.collect do |date, issues|
      issues.sort_by!(&:key_as_i)
      title = "#{label} (#{label_issues issues.size})"
      {
        x: date,
        y: positive ? issues.size : -issues.size,
        title: [title] + issues.collect { |i| "#{i.key} : #{i.summary.strip}#{" #{yield date, i}" if block_given?}" }
      }
    end,
    backgroundColor: color,
    borderRadius: positive ? 0 : 5
  }
end

#date_annotationObject



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/jirametrics/chart_base.rb', line 168

def date_annotation
  annotations = settings['date_annotations'] || []
  in_range = annotations
    .map { |a| [a, normalize_annotation_datetime(a['date'])] }
    .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
    .sort_by { |(_, dt)| dt }

  positions = stagger_label_positions(in_range.map { |(_, dt)| dt })

  in_range.each_with_index.collect do |(a, normalized), index|
    <<~TEXT
      dateAnnotation#{index}: {
        type: 'line',
        xMin: #{normalized.to_json},
        xMax: #{normalized.to_json},
        borderColor: 'rgba(0,0,0,0.7)',
        borderWidth: 1,
        label: {
          display: true,
          content: #{a['label'].to_json},
          position: #{positions[index].to_json}
        }
      },
    TEXT
  end.join
end

#describe_non_working_daysObject



387
388
389
390
391
392
393
394
# File 'lib/jirametrics/chart_base.rb', line 387

def describe_non_working_days
  <<-TEXT
    <div class='p'>
      The #{color_block '--non-working-days-color'} vertical bars indicate non-working days; weekends
      and any other holidays mentioned in the configuration.
    </div>
  TEXT
end

#description_text(text = :none) ⇒ Object



257
258
259
260
# File 'lib/jirametrics/chart_base.rb', line 257

def description_text text = :none
  @description_text = text unless text == :none
  @description_text
end

#format_integer(number) ⇒ Object

Convert a number like 1234567 into the string "1,234,567"



295
296
297
# File 'lib/jirametrics/chart_base.rb', line 295

def format_integer number
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
end

#format_status(object, board:, is_category: false, use_old_status: false) ⇒ Object

object will be either a Status or a ChangeItem if it's a ChangeItem then use_old_status will specify whether we're using the new or old Either way, is_category will format the category rather than the status



302
303
304
305
306
307
308
309
310
# File 'lib/jirametrics/chart_base.rb', line 302

def format_status object, board:, is_category: false, use_old_status: false
  status, error_message = resolve_status object, board: board, use_old_status: use_old_status
  return "<span style='color: red'>#{error_message}</span>" if error_message

  color = status_category_color status
  visibility = is_category ? '' : not_visible_icon(status, board)
  text = is_category ? status.category : status
  "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
end

#header_text(text = :none) ⇒ Object



252
253
254
255
# File 'lib/jirametrics/chart_base.rb', line 252

def header_text text = :none
  @header_text = text unless text == :none
  @header_text
end

#holidays(date_range: @date_range) ⇒ Object

Returns the non-working stretches (weekends and holidays) within the range, each as a Date range of consecutive days.



140
141
142
143
144
145
# File 'lib/jirametrics/chart_base.rb', line 140

def holidays date_range: @date_range
  date_range
    .select { |date| non_working_day?(date) }
    .slice_when { |previous_date, date| date != previous_date + 1 }
    .collect { |run| run.first..run.last }
end

#html_directoryObject



48
49
50
51
# File 'lib/jirametrics/chart_base.rb', line 48

def html_directory
  pathname = Pathname.new(File.realpath(__FILE__))
  "#{pathname.dirname}/html"
end

#icon_span(title:, icon:) ⇒ Object



339
340
341
# File 'lib/jirametrics/chart_base.rb', line 339

def icon_span title:, icon:
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
end


121
122
123
124
125
126
127
# File 'lib/jirametrics/chart_base.rb', line 121

def link_to_issue issue, args = {}
  attributes = { class: 'issue_key' }
    .merge(args)
    .collect { |key, value| "#{key}='#{value}'" }
    .join(' ')
  "<a href='#{issue.url}' #{attributes}>#{issue.key}</a>"
end

#next_idObject



81
82
83
# File 'lib/jirametrics/chart_base.rb', line 81

def next_id
  @@chart_counter += 1
end

#next_palette_colorObject

A colour for something that needs telling apart, where no specific colour is wanted. If a specific colour matters, configure it instead. See ColorPalette for why this is not random and why it must not become random again.



362
363
364
# File 'lib/jirametrics/chart_base.rb', line 362

def next_palette_color
  (@color_palette ||= ColorPalette.default).next_color
end

#non_working_day?(date) ⇒ Boolean

Returns:

  • (Boolean)


147
148
149
# File 'lib/jirametrics/chart_base.rb', line 147

def non_working_day? date
  date.saturday? || date.sunday? || holiday_dates.include?(date)
end

#normalize_annotation_datetime(value) ⇒ Object



207
208
209
210
211
212
213
214
# File 'lib/jirametrics/chart_base.rb', line 207

def normalize_annotation_datetime value
  offset = timezone_offset || '+00:00'
  if value.include?('T')
    value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
  else
    "#{value}T00:00:00#{offset}"
  end
end

#not_visible_icon(status, board) ⇒ Object

The 👀 marker for a status that isn't mapped to any column, or '' when it is visible.



330
331
332
333
334
335
336
337
# File 'lib/jirametrics/chart_base.rb', line 330

def not_visible_icon status, board
  return '' if board.visible_columns.any? { |column| column.status_ids.include? status.id }

  icon_span(
    title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
    icon: ' 👀'
  )
end

#not_visible_text(issue) ⇒ Object



343
344
345
346
347
348
# File 'lib/jirametrics/chart_base.rb', line 343

def not_visible_text issue
  reasons = issue.reasons_not_visible_on_board
  return nil if reasons.empty?

  "<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
end

#ordinal(number) ⇒ Object

1st, 2nd, 3rd, 4th ... 11th, 12th, 13th ... 21st. Percentiles are the main caller and they range over 0..100, so blindly appending "th" would render "1th" and "22th".



284
285
286
287
288
289
290
291
292
# File 'lib/jirametrics/chart_base.rb', line 284

def ordinal number
  suffix =
    if [11, 12, 13].include?(number % 100)
      'th'
    else
      { 1 => 'st', 2 => 'nd', 3 => 'rd' }.fetch(number % 10, 'th')
    end
  "#{number}#{suffix}"
end

#percentile_of(values, percentile) ⇒ Object

The nearest rank percentile: the smallest value with at least this percentage of the data at or below it. One implementation for the whole product, because three call sites used to compute this separately and two of them overshot by one whenever the rank landed exactly on a boundary, so the same percentile of the same data could read differently on two charts. Returns nil when there is nothing to measure.



267
268
269
270
271
272
273
# File 'lib/jirametrics/chart_base.rb', line 267

def percentile_of values, percentile
  return nil if values.empty?

  sorted = values.sort
  rank = (percentile / 100.0 * sorted.size).ceil
  sorted[rank.clamp(1, sorted.size) - 1]
end

#render(caller_binding, file) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/jirametrics/chart_base.rb', line 53

def render caller_binding, file
  pathname = Pathname.new(File.realpath(file))
  basename = pathname.basename.to_s
  match = basename.match(/^(?<template_name>.+)\.rb$/)
  raise "Unexpected filename #{basename.inspect}" unless match

  # Insert a incrementing chart_id so that all the chart names on the page are unique
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3

  erb = ERB.new file_system.load "#{html_directory}/#{match[:template_name]}.erb"
  erb.result(caller_binding)
end

#render_axis_title(axis_direction) ⇒ Object



419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/jirametrics/chart_base.rb', line 419

def render_axis_title axis_direction
  text = case axis_direction
  when :x
    x_axis_title
  when :y
    y_axis_title
  else
    raise "Unexpected axis_direction: #{axis_direction}"
  end
  return '' unless text

  <<~CONTENT
    title: {
        display: true,
        text: "#{text}"
      },
  CONTENT
end

#render_top_text(caller_binding) ⇒ Object



66
67
68
69
70
71
# File 'lib/jirametrics/chart_base.rb', line 66

def render_top_text caller_binding
  result = +''
  result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
  result << ERB.new(@description_text).result(caller_binding) if @description_text
  result
end

#resolve_status(object, board:, use_old_status:) ⇒ Object

Resolves the object being formatted into [status, error_message]. A Status is itself; a ChangeItem is looked up by id, yielding an error message (the raw value) when the id isn't a known status.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/jirametrics/chart_base.rb', line 314

def resolve_status object, board:, use_old_status:
  case object
  when ChangeItem
    id = use_old_status ? object.old_value_id : object.value_id
    status = board.possible_statuses.find_by_id(id)
    return [status, nil] unless status.nil?

    [nil, use_old_status ? object.old_value : object.value]
  when Status
    [object, nil]
  else
    raise "Unexpected type: #{object.class}"
  end
end

#stagger_label_positions(datetimes) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
# File 'lib/jirametrics/chart_base.rb', line 195

def stagger_label_positions datetimes
  return [] if datetimes.empty?

  threshold_days = (date_range.end - date_range.begin).to_f / 5.0
  slot = 0
  [LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
    days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
    slot = days_apart < threshold_days ? slot + 1 : 0
    LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
  end
end

#status_category_color(status) ⇒ Object



350
351
352
353
354
355
356
357
# File 'lib/jirametrics/chart_base.rb', line 350

def status_category_color status
  case status.category.key
  when 'new' then CssVariable['--status-category-todo-color']
  when 'indeterminate' then CssVariable['--status-category-inprogress-color']
  when 'done' then CssVariable['--status-category-done-color']
  else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
  end
end

#to_human_readable(number) ⇒ Object



99
100
101
# File 'lib/jirametrics/chart_base.rb', line 99

def to_human_readable number
  number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
end

#working_days_annotationObject



151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/jirametrics/chart_base.rb', line 151

def working_days_annotation
  holidays.each_with_index.collect do |range, index|
    <<~TEXT
      holiday#{index}: {
        drawTime: 'beforeDraw',
        type: 'box',
        xMin: '#{range.begin}T00:00:00',
        xMax: '#{range.end}T23:59:59',
        backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
        borderColor: #{CssVariable.new('--non-working-days-color').to_json}
      },
    TEXT
  end.join
end

#wrap_and_render(caller_binding, file) ⇒ Object

Render the file and then wrap it with standard headers and quality checks.



74
75
76
77
78
79
# File 'lib/jirametrics/chart_base.rb', line 74

def wrap_and_render caller_binding, file
  result = +''
  result << render_top_text(caller_binding)
  result << render(caller_binding, file)
  result
end