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 { |proc| proc.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



346
347
348
349
350
# File 'lib/jirametrics/chart_base.rb', line 346

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

#canvas_responsive?Boolean

Returns:

  • (Boolean)


352
353
354
# File 'lib/jirametrics/chart_base.rb', line 352

def canvas_responsive?
  @canvas_responsive
end

#chart_format(object) ⇒ Object



262
263
264
265
266
267
268
269
# File 'lib/jirametrics/chart_base.rb', line 262

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



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

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



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

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



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

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

#completed_issues_in_range(include_unstarted: false) ⇒ Object



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

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.



236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/jirametrics/chart_base.rb', line 236

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.



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

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.



387
388
389
# File 'lib/jirametrics/chart_base.rb', line 387

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

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



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/jirametrics/chart_base.rb', line 114

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



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/jirametrics/chart_base.rb', line 187

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



367
368
369
370
371
372
373
374
# File 'lib/jirametrics/chart_base.rb', line 367

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



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

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”



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

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



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/jirametrics/chart_base.rb', line 289

def format_status object, board:, is_category: false, use_old_status: false
  status = nil
  error_message = nil

  case object
  when ChangeItem
    id = use_old_status ? object.old_value_id : object.value_id
    status = board.possible_statuses.find_by_id(id)
    if status.nil?
      error_message = use_old_status ? object.old_value : object.value
    end
  when Status
    status = object
  else
    raise "Unexpected type: #{object.class}"
  end

  return "<span style='color: red'>#{error_message}</span>" if error_message

  color = status_category_color status

  visibility = ''
  if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
    visibility = icon_span(
      title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
      icon: ' 👀'
    )
  end
  text = is_category ? status.category : status
  "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
end

#header_text(text = :none) ⇒ Object



271
272
273
274
# File 'lib/jirametrics/chart_base.rb', line 271

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

#holidays(date_range: @date_range) ⇒ Object



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

def holidays date_range: @date_range
  result = []
  start_date = nil
  end_date = nil

  date_range.each do |date|
    if date.saturday? || date.sunday? || holiday_dates.include?(date)
      if start_date.nil?
        start_date = date
      else
        end_date = date
      end
    elsif start_date
      result << (start_date..(end_date || start_date))
      start_date = nil
      end_date = nil
    end
  end
  result
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



321
322
323
# File 'lib/jirametrics/chart_base.rb', line 321

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

#label_days(days) ⇒ Object



88
89
90
91
92
# File 'lib/jirametrics/chart_base.rb', line 88

def label_days days
  return 'unknown' if days.nil?

  "#{days} day#{'s' unless days == 1}"
end

#label_hours(hours) ⇒ Object



94
95
96
97
98
# File 'lib/jirametrics/chart_base.rb', line 94

def label_hours hours
  return 'unknown' if hours.nil?

  "#{hours} hour#{'s' unless hours == 1}"
end

#label_issues(count) ⇒ Object



106
107
108
# File 'lib/jirametrics/chart_base.rb', line 106

def label_issues count
  "#{count} issue#{'s' unless count == 1}"
end

#label_minutes(minutes) ⇒ Object



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

def label_minutes minutes
  return 'unknown' if minutes.nil?

  "#{minutes} minute#{'s' unless minutes == 1}"
end


132
133
134
135
136
137
138
# File 'lib/jirametrics/chart_base.rb', line 132

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



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

def next_id
  @@chart_counter += 1
end

#normalize_annotation_datetime(value) ⇒ Object



226
227
228
229
230
231
232
233
# File 'lib/jirametrics/chart_base.rb', line 226

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_text(issue) ⇒ Object



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

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



341
342
343
344
# File 'lib/jirametrics/chart_base.rb', line 341

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
# File 'lib/jirametrics/chart_base.rb', line 53

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

  # 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}/#{$1}.erb"
  erb.result(caller_binding)
end

#render_axis_title(axis_direction) ⇒ Object



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/jirametrics/chart_base.rb', line 399

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



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

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

#seam_end(type = 'chart') ⇒ Object



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

def seam_end type = 'chart'
  "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
end

#seam_start(type = 'chart') ⇒ Object



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

def seam_start type = 'chart'
  "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
end

#stagger_label_positions(datetimes) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
# File 'lib/jirametrics/chart_base.rb', line 214

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



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

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



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

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

#working_days_annotationObject



170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/jirametrics/chart_base.rb', line 170

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.



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

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