jirametrics 2.25pre7 → 2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98bcc110a47cd8dfe55eafb58b699d27604f2fb8d24a7ed357e6dd970998af8d
4
- data.tar.gz: 2501f1064f33999d31ce42a1dcdf29fd023fb5b60fc06f01294d064d60210fc4
3
+ metadata.gz: 615528aa77577881d7658b0ce059e9e3972ce4285aeb3c1081c4347dd1a20ca4
4
+ data.tar.gz: 483cc9b7535da95ca2249813e5ad8ff924334f65c8b7fa84c5705367a9d1b147
5
5
  SHA512:
6
- metadata.gz: 0761c74f71511217ed38496578d2863df6e5c1fe38d2932fd6fa3c1fed6dd626f5bcbc8bc015ce06aa91d098c55dbff5f1944daba386e39cdb9319f66d658cee
7
- data.tar.gz: b3657601410caceb3b186994785609f55d67f80094c945cd34203f68ff8dcdd494ff3d51375498071cfe0f316bf69aef38eac75e3a0e3acfb1462c8b797dc5cd
6
+ metadata.gz: 36acebeef4d036c6ed043f0073177944c390d5c80ba6ef162319f6a9bbb67bb2762ca4a345f4160d0f1d3fdc7047e257474920c09da9c5770c8a0e45a4748e59
7
+ data.tar.gz: 2c6f92cdb1d61b49ed7b318bc5c2108649ad387e9af51c312ade43f1522f3cc842dc949dde7d0373d41dc25630bbf9e5a3f883fc9255423ecd6e19c9008b3a4f
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CfdDataBuilder
4
+ def initialize board:, issues:, date_range:, columns: nil
5
+ @board = board
6
+ @issues = issues
7
+ @date_range = date_range
8
+ @columns = columns || board.visible_columns
9
+ end
10
+
11
+ def run
12
+ column_map = build_column_map
13
+ issue_states = @issues.map { |issue| process_issue(issue, column_map) }
14
+
15
+ {
16
+ columns: @columns.map(&:name),
17
+ daily_counts: build_daily_counts(issue_states),
18
+ correction_windows: issue_states.flat_map { |s| s[:correction_windows] }
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def build_column_map
25
+ map = {}
26
+ @columns.each_with_index do |column, index|
27
+ column.status_ids.each { |id| map[id] = index }
28
+ end
29
+ map
30
+ end
31
+
32
+ # Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
33
+ def process_issue issue, column_map
34
+ high_water_mark = nil
35
+ correction_open_since = nil
36
+ correction_windows = []
37
+ hwm_timeline = [] # sorted chronologically by date
38
+
39
+ issue.status_changes.each do |change|
40
+ col_index = column_map[change.value_id]
41
+ next if col_index.nil?
42
+
43
+ if high_water_mark.nil? || col_index > high_water_mark
44
+ # Forward movement: advance hwm, close any open correction window, record timeline entry
45
+ if correction_open_since
46
+ correction_windows << {
47
+ start_date: correction_open_since,
48
+ end_date: change.time.to_date,
49
+ column_index: high_water_mark
50
+ }
51
+ correction_open_since = nil
52
+ end
53
+ high_water_mark = col_index
54
+ hwm_timeline << [change.time.to_date, high_water_mark]
55
+ elsif col_index == high_water_mark && correction_open_since
56
+ # Same-column recovery: close the correction window without changing hwm or adding timeline entry
57
+ correction_windows << {
58
+ start_date: correction_open_since,
59
+ end_date: change.time.to_date,
60
+ column_index: high_water_mark
61
+ }
62
+ correction_open_since = nil
63
+ elsif col_index < high_water_mark
64
+ # Backwards movement: open correction window if not already open
65
+ correction_open_since ||= change.time.to_date
66
+ end
67
+ end
68
+
69
+ if correction_open_since
70
+ correction_windows << {
71
+ start_date: correction_open_since,
72
+ end_date: @date_range.end,
73
+ column_index: high_water_mark
74
+ }
75
+ end
76
+
77
+ { hwm_timeline: hwm_timeline, correction_windows: correction_windows }
78
+ end
79
+
80
+ def hwm_at hwm_timeline, date
81
+ result = nil
82
+ hwm_timeline.each do |timeline_date, hwm|
83
+ break if timeline_date > date
84
+
85
+ result = hwm
86
+ end
87
+ result
88
+ end
89
+
90
+ def build_daily_counts issue_states
91
+ column_count = @columns.size
92
+ @date_range.each_with_object({}) do |date, result|
93
+ counts = Array.new(column_count, 0)
94
+ issue_states.each do |state|
95
+ hwm = hwm_at(state[:hwm_timeline], date)
96
+ next if hwm.nil?
97
+
98
+ (0..hwm).each { |i| counts[i] += 1 }
99
+ end
100
+ result[date] = counts
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/cfd_data_builder'
4
+
5
+ class CumulativeFlowDiagram < ChartBase
6
+ # Used to embed a Chart.js segment callback (which contains JS functions) into
7
+ # a JSON-like dataset object. The custom to_json emits raw JS rather than a
8
+ # quoted string, following the same pattern as ExpeditedChart::EXPEDITED_SEGMENT.
9
+ class Segment
10
+ def initialize windows
11
+ # Build a JS array literal of [start_date, end_date] string pairs
12
+ @windows_js = windows
13
+ .map { |w| "[#{w[:start_date].to_json}, #{w[:end_date].to_json}]" }
14
+ .join(', ')
15
+ end
16
+
17
+ def to_json *_args
18
+ <<~JS
19
+ {
20
+ borderDash: function(ctx) {
21
+ const x = ctx.p1.parsed.x;
22
+ const windows = [#{@windows_js}];
23
+ return windows.some(function(w) {
24
+ return x >= new Date(w[0]).getTime() && x <= new Date(w[1]).getTime();
25
+ }) ? [6, 4] : undefined;
26
+ }
27
+ }
28
+ JS
29
+ end
30
+ end
31
+ private_constant :Segment
32
+
33
+ class CfdColumnRules < Rules
34
+ attr_accessor :color, :label, :label_hint
35
+ end
36
+ private_constant :CfdColumnRules
37
+
38
+ def initialize block
39
+ super()
40
+ header_text 'Cumulative Flow Diagram'
41
+ description_text <<~HTML
42
+ <div class="p">
43
+ A Cumulative Flow Diagram (CFD) shows how work accumulates across board columns over time.
44
+ Each coloured band represents a workflow stage. The top edge of the leftmost band shows
45
+ total work entered; the top edge of the rightmost band shows total work completed.
46
+ </div>
47
+ <div class="p">
48
+ A widening band means work is piling up in that stage — a bottleneck. Parallel top edges
49
+ (bands staying the same width) indicate smooth flow. Steep rises in the leftmost band
50
+ without corresponding rises on the right mean new work is arriving faster than it is
51
+ being finished.
52
+ </div>
53
+ <div class="p">
54
+ Dashed lines and hatched regions indicate periods where an item moved backwards through
55
+ the workflow (a correction). These highlight rework or process irregularities worth
56
+ investigating.
57
+ </div>
58
+ <div class="p">
59
+ The chart also overlays two trend lines and an interactive triangle. The <b>arrival rate</b>
60
+ trend line shows how fast work is entering the system; the <b>departure rate</b> trend line
61
+ shows how fast it is leaving. Move the mouse over the chart to see a Little's Law triangle
62
+ at that point in time, labelled with three derived metrics: <b>Work In Progress (WIP)</b> (items started
63
+ but not finished), <b>approximate average cycle time (CT)</b> (roughly how long an average item takes to complete), and
64
+ <b>average throughput (TP)</b> (items completed per day). Use the checkbox above the chart to toggle
65
+ between the triangle and the normal data tooltips.
66
+ </div>
67
+ HTML
68
+ instance_eval(&block)
69
+ end
70
+
71
+ def column_rules &block
72
+ @column_rules_block = block
73
+ end
74
+
75
+ def triangle_color color
76
+ @triangle_color = parse_theme_color(color)
77
+ end
78
+
79
+ def arrival_rate_line_color color
80
+ @arrival_rate_line_color = parse_theme_color(color)
81
+ end
82
+
83
+ def departure_rate_line_color color
84
+ @departure_rate_line_color = parse_theme_color(color)
85
+ end
86
+
87
+ def run
88
+ all_columns = current_board.visible_columns
89
+
90
+ column_rules_list = all_columns.map do |column|
91
+ rules = CfdColumnRules.new
92
+ @column_rules_block&.call(column, rules)
93
+ rules
94
+ end
95
+
96
+ active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
97
+ active_columns = active_pairs.map(&:first)
98
+ active_rules = active_pairs.map(&:last)
99
+
100
+ cfd = CfdDataBuilder.new(
101
+ board: current_board,
102
+ issues: issues,
103
+ date_range: date_range,
104
+ columns: active_columns
105
+ ).run
106
+
107
+ columns = cfd[:columns]
108
+ daily_counts = cfd[:daily_counts]
109
+ correction_windows = cfd[:correction_windows]
110
+ column_count = columns.size
111
+
112
+ # Convert cumulative totals to marginal band heights for Chart.js stacking.
113
+ # cumulative[i] = issues that reached column i or further.
114
+ # marginal[i] = cumulative[i] - cumulative[i+1] (last column: marginal = cumulative)
115
+ daily_marginals = daily_counts.transform_values do |cumulative|
116
+ cumulative.each_with_index.map do |count, i|
117
+ i < column_count - 1 ? count - cumulative[i + 1] : count
118
+ end
119
+ end
120
+
121
+ border_colors = active_rules.map { |rules| rules.color || random_color }
122
+
123
+ fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
124
+
125
+ # Datasets in reversed order: rightmost column first (bottom of stack), leftmost last (top).
126
+ data_sets = columns.each_with_index.map do |name, col_index|
127
+ col_windows = correction_windows
128
+ .select { |w| w[:column_index] == col_index }
129
+ .map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
130
+
131
+ {
132
+ label: active_rules[col_index].label || name,
133
+ label_hint: active_rules[col_index].label_hint,
134
+ data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
135
+ backgroundColor: fill_colors[col_index],
136
+ borderColor: border_colors[col_index],
137
+ fill: true,
138
+ tension: 0,
139
+ segment: Segment.new(col_windows)
140
+ }
141
+ end.reverse
142
+
143
+ # Correction windows for the afterDraw hatch plugin, with dataset index in
144
+ # Chart.js dataset array (reversed: done column = index 0).
145
+ hatch_windows = correction_windows.map do |w|
146
+ {
147
+ dataset_index: column_count - 1 - w[:column_index],
148
+ start_date: w[:start_date].to_s,
149
+ end_date: w[:end_date].to_s,
150
+ color: border_colors[w[:column_index]],
151
+ fill_color: fill_colors[w[:column_index]]
152
+ }
153
+ end
154
+
155
+ @triangle_color = parse_theme_color(['#333333', '#ffffff']) unless instance_variable_defined?(:@triangle_color)
156
+ unless instance_variable_defined?(:@arrival_rate_line_color)
157
+ @arrival_rate_line_color = 'rgba(255,138,101,0.85)'
158
+ end
159
+ unless instance_variable_defined?(:@departure_rate_line_color)
160
+ @departure_rate_line_color = 'rgba(128,203,196,0.85)'
161
+ end
162
+
163
+ wrap_and_render(binding, __FILE__)
164
+ end
165
+
166
+ private
167
+
168
+ def parse_theme_color color
169
+ return color unless color.is_a?(Array)
170
+
171
+ raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
172
+ raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
173
+
174
+ if color.any? { |c| c.start_with?('--') }
175
+ raise ArgumentError,
176
+ 'CSS variable references are not supported as color pair elements; use a literal color value instead'
177
+ end
178
+
179
+ light, dark = color
180
+ RawJavascript.new(
181
+ "(document.documentElement.dataset.theme === 'dark' || " \
182
+ '(!document.documentElement.dataset.theme && ' \
183
+ "window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
184
+ "? #{dark.to_json} : #{light.to_json}"
185
+ )
186
+ end
187
+
188
+ def hex_to_rgba hex, alpha
189
+ r, g, b = hex.delete_prefix('#').scan(/../).map { |c| c.to_i(16) }
190
+ "rgba(#{r}, #{g}, #{b}, #{alpha})"
191
+ end
192
+
193
+ def fill_color_for rules, border
194
+ if rules.color.nil? || rules.color.match?(/\A#[0-9a-fA-F]{6}\z/)
195
+ hex_to_rgba(border, 0.35)
196
+ else
197
+ rules.color
198
+ end
199
+ end
200
+ end
@@ -58,9 +58,8 @@ class Exporter
58
58
  html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
59
59
  type: :header
60
60
  end
61
-
62
61
  daily_view
63
-
62
+ cumulative_flow_diagram
64
63
  cycletime_scatterplot do
65
64
  show_trend_lines
66
65
  end
@@ -91,7 +90,6 @@ class Exporter
91
90
  flow_efficiency_scatterplot if show_experimental_charts
92
91
  sprint_burndown
93
92
  estimate_accuracy_chart
94
- expedited_chart
95
93
  dependency_chart
96
94
  end
97
95
  end
@@ -0,0 +1,504 @@
1
+ <%= seam_start %>
2
+ <div class="chart">
3
+ <label style="font-size:0.85em;display:block;text-align:right;margin-bottom:2px">
4
+ <input type="checkbox" id="<%= chart_id %>_triangle_toggle" checked>
5
+ Show flow metrics triangle
6
+ </label>
7
+ <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
8
+ </div>
9
+ <script>
10
+ if (!Chart.Tooltip.positioners.legendItem) {
11
+ Chart.Tooltip.positioners.legendItem = function(items) {
12
+ return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
13
+ };
14
+ }
15
+ (function() {
16
+ const hatchWindows = <%= hatch_windows.to_json %>;
17
+
18
+ // Custom plugin: draws diagonal hatching over correction windows in the affected band.
19
+ // Uses createDiagonalPattern() defined in index.js.
20
+ const cfdHatchPlugin = {
21
+ id: 'cfdHatch',
22
+ afterDatasetsDraw: function(chart) {
23
+ // Redraw each correction-window border line so that the gaps between
24
+ // dashes show the band fill colour rather than being transparent.
25
+ // Strategy: draw a solid line in the fill colour first, then the dashed
26
+ // border line on top. afterDraw (hatching) fires after this, so the
27
+ // hatch pattern still appears over both lines.
28
+ const ctx = chart.ctx;
29
+ const ca = chart.chartArea;
30
+ hatchWindows.forEach(function(win) {
31
+ const meta = chart.getDatasetMeta(win.dataset_index);
32
+ if (!meta || !meta.data.length) return;
33
+
34
+ const startX = chart.scales.x.getPixelForValue(new Date(win.start_date).getTime());
35
+ const endX = chart.scales.x.getPixelForValue(new Date(win.end_date).getTime());
36
+ const lw = chart.data.datasets[win.dataset_index].borderWidth || 2;
37
+ const points = meta.data.filter(function(p) { return p.x >= startX - 1 && p.x <= endX + 1; });
38
+ if (points.length < 2) return;
39
+
40
+ function drawSegment(color, dash) {
41
+ ctx.save();
42
+ ctx.beginPath();
43
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
44
+ ctx.clip();
45
+ ctx.strokeStyle = color;
46
+ ctx.setLineDash(dash);
47
+ ctx.lineWidth = lw;
48
+ ctx.beginPath();
49
+ ctx.moveTo(points[0].x, points[0].y);
50
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
51
+ ctx.stroke();
52
+ ctx.restore();
53
+ }
54
+
55
+ drawSegment(win.fill_color, []); // solid fill colour fills the gaps
56
+ drawSegment(win.color, [6, 4]); // dashed border colour on top
57
+ });
58
+ },
59
+ afterDraw: function(chart) {
60
+ const ctx = chart.ctx;
61
+ const ca = chart.chartArea;
62
+ hatchWindows.forEach(function(win) {
63
+ const meta = chart.getDatasetMeta(win.dataset_index);
64
+ if (!meta || !meta.data.length) return;
65
+
66
+ const startX = chart.scales.x.getPixelForValue(new Date(win.start_date).getTime());
67
+ const endX = chart.scales.x.getPixelForValue(new Date(win.end_date).getTime());
68
+
69
+ // Draw hatched slices over the correction window.
70
+ // For stacked line charts, PointElement has no .base — derive the band bottom from the
71
+ // dataset directly below in the visual stack (dataset_index - 1, since datasets are
72
+ // stored reversed), or chart.chartArea.bottom for the lowest dataset.
73
+ // Use a trapezoid clip path per slice so hatching stays within the actual band boundary
74
+ // even when band height changes between data points.
75
+ const belowMeta = win.dataset_index > 0 ? chart.getDatasetMeta(win.dataset_index - 1) : null;
76
+ meta.data.forEach(function(point, i) {
77
+ if (point.x < startX || point.x > endX) return;
78
+ const prev = i > 0 ? meta.data[i - 1] : null;
79
+ const sliceLeft = Math.max(prev ? prev.x : startX, startX);
80
+ const sliceRight = Math.min(point.x, endX);
81
+ if (sliceLeft >= sliceRight) return;
82
+
83
+ const topLeft = prev ? prev.y : point.y;
84
+ const topRight = point.y;
85
+ const bottomLeft = belowMeta && prev && belowMeta.data[i - 1]
86
+ ? belowMeta.data[i - 1].y : chart.chartArea.bottom;
87
+ const bottomRight = belowMeta && belowMeta.data[i]
88
+ ? belowMeta.data[i].y : chart.chartArea.bottom;
89
+
90
+ if (Math.min(topLeft, topRight) >= Math.max(bottomLeft, bottomRight)) return;
91
+
92
+ ctx.save();
93
+ ctx.beginPath();
94
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
95
+ ctx.clip();
96
+ ctx.beginPath();
97
+ ctx.moveTo(sliceLeft, topLeft);
98
+ ctx.lineTo(sliceRight, topRight);
99
+ ctx.lineTo(sliceRight, bottomRight);
100
+ ctx.lineTo(sliceLeft, bottomLeft);
101
+ ctx.closePath();
102
+ ctx.clip();
103
+ ctx.fillStyle = createDiagonalPattern(win.color);
104
+ ctx.fillRect(sliceLeft, Math.min(topLeft, topRight),
105
+ sliceRight - sliceLeft, Math.max(bottomLeft, bottomRight) - Math.min(topLeft, topRight));
106
+ ctx.restore();
107
+ });
108
+ });
109
+ }
110
+ };
111
+
112
+ const cfdFlowMetricsPlugin = (function () {
113
+ const triangleColor = <%= @triangle_color.to_json %>;
114
+ const arrivalColor = <%= @arrival_rate_line_color.nil? ? 'null' : @arrival_rate_line_color.to_json %>;
115
+ const departureColor = <%= @departure_rate_line_color.nil? ? 'null' : @departure_rate_line_color.to_json %>;
116
+ function buildArrays(chart) {
117
+ const ds = chart.data.datasets;
118
+ const n = ds[0].data.length;
119
+ const arrivals = [], departures = [];
120
+ for (let j = 0; j < n; j++) {
121
+ arrivals[j] = ds.reduce((s, d) => s + (d.data[j]?.y || 0), 0);
122
+ // ds[0] is the Done (rightmost) column after dataset reversal.
123
+ // Its marginal equals its cumulative count (no column to its right),
124
+ // so this directly gives total departures. Assumes Done is not ignored via column_rules.
125
+ departures[j] = ds[0].data[j]?.y || 0;
126
+ }
127
+ return { arrivals, departures };
128
+ }
129
+
130
+ function linearRegression(yValues) {
131
+ const n = yValues.length;
132
+ const sumX = n * (n - 1) / 2;
133
+ const sumX2 = n * (n - 1) * (2 * n - 1) / 6;
134
+ const sumY = yValues.reduce((s, y) => s + y, 0);
135
+ const sumXY = yValues.reduce((s, y, i) => s + i * y, 0);
136
+ const denom = n * sumX2 - sumX * sumX;
137
+ if (denom === 0) return { slope: 0, intercept: sumY / n };
138
+ const slope = (n * sumXY - sumX * sumY) / denom;
139
+ return { slope, intercept: (sumY - slope * sumX) / n };
140
+ }
141
+
142
+ function trendPixelY(chart, reg, dayIndex) {
143
+ return chart.scales.y.getPixelForValue(reg.slope * dayIndex + reg.intercept);
144
+ }
145
+
146
+ function drawTrendLines(chart, fm) {
147
+ const ctx = chart.ctx;
148
+ const ca = chart.chartArea;
149
+ const ds = chart.data.datasets;
150
+ const n = ds[0].data.length;
151
+ const x0 = chart.scales.x.getPixelForValue(new Date(ds[0].data[0].x).getTime());
152
+ const x1 = chart.scales.x.getPixelForValue(new Date(ds[0].data[n - 1].x).getTime());
153
+
154
+ function drawLine(reg, color) {
155
+ const y0 = trendPixelY(chart, reg, 0);
156
+ const y1 = trendPixelY(chart, reg, n - 1);
157
+ ctx.save();
158
+ ctx.beginPath();
159
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
160
+ ctx.clip();
161
+ ctx.setLineDash([6, 4]);
162
+ ctx.lineWidth = 1.5;
163
+ ctx.strokeStyle = color;
164
+ ctx.beginPath();
165
+ ctx.moveTo(x0, y0);
166
+ ctx.lineTo(x1, y1);
167
+ ctx.stroke();
168
+ ctx.restore();
169
+ }
170
+
171
+ if (arrivalColor !== null) drawLine(fm.arrivalReg, arrivalColor);
172
+ if (departureColor !== null) drawLine(fm.departureReg, departureColor);
173
+
174
+ // Edge labels (inside chart area, right-aligned to avoid canvas clipping)
175
+ if (arrivalColor !== null || departureColor !== null) {
176
+ ctx.save();
177
+ ctx.font = '10px sans-serif';
178
+ ctx.textAlign = 'right';
179
+ ctx.textBaseline = 'middle';
180
+ const labelX = ca.right - 4;
181
+ if (arrivalColor !== null) {
182
+ ctx.fillStyle = arrivalColor;
183
+ ctx.fillText('Arrivals', labelX, trendPixelY(chart, fm.arrivalReg, n - 1));
184
+ }
185
+ if (departureColor !== null) {
186
+ ctx.fillStyle = departureColor;
187
+ ctx.fillText('Departures', labelX, trendPixelY(chart, fm.departureReg, n - 1));
188
+ }
189
+ ctx.restore();
190
+ }
191
+ }
192
+
193
+ function bgLabel(ctx, text, cx, cy) {
194
+ ctx.save();
195
+ ctx.font = '11px sans-serif';
196
+ const w = ctx.measureText(text).width;
197
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
198
+ ctx.fillRect(cx - w / 2 - 3, cy - 9, w + 6, 14);
199
+ ctx.fillStyle = '#ffffff';
200
+ ctx.textAlign = 'center';
201
+ ctx.textBaseline = 'middle';
202
+ ctx.fillText(text, cx, cy - 2);
203
+ ctx.restore();
204
+ }
205
+
206
+ function drawTriangle(ctx, chart, fm) {
207
+ const ca = chart.chartArea;
208
+ const ds = chart.data.datasets;
209
+ const n = ds[0].data.length;
210
+ const { arrivals, departures, dates } = fm;
211
+
212
+ // Locate cursor data index j
213
+ const cursorMs = chart.scales.x.getValueForPixel(fm.mouseX);
214
+ const j = dates.reduce((best, t, i) =>
215
+ Math.abs(t - cursorMs) < Math.abs(dates[best] - cursorMs) ? i : best, 0);
216
+
217
+ const wip = arrivals[j] - departures[j];
218
+ if (wip === 0) return;
219
+
220
+ // Find j_c: first index > j where departures[k] >= arrivals[j] (fixed threshold)
221
+ const threshold = arrivals[j];
222
+ let j_c = -1;
223
+ for (let k = j + 1; k < n; k++) {
224
+ if (departures[k] >= threshold) { j_c = k; break; }
225
+ }
226
+
227
+ const xA = chart.scales.x.getPixelForValue(dates[j]);
228
+ // Use Chart.js's own rendered pixel positions so the triangle edges
229
+ // align exactly with the drawn band edges rather than relying on
230
+ // getPixelForValue(arrivals/departures[j]), which can differ slightly
231
+ // from the internal stacked totals Chart.js uses when drawing.
232
+ const yA = chart.getDatasetMeta(ds.length - 1).data[j].y; // top of full stack
233
+ const yB = chart.getDatasetMeta(0).data[j].y; // top of done band
234
+ const xC = j_c >= 0 ? chart.scales.x.getPixelForValue(dates[j_c]) : null;
235
+
236
+ ctx.save();
237
+ ctx.beginPath();
238
+ ctx.rect(ca.left, ca.top, ca.width, ca.height);
239
+ ctx.clip();
240
+
241
+ // Triangle fill (only when C is within range)
242
+ if (xC !== null) {
243
+ ctx.beginPath();
244
+ ctx.moveTo(xA, yA);
245
+ ctx.lineTo(xA, yB);
246
+ ctx.lineTo(xC, yA);
247
+ ctx.closePath();
248
+ ctx.fillStyle = 'rgba(255,255,255,0.06)';
249
+ ctx.fill();
250
+ }
251
+
252
+ // AB: vertical (WIP)
253
+ ctx.setLineDash([]);
254
+ ctx.lineWidth = 2;
255
+ ctx.strokeStyle = triangleColor;
256
+ ctx.beginPath();
257
+ ctx.moveTo(xA, yA);
258
+ ctx.lineTo(xA, yB);
259
+ ctx.stroke();
260
+
261
+ if (xC !== null) {
262
+ // AC: horizontal (cycle time)
263
+ ctx.beginPath();
264
+ ctx.moveTo(xA, yA);
265
+ ctx.lineTo(xC, yA);
266
+ ctx.stroke();
267
+ // BC: dashed hypotenuse (throughput)
268
+ ctx.setLineDash([4, 2]);
269
+ ctx.lineWidth = 1.5;
270
+ ctx.beginPath();
271
+ ctx.moveTo(xA, yB);
272
+ ctx.lineTo(xC, yA);
273
+ ctx.stroke();
274
+ } else {
275
+ // C outside range: dashed extension to right edge
276
+ ctx.setLineDash([4, 2]);
277
+ ctx.lineWidth = 1.5;
278
+ ctx.beginPath();
279
+ ctx.moveTo(xA, yA);
280
+ ctx.lineTo(ca.right, yA);
281
+ ctx.stroke();
282
+ }
283
+
284
+ ctx.restore();
285
+
286
+ // Labels
287
+ const abMidY = (yA + yB) / 2;
288
+ // WIP: right-aligned, left of AB
289
+ ctx.save();
290
+ ctx.font = '11px sans-serif';
291
+ const wipText = 'WIP: ' + wip;
292
+ const wipW = ctx.measureText(wipText).width;
293
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
294
+ ctx.fillRect(xA - wipW - 8, abMidY - 9, wipW + 6, 14);
295
+ ctx.fillStyle = '#ffffff';
296
+ ctx.textAlign = 'right';
297
+ ctx.textBaseline = 'middle';
298
+ ctx.fillText(wipText, xA - 5, abMidY - 2);
299
+ ctx.restore();
300
+
301
+ if (xC !== null) {
302
+ const cycleTime = j_c - j;
303
+ const throughput = (wip / cycleTime).toFixed(2);
304
+ bgLabel(ctx, '~CT: ' + cycleTime + ' days', (xA + xC) / 2, yA - 10);
305
+ bgLabel(ctx, '~TP: ' + throughput + '/day', (xA + xC) / 2, (yA + yB) / 2 + 12);
306
+ }
307
+ }
308
+
309
+ return {
310
+ id: 'cfdFlowMetrics',
311
+
312
+ afterInit(chart) {
313
+ const { arrivals, departures } = buildArrays(chart);
314
+ const dates = chart.data.datasets[0].data.map(d => new Date(d.x).getTime());
315
+ chart._flowMetrics = {
316
+ mouseX: null,
317
+ arrivals,
318
+ departures,
319
+ dates,
320
+ arrivalReg: linearRegression(arrivals),
321
+ departureReg: linearRegression(departures)
322
+ };
323
+ const fm = chart._flowMetrics;
324
+ const canvas = chart.canvas;
325
+
326
+ // The triangle is drawn on a separate overlay canvas using native DOM
327
+ // events, completely bypassing Chart.js's render/event cycle (which
328
+ // behaves unreliably in Safari). The triangle is hidden immediately on
329
+ // mouse move and redrawn after a short pause (debounce), preventing the
330
+ // browser from being overwhelmed by rapid redraws.
331
+ const dpr = window.devicePixelRatio || 1;
332
+ const parent = canvas.parentNode;
333
+ if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
334
+ // Read canvas.offsetTop/Left after parent is positioned so they are
335
+ // relative to parent — this accounts for the label/checkbox above the
336
+ // canvas that would otherwise shift the overlay upward.
337
+ const overlay = document.createElement('canvas');
338
+ overlay.width = canvas.width;
339
+ overlay.height = canvas.height;
340
+ overlay.style.cssText = 'position:absolute;top:' + canvas.offsetTop + 'px;' +
341
+ 'left:' + canvas.offsetLeft + 'px;pointer-events:none;' +
342
+ 'width:' + (canvas.style.width || (canvas.width / dpr) + 'px') + ';' +
343
+ 'height:' + (canvas.style.height || (canvas.height / dpr) + 'px') + ';';
344
+ parent.insertBefore(overlay, canvas.nextSibling);
345
+
346
+ const octx = overlay.getContext('2d');
347
+ octx.scale(dpr, dpr);
348
+ const cssW = canvas.width / dpr;
349
+ const cssH = canvas.height / dpr;
350
+ fm._overlay = overlay;
351
+
352
+ // Store overlay state on fm so afterDraw can reach it.
353
+ fm._overlayCtx = octx;
354
+ fm._cssW = cssW;
355
+ fm._cssH = cssH;
356
+ fm._triangleEnabled = true;
357
+ fm._rafId = null;
358
+
359
+ // Checkbox toggles between triangle mode and tooltip mode. They are
360
+ // mutually exclusive: in triangle mode, Chart.js event processing is
361
+ // disabled entirely so its internal hover/tooltip logic cannot interfere
362
+ // with the overlay canvas rAF (which hangs Safari at certain positions).
363
+ // Our native canvas mousemove handler is unaffected by this setting.
364
+ const defaultEvents = ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'];
365
+
366
+ function setTriangleMode(enabled) {
367
+ fm._triangleEnabled = enabled;
368
+ if (enabled) {
369
+ chart.options.events = [];
370
+ chart.options.plugins.tooltip.enabled = false;
371
+ } else {
372
+ chart.options.events = defaultEvents;
373
+ chart.options.plugins.tooltip.enabled = true;
374
+ octx.clearRect(0, 0, cssW, cssH);
375
+ }
376
+ chart.update('none');
377
+ }
378
+
379
+ const checkbox = document.getElementById('<%= chart_id %>_triangle_toggle');
380
+ if (checkbox) {
381
+ checkbox.addEventListener('change', function () { setTriangleMode(this.checked); });
382
+ }
383
+ // Apply initial triangle mode without triggering a redundant update
384
+ // (tooltip is already disabled in the chart config; just silence events).
385
+ chart.options.events = [];
386
+
387
+ function scheduleOverlayRedraw() {
388
+ if (fm._rafId !== null) return;
389
+ fm._rafId = requestAnimationFrame(function () {
390
+ fm._rafId = null;
391
+ if (fm._triangleEnabled) {
392
+ octx.clearRect(0, 0, cssW, cssH);
393
+ if (fm.mouseX !== null) drawTriangle(octx, chart, fm);
394
+ }
395
+ });
396
+ }
397
+
398
+ function onMouseMove(e) {
399
+ const rect = canvas.getBoundingClientRect();
400
+ const x = e.clientX - rect.left;
401
+ const y = e.clientY - rect.top;
402
+ const ca = chart.chartArea;
403
+ if (!ca) return;
404
+ fm.mouseX = (x >= ca.left && x <= ca.right && y >= ca.top && y <= ca.bottom) ? x : null;
405
+ scheduleOverlayRedraw();
406
+ }
407
+
408
+ function onMouseLeave() {
409
+ fm.mouseX = null;
410
+ scheduleOverlayRedraw();
411
+ }
412
+
413
+ canvas.addEventListener('mousemove', onMouseMove);
414
+ canvas.addEventListener('mouseleave', onMouseLeave);
415
+ fm._onMouseMove = onMouseMove;
416
+ fm._onMouseLeave = onMouseLeave;
417
+ },
418
+
419
+ destroy(chart) {
420
+ const fm = chart._flowMetrics;
421
+ if (!fm) return;
422
+ chart.canvas.removeEventListener('mousemove', fm._onMouseMove);
423
+ chart.canvas.removeEventListener('mouseleave', fm._onMouseLeave);
424
+ if (fm._overlay && fm._overlay.parentNode) fm._overlay.parentNode.removeChild(fm._overlay);
425
+ },
426
+
427
+ afterDraw(chart) {
428
+ const fm = chart._flowMetrics;
429
+ drawTrendLines(chart, fm);
430
+ // Triangle is on the overlay canvas — no drawing needed here.
431
+ }
432
+ };
433
+ })();
434
+
435
+ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
436
+ type: 'line',
437
+ plugins: [cfdHatchPlugin, cfdFlowMetricsPlugin],
438
+ data: {
439
+ datasets: <%= JSON.generate(data_sets) %>
440
+ },
441
+ options: {
442
+ responsive: <%= canvas_responsive? %>,
443
+ scales: {
444
+ x: {
445
+ type: 'time',
446
+ time: { format: 'YYYY-MM-DD' },
447
+ min: "<%= date_range.begin.to_s %>",
448
+ max: "<%= (date_range.end + 1).to_s %>",
449
+ grid: { color: <%= CssVariable['--grid-line-color'].to_json %> }
450
+ },
451
+ y: {
452
+ stacked: true,
453
+ min: 0,
454
+ title: { display: true, text: 'Number of items' },
455
+ grid: { color: <%= CssVariable['--grid-line-color'].to_json %> }
456
+ }
457
+ },
458
+ elements: {
459
+ point: { radius: 0 }
460
+ },
461
+ plugins: {
462
+ tooltip: {
463
+ enabled: false,
464
+ position: 'legendItem',
465
+ callbacks: {
466
+ title: function(contexts) {
467
+ if (contexts[0]?.chart._legendHoverIndex != null) return '';
468
+ },
469
+ label: function(context) {
470
+ if (context.chart._legendHoverIndex != null) {
471
+ return context.dataset.label_hint || '';
472
+ }
473
+ }
474
+ }
475
+ },
476
+ legend: {
477
+ reverse: true,
478
+ onHover: function(event, legendItem, legend) {
479
+ const chart = legend.chart;
480
+ const dataset = chart.data.datasets[legendItem.datasetIndex];
481
+ if (!dataset?.label_hint) return;
482
+ chart._legendHoverIndex = legendItem.datasetIndex;
483
+ chart._legendHoverPosition = { x: event.x, y: event.y };
484
+ const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
485
+ if (firstNonZero === -1) return;
486
+ chart.tooltip.setActiveElements(
487
+ [{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
488
+ { x: event.x, y: event.y }
489
+ );
490
+ chart.update();
491
+ },
492
+ onLeave: function(event, legendItem, legend) {
493
+ legend.chart._legendHoverIndex = null;
494
+ legend.chart._legendHoverPosition = null;
495
+ legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
496
+ legend.chart.update();
497
+ }
498
+ }
499
+ }
500
+ }
501
+ });
502
+ })();
503
+ </script>
504
+ <%= seam_end %>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.25pre7
4
+ version: '2.25'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
@@ -73,10 +73,12 @@ files:
73
73
  - lib/jirametrics/board_config.rb
74
74
  - lib/jirametrics/board_feature.rb
75
75
  - lib/jirametrics/board_movement_calculator.rb
76
+ - lib/jirametrics/cfd_data_builder.rb
76
77
  - lib/jirametrics/change_item.rb
77
78
  - lib/jirametrics/chart_base.rb
78
79
  - lib/jirametrics/columns_config.rb
79
80
  - lib/jirametrics/css_variable.rb
81
+ - lib/jirametrics/cumulative_flow_diagram.rb
80
82
  - lib/jirametrics/cycle_time_config.rb
81
83
  - lib/jirametrics/cycletime_histogram.rb
82
84
  - lib/jirametrics/cycletime_scatterplot.rb
@@ -109,6 +111,7 @@ files:
109
111
  - lib/jirametrics/html/aging_work_in_progress_chart.erb
110
112
  - lib/jirametrics/html/aging_work_table.erb
111
113
  - lib/jirametrics/html/collapsible_issues_panel.erb
114
+ - lib/jirametrics/html/cumulative_flow_diagram.erb
112
115
  - lib/jirametrics/html/daily_wip_chart.erb
113
116
  - lib/jirametrics/html/estimate_accuracy_chart.erb
114
117
  - lib/jirametrics/html/expedited_chart.erb