Class: TimeBasedScatterplot

Inherits:
TimeBasedChart show all
Includes:
GroupableIssueChart, PercentileValidation
Defined in:
lib/jirametrics/time_based_scatterplot.rb

Constant Summary collapse

OVERALL_LABEL =

What the whole-data-set percentile lines call themselves when you hover them. They have no legend entry, so without this there is nothing identifying them at all. "items" rather than "data" or "everything" because it matches the description prose and because anything a grouping rule ignored has already been dropped by the time these lines are calculated.

'All items'

Constants inherited from TimeBasedChart

TimeBasedChart::VALUE_AXIS_LABELS

Constants inherited from ChartBase

ChartBase::LABEL_POSITIONS, ChartBase::OKABE_ITO_PALETTE

Instance Attribute Summary collapse

Attributes included from GroupableIssueChart

#group_by_block, #issue_hints, #issue_periods

Attributes inherited from ChartBase

#aggregated_project, #all_boards, #atlassian_document_format, #board_id, #canvas_height, #canvas_width, #color_palette, #data_quality, #date_range, #file_system, #fix_versions, #holiday_dates, #issues, #settings, #time_range, #timezone_offset, #x_axis_title, #y_axis_title

Instance Method Summary collapse

Methods included from PercentileValidation

validate_percentile, validate_percentiles

Methods included from GroupableIssueChart

#accumulate_issue_for_group, #group_issues, #grouping_rules, #init_configuration_block, #reconcile_percentiles

Methods inherited from TimeBasedChart

#cycletime_unit, #duration_in_unit, #label_cycletime

Methods inherited from ChartBase

#aggregated_project?, #before_run, #call_before_run, #canvas, #canvas_responsive?, #chart_format, #collapsible_issues_panel, #color_block, #color_for, #comma_and, #completed_issues_in_range, #current_board, #cycletime, #cycletime_for_issue, #daily_chart_dataset, #date_annotation, #describe_non_working_days, #description_text, #format_integer, #format_status, #header_text, #holidays, #html_directory, #icon_span, #link_to_issue, #next_id, #next_palette_color, #non_working_day?, #normalize_annotation_datetime, #not_visible_icon, #not_visible_text, #ordinal, #percentile_of, #render, #render_axis_title, #render_top_text, #resolve_status, #stagger_label_positions, #status_category_color, #to_human_readable, #working_days_annotation, #wrap_and_render

Constructor Details

#initializeTimeBasedScatterplot

Returns a new instance of TimeBasedScatterplot.



22
23
24
25
26
27
28
# File 'lib/jirametrics/time_based_scatterplot.rb', line 22

def initialize
  super

  @percentage_lines = []
  @highest_y_value = 0
  @percentiles = [85]
end

Instance Attribute Details

#percentage_linesObject (readonly)

percentage_lines is internal, not part of the documented config DSL. It exists so that specs and the ERB can see the computed lines without reaching into instance variables. Its shape, including the :id strings that encode positional group indices, is free to change.



20
21
22
# File 'lib/jirametrics/time_based_scatterplot.rb', line 20

def percentage_lines
  @percentage_lines
end

#y_axis_cap_percentileObject (readonly)

percentage_lines is internal, not part of the documented config DSL. It exists so that specs and the ERB can see the computed lines without reaching into instance variables. Its shape, including the :id strings that encode positional group indices, is free to change.



20
21
22
# File 'lib/jirametrics/time_based_scatterplot.rb', line 20

def y_axis_cap_percentile
  @y_axis_cap_percentile
end

Instance Method Details

#cap_y_axis(percentile: 98) ⇒ Object



35
36
37
# File 'lib/jirametrics/time_based_scatterplot.rb', line 35

def cap_y_axis percentile: 98
  @y_axis_cap_percentile = percentile
end

#compute_cap(items) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/jirametrics/time_based_scatterplot.rb', line 217

def compute_cap items
  return nil unless @y_axis_cap_percentile

  cutoff = percentile_value items, @y_axis_cap_percentile
  return nil unless cutoff

  values = filtered_values(items)
  outlier_count = values.count { |value| value > cutoff }
  return nil if outlier_count.zero?

  pad = cutoff * 0.06        # breathing room so the top real dot does not touch the break
  gutter_height = cutoff * 0.15
  sep = cutoff + pad
  {
    cutoff: cutoff,
    sep: sep,
    pin_row: sep + (gutter_height * 0.55),
    axis_max: (sep + gutter_height).ceil,
    outlier_count: outlier_count,
    label: cap_label(outlier_count: outlier_count, cutoff: cutoff)
  }
end

#create_datasets(items) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/jirametrics/time_based_scatterplot.rb', line 67

def create_datasets items
  @cap = compute_cap items
  data_sets = []

  group_issues(items).each_with_index do |(rules, items_by_type), group_index|
    label = rules.label
    color = rules.color
    lines = percentile_lines_for items_by_type, (rules.percentiles || @percentiles)
    data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }

    # Where this group's scatter set is about to land. The legend handler knows the clicked
    # dataset by index, so that's what the annotation map is keyed by.
    dataset_index = data_sets.size
    data_sets << {
      label: percentile_label(label, lines),
      data: data,
      fill: false,
      showLine: false,
      backgroundColor: color
    }

    data_sets << trend_line_data_set(label: label, data: data, color: color)

    lines.each do |percentile, value|
      @percentage_lines << {
        percentile: percentile, value: value, color: color,
        id: "group#{group_index}_#{percentile}", dataset_index: dataset_index, label: label
      }
    end
  end
  data_sets
end

#data_for_item(item, rules: nil) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/jirametrics/time_based_scatterplot.rb', line 181

def data_for_item item, rules: nil
  y = y_value(item)
  min = minimum_y_value
  return nil if min && y < min

  over = @cap && y > @cap[:cutoff]
  plotted_y = over ? @cap[:pin_row] : y
  @highest_y_value = plotted_y if @highest_y_value < plotted_y

  point = {
    y: plotted_y,
    x: chart_format(x_value(item)),
    title: [title_value(item, rules: rules)]
  }
  if over
    point[:over] = true
    point[:true_y] = y
  end
  point
end

#legend_annotation_mapObject

Dataset index to the annotation ids belonging to that dataset's group, so the legend handler can toggle all of a group's lines. Keyed by index rather than by label because two groups may legitimately share a label while differing in colour, and keying by label would then toggle both of them at once. Overall lines are deliberately absent; they are not owned by any group and stay visible when a group is switched off.



142
143
144
145
146
# File 'lib/jirametrics/time_based_scatterplot.rb', line 142

def legend_annotation_map
  @percentage_lines.reject { |line| line[:dataset_index].nil? }
    .group_by { |line| line[:dataset_index] }
    .transform_values { |lines| lines.collect { |line| line[:id] } }
end

#minimum_y_valueObject



177
178
179
# File 'lib/jirametrics/time_based_scatterplot.rb', line 177

def minimum_y_value
  nil
end

#percentile_label(label, lines) ⇒ Object

"Story (85% at 81 days)" for one, comma separated for several, bare label for none.



101
102
103
104
105
106
# File 'lib/jirametrics/time_based_scatterplot.rb', line 101

def percentile_label label, lines
  return label if lines.empty?

  parts = lines.collect { |percentile, value| "#{percentile}% at #{label_days value}" }
  "#{label} (#{parts.join ', '})"
end

#percentile_lines_for(items, percentiles) ⇒ Object

Returns [[percentile, value], ...] for the requested percentiles, sorted ascending by percentile and dropping any that have no value because the item list is empty after filtering. Sorting happens here, not in the caller, because GroupingRules#percentiles is user-assigned with no ordering guarantee.



206
207
208
209
210
211
# File 'lib/jirametrics/time_based_scatterplot.rb', line 206

def percentile_lines_for items, percentiles
  percentiles.sort.filter_map do |percentile|
    value = percentile_value items, percentile
    [percentile, value] unless value.nil?
  end
end

#percentile_value(items, percentile) ⇒ Object



213
214
215
# File 'lib/jirametrics/time_based_scatterplot.rb', line 213

def percentile_value items, percentile
  percentile_of filtered_values(items), percentile
end

#percentiles(list = nil) ⇒ Object

Percentile reference lines. The chart level value defines the lines drawn across the whole data set AND the default for each group; a group can override with rule.percentiles. An empty list switches the lines off.



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

def percentiles list = nil
  @percentiles = validate_percentiles(list) unless list.nil?
  @percentiles
end

#runObject



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/jirametrics/time_based_scatterplot.rb', line 47

def run
  items = all_items
  data_sets = create_datasets items
  overall_color = CssVariable['--cycletime-scatterplot-overall-trendline-color']

  percentile_lines_for(items, @percentiles).each do |percentile, value|
    @percentage_lines << {
      percentile: percentile, value: value, color: overall_color,
      id: "overall_#{percentile}", dataset_index: nil, label: OVERALL_LABEL
    }
  end

  if data_sets.empty?
    return "<h1 class='foldable'>#{@header_text}</h1>" \
      '<div>No data matched the selected criteria. Nothing to show.</div>'
  end

  wrap_and_render(binding, __FILE__)
end

#show_trend_linesObject



108
109
110
# File 'lib/jirametrics/time_based_scatterplot.rb', line 108

def show_trend_lines
  @show_trend_lines = true
end

#trend_line_data_set(label:, data:, color:) ⇒ Object



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/jirametrics/time_based_scatterplot.rb', line 148

def trend_line_data_set label:, data:, color:
  points = data.collect do |hash|
    [Time.parse(hash[:x]).to_i, hash[:true_y] || hash[:y]]
  end

  # The trend calculation works with numbers only so convert Time to an int and back
  calculator = TrendLineCalculator.new(points)
  data_points = calculator.chart_datapoints(
    range: time_range.begin.to_i..time_range.end.to_i,
    max_y: (@cap ? @cap[:cutoff] : @highest_y_value)
  )
  data_points.each do |point_hash|
    point_hash[:x] = chart_format Time.at(point_hash[:x])
  end

  {
    type: 'line',
    label: "#{label} Trendline",
    data: data_points,
    fill: false,
    borderWidth: 1,
    markerType: 'none',
    borderColor: color,
    borderDash: [6, 3],
    pointStyle: 'dash',
    hidden: !@show_trend_lines
  }
end

#trend_line_descriptionObject

The lines are always built but drawn hidden unless asked for, so this stays empty until they are actually switched on. Note the caller must be the ERB tag <%= trend_line_description %>: description_text is built during initialize, before the config block has called show_trend_lines, so interpolation would freeze "off" in permanently.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/jirametrics/time_based_scatterplot.rb', line 116

def trend_line_description
  return '' unless @show_trend_lines

  <<-HTML
    <div class="p">
      The dashed lines are trend lines, one per group in that group's colour. Each is a straight
      line fitted through that group's dots, so the slope tells you whether cycle times have been
      getting longer or shorter across the period shown. A line sloping up means work of that
      kind has been taking progressively longer to finish.
    </div>
    <div class="p">
      Read the slope as a description of this window rather than a prediction. A line is drawn
      whenever a group has at least three dots and nothing checks how well it actually fits
      them, so a scattered cloud with no real trend in it still gets a confident looking line.
      It is a straight line, so it cannot show a trend that changed direction partway through,
      and a handful of unusually long items will tilt it noticeably. If the slope surprises you,
      look at the dots before you believe it.
    </div>
  HTML
end

#value_axis_title=(title) ⇒ Object

On a scatterplot the cycle time is plotted up the y-axis.



31
32
33
# File 'lib/jirametrics/time_based_scatterplot.rb', line 31

def value_axis_title= title
  @y_axis_title = title
end