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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615528aa77577881d7658b0ce059e9e3972ce4285aeb3c1081c4347dd1a20ca4
|
|
4
|
+
data.tar.gz: 483cc9b7535da95ca2249813e5ad8ff924334f65c8b7fa84c5705367a9d1b147
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|