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

#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



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

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

#canvas_responsive?Boolean

Returns:

  • (Boolean)


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

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



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

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] ||= random_color
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.



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

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.



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

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



353
354
355
356
357
358
359
360
# File 'lib/jirametrics/chart_base.rb', line 353

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"



263
264
265
# File 'lib/jirametrics/chart_base.rb', line 263

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



270
271
272
273
274
275
276
277
278
# File 'lib/jirametrics/chart_base.rb', line 270

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



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

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

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



298
299
300
301
302
303
304
305
# File 'lib/jirametrics/chart_base.rb', line 298

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



311
312
313
314
315
316
# File 'lib/jirametrics/chart_base.rb', line 311

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

#random_colorObject



327
328
329
330
# File 'lib/jirametrics/chart_base.rb', line 327

def random_color
  @palette_index = (@palette_index || -1) + 1
  OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
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



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/jirametrics/chart_base.rb', line 385

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.



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

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



318
319
320
321
322
323
324
325
# File 'lib/jirametrics/chart_base.rb', line 318

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