Class: ChartBase

Inherits:
Object
  • Object
show all
Includes:
ChartFormat
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

Methods included from ChartFormat

#chart_format

Constructor Details

#initializeChartBase

Returns a new instance of ChartBase.



29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/jirametrics/chart_base.rb', line 29

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.



24
25
26
# File 'lib/jirametrics/chart_base.rb', line 24

def aggregated_project=(value)
  @aggregated_project = value
end

#all_boardsObject

Returns the value of attribute all_boards.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def all_boards
  @all_boards
end

#atlassian_document_formatObject

Returns the value of attribute atlassian_document_format.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def atlassian_document_format
  @atlassian_document_format
end

#board_idObject

Returns the value of attribute board_id.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def board_id
  @board_id
end

#canvas_heightObject (readonly)

Returns the value of attribute canvas_height.



25
26
27
# File 'lib/jirametrics/chart_base.rb', line 25

def canvas_height
  @canvas_height
end

#canvas_widthObject (readonly)

Returns the value of attribute canvas_width.



25
26
27
# File 'lib/jirametrics/chart_base.rb', line 25

def canvas_width
  @canvas_width
end

#color_paletteObject

Returns the value of attribute color_palette.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def color_palette
  @color_palette
end

#data_qualityObject

Returns the value of attribute data_quality.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def data_quality
  @data_quality
end

#date_rangeObject

Returns the value of attribute date_range.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def date_range
  @date_range
end

#file_systemObject

Returns the value of attribute file_system.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def file_system
  @file_system
end

#fix_versionsObject

Returns the value of attribute fix_versions.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def fix_versions
  @fix_versions
end

#holiday_datesObject

Returns the value of attribute holiday_dates.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def holiday_dates
  @holiday_dates
end

#issuesObject

Returns the value of attribute issues.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def issues
  @issues
end

#settingsObject

Returns the value of attribute settings.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def settings
  @settings
end

#time_rangeObject

Returns the value of attribute time_range.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def time_range
  @time_range
end

#timezone_offsetObject

Returns the value of attribute timezone_offset.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def timezone_offset
  @timezone_offset
end

#x_axis_titleObject

Returns the value of attribute x_axis_title.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def x_axis_title
  @x_axis_title
end

#y_axis_titleObject

Returns the value of attribute y_axis_title.



21
22
23
# File 'lib/jirametrics/chart_base.rb', line 21

def y_axis_title
  @y_axis_title
end

Instance Method Details

#aggregated_project?Boolean

Returns:

  • (Boolean)


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

def aggregated_project?
  @aggregated_project
end

#before_runObject



46
47
48
# File 'lib/jirametrics/chart_base.rb', line 46

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

#call_before_run(&proc) ⇒ Object



42
43
44
# File 'lib/jirametrics/chart_base.rb', line 42

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

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



395
396
397
398
399
# File 'lib/jirametrics/chart_base.rb', line 395

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

#canvas_responsive?Boolean

Returns:

  • (Boolean)


401
402
403
# File 'lib/jirametrics/chart_base.rb', line 401

def canvas_responsive?
  @canvas_responsive
end

#collapsible_issues_panel(issue_descriptions, *args) ⇒ Object



162
163
164
165
166
167
168
169
# File 'lib/jirametrics/chart_base.rb', line 162

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



405
406
407
408
409
410
411
412
413
414
# File 'lib/jirametrics/chart_base.rb', line 405

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



118
119
120
# File 'lib/jirametrics/chart_base.rb', line 118

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.



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

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



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

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.



250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/jirametrics/chart_base.rb', line 250

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.



426
427
428
429
430
431
432
433
# File 'lib/jirametrics/chart_base.rb', line 426

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.



436
437
438
# File 'lib/jirametrics/chart_base.rb', line 436

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

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



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/jirametrics/chart_base.rb', line 136

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



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/jirametrics/chart_base.rb', line 201

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



416
417
418
419
420
421
422
423
# File 'lib/jirametrics/chart_base.rb', line 416

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



286
287
288
289
# File 'lib/jirametrics/chart_base.rb', line 286

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

#expand_template(text) ⇒ Object



78
79
80
# File 'lib/jirametrics/chart_base.rb', line 78

def expand_template text
  ERB.new(text).result(render_binding)
end

#expanded_header_textObject

The header as the reader sees it. Stitch configs match on this through grab_by_title, so it has to be the expanded text rather than the template.



84
# File 'lib/jirametrics/chart_base.rb', line 84

def expanded_header_text = @header_text.nil? ? '' : expand_template(@header_text)

#format_integer(number) ⇒ Object

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



324
325
326
# File 'lib/jirametrics/chart_base.rb', line 324

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



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

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



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

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.



173
174
175
176
177
178
# File 'lib/jirametrics/chart_base.rb', line 173

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



54
55
56
57
# File 'lib/jirametrics/chart_base.rb', line 54

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

#icon_span(title:, icon:) ⇒ Object



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

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


154
155
156
157
158
159
160
# File 'lib/jirametrics/chart_base.rb', line 154

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



114
115
116
# File 'lib/jirametrics/chart_base.rb', line 114

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.



391
392
393
# File 'lib/jirametrics/chart_base.rb', line 391

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

#no_data_text(text = :none) ⇒ Object



281
282
283
284
# File 'lib/jirametrics/chart_base.rb', line 281

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

#non_working_day?(date) ⇒ Boolean

Returns:

  • (Boolean)


180
181
182
# File 'lib/jirametrics/chart_base.rb', line 180

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

#normalize_annotation_datetime(value) ⇒ Object



240
241
242
243
244
245
246
247
# File 'lib/jirametrics/chart_base.rb', line 240

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.



359
360
361
362
363
364
365
366
# File 'lib/jirametrics/chart_base.rb', line 359

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



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

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".



313
314
315
316
317
318
319
320
321
# File 'lib/jirametrics/chart_base.rb', line 313

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.



296
297
298
299
300
301
302
# File 'lib/jirametrics/chart_base.rb', line 296

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



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/jirametrics/chart_base.rb', line 59

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



448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'lib/jirametrics/chart_base.rb', line 448

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_bindingObject

A fresh scope for every template expansion. self is still the chart, so instance variables and methods are reachable from a template, but the caller's local variables are not. That is the point: ERB#result assigns its output buffer into whatever binding you hand it, so two templates sharing one scope silently discard each other's output when one renders the other.



76
# File 'lib/jirametrics/chart_base.rb', line 76

def render_binding = binding

#render_headerObject



86
87
88
89
90
# File 'lib/jirametrics/chart_base.rb', line 86

def render_header
  return '' unless @header_text

  "<h1 class='foldable'>#{expanded_header_text}</h1>"
end

#render_no_dataObject

What a chart shows when it has nothing to show. Empty means show nothing at all, not even the header, which is what a chart that simply does not apply to this board wants.



100
101
102
103
104
# File 'lib/jirametrics/chart_base.rb', line 100

def render_no_data
  return '' if @no_data_text.nil? || @no_data_text.empty?

  expand_template @no_data_text
end

#render_top_textObject



92
93
94
95
96
# File 'lib/jirametrics/chart_base.rb', line 92

def render_top_text
  result = +render_header
  result << expand_template(@description_text) 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.



343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/jirametrics/chart_base.rb', line 343

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



228
229
230
231
232
233
234
235
236
237
238
# File 'lib/jirametrics/chart_base.rb', line 228

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



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

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



132
133
134
# File 'lib/jirametrics/chart_base.rb', line 132

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

#working_days_annotationObject



184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/jirametrics/chart_base.rb', line 184

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.



107
108
109
110
111
112
# File 'lib/jirametrics/chart_base.rb', line 107

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