predictability-engine 0.6.6
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 +7 -0
- data/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- metadata +566 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module VegaVisualizer
|
|
5
|
+
module BasicCharts
|
|
6
|
+
def self.cycle_time_scatter(items, percentiles, title: 'Cycle Time Scatter Plot')
|
|
7
|
+
completed = PredictabilityEngine.completed_items(items)
|
|
8
|
+
data = completed.map do |i|
|
|
9
|
+
{ date: PredictabilityEngine.format_date(i.end_date), cycle_time: i.cycle_time, id: i.id,
|
|
10
|
+
title: i.title, title_display: VegaVisualizer.wrap_tooltip_title(i.title), url: i.url }
|
|
11
|
+
end
|
|
12
|
+
pct_data = PredictabilityEngine.mapped_percentiles(items, percentiles)
|
|
13
|
+
VegaVisualizer.apply_standard_dims(
|
|
14
|
+
Vega.lite.data(data + pct_data.map { |p| { type: p[:label], val: p[:val], p: p[:p] } })
|
|
15
|
+
.layer([scatter_points_layer, scatter_rules_layer(pct_data)]),
|
|
16
|
+
title: title
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.scatter_points_layer
|
|
21
|
+
x_axis = VegaVisualizer.date_x_axis(title: 'Completion Date',
|
|
22
|
+
tickCount: { interval: 'week' })
|
|
23
|
+
{ mark: { type: 'point', opacity: 0.6, size: 20 },
|
|
24
|
+
encoding: { x: x_axis,
|
|
25
|
+
y: VegaVisualizer.quantitative_y_axis('cycle_time', title: 'Cycle Time (days)'),
|
|
26
|
+
color: { value: '#4c78a8' },
|
|
27
|
+
**VegaVisualizer.item_href_and_tooltip(
|
|
28
|
+
[{ field: 'date', type: 'temporal', title: 'Completion Date' },
|
|
29
|
+
VegaVisualizer.cycle_time_tooltip_field]
|
|
30
|
+
) } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.scatter_rules_layer(pct_data)
|
|
34
|
+
count = pct_data.size
|
|
35
|
+
palette = ['#72b7b2', '#e45756', '#b279a2', '#ff9da7', '#ad494a', '#8ca27a']
|
|
36
|
+
# More distinct dash styles and thicker lines
|
|
37
|
+
dash_map = { 50 => [], 75 => [8, 4], 85 => [4, 4], 95 => [2, 2], 98 => [1, 1] }
|
|
38
|
+
width_map = { 50 => 1.5, 75 => 2, 85 => 2.5, 95 => 3, 98 => 3.5 }
|
|
39
|
+
|
|
40
|
+
dash_condition = dash_map.map { |p, dash| { test: "datum.p == #{p}", value: dash } }
|
|
41
|
+
width_condition = width_map.map { |p, w| { test: "datum.p == #{p}", value: w } }
|
|
42
|
+
|
|
43
|
+
{ transform: [{ filter: 'datum.type != null' }],
|
|
44
|
+
mark: { type: 'rule' },
|
|
45
|
+
encoding: { y: VegaVisualizer.quantitative_y_axis('val', title: 'Cycle Time (days)'),
|
|
46
|
+
strokeDash: { condition: dash_condition, value: [4, 4] },
|
|
47
|
+
strokeWidth: { condition: width_condition, value: 1 },
|
|
48
|
+
color: { field: 'type', type: 'nominal', title: 'Percentiles',
|
|
49
|
+
scale: { range: palette.take(count) },
|
|
50
|
+
legend: { orient: 'bottom', columns: 3 } },
|
|
51
|
+
tooltip: [{ field: 'type', type: 'nominal', title: 'Percentile' },
|
|
52
|
+
VegaVisualizer.cycle_time_tooltip_field(field: 'val')] } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.throughput_histogram(items, title: 'Throughput Histogram')
|
|
56
|
+
data = Calculators::Throughput.daily(items).values.map { |v| { throughput: v } }
|
|
57
|
+
bar_chart(data, title: title,
|
|
58
|
+
x: VegaVisualizer.quantitative_x_axis('throughput', bin: true, title: 'Items per Day'),
|
|
59
|
+
y: VegaVisualizer.quantitative_y_axis('count', aggregate: 'count', title: 'Frequency'))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
BAND_COLORS = %w[#2ca02c #98df8a #ffdd57 #ff7f0e #d62728 #7b0000].freeze
|
|
63
|
+
|
|
64
|
+
GRANULARITY_PARAM = {
|
|
65
|
+
name: 'granularity',
|
|
66
|
+
value: 'yearweek',
|
|
67
|
+
bind: { input: 'select',
|
|
68
|
+
options: %w[yearday yearweek yearmonth],
|
|
69
|
+
labels: %w[Daily Weekly Monthly],
|
|
70
|
+
name: 'Group by: ' }
|
|
71
|
+
}.freeze
|
|
72
|
+
|
|
73
|
+
PERIOD_EXPR = "granularity === 'yearmonth' ? datum.date_month : " \
|
|
74
|
+
"(granularity === 'yearday' ? datum.date : datum.date_week)"
|
|
75
|
+
|
|
76
|
+
def self.cycle_time_bands(items, title: 'Cycle Time Bands Over Time', **)
|
|
77
|
+
labels = RawDataExporter::DONE_THRESHOLD_LABELS
|
|
78
|
+
completed = PredictabilityEngine.completed_items(items)
|
|
79
|
+
return Vega.lite.data([]).title(title) if completed.empty?
|
|
80
|
+
|
|
81
|
+
data = completed.map do |item|
|
|
82
|
+
idx = RawDataExporter.threshold_index(item.cycle_time)
|
|
83
|
+
{ date: PredictabilityEngine.format_date(item.end_date),
|
|
84
|
+
date_week: PredictabilityEngine.format_year_week(item.end_date),
|
|
85
|
+
date_month: PredictabilityEngine.format_year_month(item.end_date),
|
|
86
|
+
band: labels[idx], band_order: idx }
|
|
87
|
+
end
|
|
88
|
+
VegaVisualizer.apply_standard_dims(
|
|
89
|
+
Vega.lite.data(data)
|
|
90
|
+
.params([GRANULARITY_PARAM])
|
|
91
|
+
.transform([{ calculate: PERIOD_EXPR, as: 'period' }])
|
|
92
|
+
.mark(type: 'area')
|
|
93
|
+
.encoding(
|
|
94
|
+
x: { field: 'period', type: 'ordinal', sort: 'ascending',
|
|
95
|
+
title: nil, axis: VegaVisualizer::LABEL_AXIS },
|
|
96
|
+
y: { aggregate: 'count', type: 'quantitative', title: 'Items Completed' },
|
|
97
|
+
color: { field: 'band', type: 'ordinal', sort: labels,
|
|
98
|
+
scale: { domain: labels, range: BAND_COLORS },
|
|
99
|
+
legend: { title: 'Cycle Time', orient: 'bottom', columns: labels.size } },
|
|
100
|
+
order: { field: 'band_order', type: 'quantitative' },
|
|
101
|
+
tooltip: [
|
|
102
|
+
{ field: 'period', type: 'ordinal', title: 'Period' },
|
|
103
|
+
{ field: 'band', type: 'ordinal', title: 'Cycle Time' },
|
|
104
|
+
{ aggregate: 'count', type: 'quantitative', title: 'Items' }
|
|
105
|
+
]
|
|
106
|
+
),
|
|
107
|
+
title: title
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.bar_chart(data, title:, **encoding)
|
|
112
|
+
VegaVisualizer.apply_standard_dims(
|
|
113
|
+
Vega.lite.data(data).mark(type: 'bar', tooltip: true).encoding(**encoding),
|
|
114
|
+
title: title
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private_class_method :bar_chart
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module VegaVisualizer
|
|
5
|
+
module CfdCharts
|
|
6
|
+
def self.cfd(work_items, title: 'Cumulative Flow Diagram', history_range: nil)
|
|
7
|
+
data = Calculators::Cfd.calculate(work_items)
|
|
8
|
+
days = Duration.parse(history_range)
|
|
9
|
+
data = data.last(days) if days
|
|
10
|
+
formatted = VegaVisualizer.format_cfd_data(data)
|
|
11
|
+
render_cfd(formatted, [], title)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.forecasted_cfd(work_items, percentiles, title, history_range: nil)
|
|
15
|
+
data = Calculators::Cfd.forecast_series(work_items, percentiles: percentiles, history_range: history_range)
|
|
16
|
+
return cfd(work_items, title: title) unless data
|
|
17
|
+
|
|
18
|
+
unified = VegaVisualizer.build_cfd_unified_data(data, percentiles)
|
|
19
|
+
render_cfd(unified, percentiles, title, forecast: data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.render_cfd(data, percentiles, title, forecast: nil)
|
|
23
|
+
dom, range = VegaVisualizer.cfd_color_scale(percentiles)
|
|
24
|
+
chart = base_cfd_chart(data, dom, range)
|
|
25
|
+
.layer(cfd_layers(percentiles, forecast))
|
|
26
|
+
resolved_title = forecast ? forecast_title(title) : title
|
|
27
|
+
padding = forecast ? { right: 80, top: 30 } : nil
|
|
28
|
+
VegaVisualizer.apply_standard_dims(chart, title: resolved_title, padding: padding)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.forecast_title(text)
|
|
32
|
+
{ text: text,
|
|
33
|
+
subtitle: [
|
|
34
|
+
'Each X% Confidence surface = forecast of cumulative departures if backlog completes at that confidence',
|
|
35
|
+
'(Monte Carlo on historical throughput; NOT cycle-time percentile bands)'
|
|
36
|
+
] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.cfd_layers(percentiles, forecast)
|
|
40
|
+
layers = [VegaVisualizer.cfd_area_layer(percentiles, legend: !percentiles.empty?)]
|
|
41
|
+
return layers unless forecast
|
|
42
|
+
|
|
43
|
+
layers + [VegaVisualizer.cfd_line_layer(percentiles),
|
|
44
|
+
*VegaVisualizer.cfd_vert_layers(forecast, percentiles)]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.base_cfd_chart(data, dom, range)
|
|
48
|
+
# Find last day to ensure it can be labeled
|
|
49
|
+
dates = data.map { |d| PredictabilityEngine.format_date(d[:date]) }.compact.uniq.sort
|
|
50
|
+
first_date = Date.parse(dates.first)
|
|
51
|
+
last_date = Date.parse(dates.last)
|
|
52
|
+
|
|
53
|
+
# Major ticks every week, starting from first_date, plus exactly last_date
|
|
54
|
+
major_ticks = []
|
|
55
|
+
curr = first_date
|
|
56
|
+
while curr < last_date
|
|
57
|
+
major_ticks << PredictabilityEngine.format_date(curr)
|
|
58
|
+
curr += 7
|
|
59
|
+
end
|
|
60
|
+
major_ticks << PredictabilityEngine.format_date(last_date)
|
|
61
|
+
major_ticks.uniq!
|
|
62
|
+
|
|
63
|
+
# Use axis options directly in date_x_axis to avoid hash merge overwrite
|
|
64
|
+
Vega.lite.data(data)
|
|
65
|
+
.encoding(
|
|
66
|
+
x: VegaVisualizer.date_x_axis(
|
|
67
|
+
values: major_ticks,
|
|
68
|
+
labelFlush: true,
|
|
69
|
+
labelOverlap: 'parity',
|
|
70
|
+
minorTicks: true,
|
|
71
|
+
tickSize: 8,
|
|
72
|
+
minorTickSize: 4
|
|
73
|
+
),
|
|
74
|
+
y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items'),
|
|
75
|
+
color: { field: 'type', type: 'nominal', scale: { domain: dom, range: range } }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private_class_method :base_cfd_chart, :forecast_title
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module VegaVisualizer
|
|
5
|
+
# Layout and data logic for CFD charts in Vega-Lite.
|
|
6
|
+
module CfdLayout
|
|
7
|
+
def self.build_unified_data(data, percentiles)
|
|
8
|
+
res = []
|
|
9
|
+
sorted_pcts = percentiles.sort
|
|
10
|
+
data[:dates].each_with_index do |date, i|
|
|
11
|
+
res << { date: PredictabilityEngine.format_date(date), count: data[:arrivals][i], type: 'Arrivals', order: 0 }
|
|
12
|
+
sorted_pcts.each_with_index do |p, pi|
|
|
13
|
+
res << { date: PredictabilityEngine.format_date(date), count: data[:forecasts][p][i],
|
|
14
|
+
type: "#{p}% Confidence", order: pi + 1 }
|
|
15
|
+
end
|
|
16
|
+
if i < data[:departed].size
|
|
17
|
+
res << { date: PredictabilityEngine.format_date(date), count: data[:departed][i], type: 'Departures',
|
|
18
|
+
order: sorted_pcts.size + 1 }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
res
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.color_scale(pcts)
|
|
25
|
+
sorted_pcts = pcts.sort
|
|
26
|
+
dom = ['Arrivals'] + sorted_pcts.map { |p| "#{p}% Confidence" } + ['Departures']
|
|
27
|
+
palette = ['#72b7b2', '#e45756', '#b279a2', '#ff9da7', '#ad494a', '#8ca27a']
|
|
28
|
+
range = ['#4c78a8'] + palette.take(sorted_pcts.size) + ['#59a14f']
|
|
29
|
+
[dom, range]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.area_layer(pcts, legend: true)
|
|
33
|
+
cfg = { field: 'type', type: 'nominal' }
|
|
34
|
+
cfg[:legend] = { title: 'Flow & Forecast', orient: 'bottom', columns: 3 } if legend && !pcts.empty?
|
|
35
|
+
{ mark: { type: 'area' },
|
|
36
|
+
encoding: { y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items', stack: nil),
|
|
37
|
+
color: cfg,
|
|
38
|
+
order: { field: 'order', type: 'quantitative' },
|
|
39
|
+
tooltip: VegaVisualizer.cfd_tooltip_fields } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.line_layer
|
|
43
|
+
{ mark: { type: 'line' },
|
|
44
|
+
encoding: { y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items'),
|
|
45
|
+
strokeDash: {
|
|
46
|
+
condition: { test: "datum.type == 'Arrivals' || datum.type == 'Departures'", value: [] },
|
|
47
|
+
value: [4, 4]
|
|
48
|
+
},
|
|
49
|
+
tooltip: VegaVisualizer.cfd_tooltip_fields } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.vert_layers(forecast, percentiles)
|
|
53
|
+
data = vert_data(forecast, percentiles)
|
|
54
|
+
[rule_layer(data), text_layer(data)]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.rule_layer(data)
|
|
58
|
+
base_layer(data).merge(
|
|
59
|
+
mark: { type: 'rule', strokeDash: [4, 2], strokeWidth: 2, tooltip: true },
|
|
60
|
+
encoding: vert_encoding(y: { datum: 0 }, y2: VegaVisualizer.quantitative_y_axis('count', title: nil))
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.text_layer(data)
|
|
65
|
+
base_layer(data).merge(
|
|
66
|
+
mark: { type: 'text', align: 'left', baseline: 'middle',
|
|
67
|
+
fontWeight: 'bold', fontSize: 10, angle: -45, dx: 5,
|
|
68
|
+
clip: false, tooltip: true },
|
|
69
|
+
encoding: vert_encoding(y: VegaVisualizer.quantitative_y_axis('count', title: 'Total Items'),
|
|
70
|
+
text: { field: 'label' })
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.vert_encoding(**opts)
|
|
75
|
+
{ x: VegaVisualizer.date_axis_base, tooltip: tooltip_field, color: { value: '#e45756' } }.merge(opts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.base_layer(data)
|
|
79
|
+
{ data: { values: data } }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.tooltip_field
|
|
83
|
+
{ field: 'tooltip', type: 'nominal' }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.vert_data(forecast, percentiles)
|
|
87
|
+
sorted_pcts = percentiles.sort
|
|
88
|
+
data_by_date = group_pcts_by_date(forecast, sorted_pcts)
|
|
89
|
+
|
|
90
|
+
# IMMUTABLE invariant — see CLAUDE.md §"Forecast alignment invariant".
|
|
91
|
+
# Rule height = percentile-surface plateau (departed_so_far + wip), so each
|
|
92
|
+
# vertical rule hits the top-right corner of its p% surface exactly.
|
|
93
|
+
plateau = forecast[:summary][:departed_so_far] + forecast[:summary][:wip]
|
|
94
|
+
|
|
95
|
+
data_by_date.sort_by { |date, _| date }.map do |date, p_list|
|
|
96
|
+
date_str = PredictabilityEngine.format_date(date)
|
|
97
|
+
label = "#{p_list.sort.map { |p| "#{p}%" }.join(', ')} (#{date_str})"
|
|
98
|
+
|
|
99
|
+
{ date: date_str, label: label,
|
|
100
|
+
tooltip: p_list.map { |p| "#{p}% Confidence (#{date_str})" }.join("\n"),
|
|
101
|
+
count: plateau }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.group_pcts_by_date(forecast, sorted_pcts)
|
|
106
|
+
groups = []
|
|
107
|
+
today = forecast[:summary][:today]
|
|
108
|
+
|
|
109
|
+
sorted_pcts.each do |p|
|
|
110
|
+
days = forecast[:summary][:"p#{p}"]
|
|
111
|
+
next unless days
|
|
112
|
+
|
|
113
|
+
date = today + days
|
|
114
|
+
|
|
115
|
+
# Collision avoidance: if date is close to an existing group, add to it
|
|
116
|
+
# Threshold: 2 days seems reasonable for collision avoidance
|
|
117
|
+
matched_group = groups.find { |g| (g[:date] - date).abs <= 2 }
|
|
118
|
+
if matched_group
|
|
119
|
+
matched_group[:pcts] << p
|
|
120
|
+
else
|
|
121
|
+
groups << { date: date, pcts: [p] }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
groups.to_h { |g| [PredictabilityEngine.format_date(g[:date]), g[:pcts]] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method :rule_layer, :text_layer, :tooltip_field,
|
|
129
|
+
:group_pcts_by_date, :base_layer, :vert_encoding
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module VegaVisualizer
|
|
5
|
+
module TooltipHelpers
|
|
6
|
+
TOOLTIP_WRAP_WIDTH = 40
|
|
7
|
+
|
|
8
|
+
def item_id_tooltip_field = { field: 'id', type: 'nominal', title: 'Work Item ID' }
|
|
9
|
+
def title_tooltip_field = { field: 'title_display', type: 'nominal', title: 'Title' }
|
|
10
|
+
def standard_item_tooltip_fields = [item_id_tooltip_field, title_tooltip_field]
|
|
11
|
+
def item_href_and_tooltip(extra) = { href: url_href, tooltip: standard_item_tooltip_fields + extra }
|
|
12
|
+
def url_href = { field: 'url', type: 'nominal' }
|
|
13
|
+
|
|
14
|
+
def cycle_time_tooltip_field(field: 'cycle_time')
|
|
15
|
+
{ field: field, type: 'quantitative', title: 'Cycle Time (days)' }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def cfd_tooltip_fields
|
|
19
|
+
[{ field: 'date', type: 'temporal', title: 'Date' }, { field: 'type', type: 'nominal', title: 'Type' },
|
|
20
|
+
{ field: 'count', type: 'quantitative', title: 'Items' }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def wrap_tooltip_title(text, width: TOOLTIP_WRAP_WIDTH)
|
|
24
|
+
str = text.to_s
|
|
25
|
+
return str if str.length <= width
|
|
26
|
+
|
|
27
|
+
str.split.each_with_object(['']) do |word, lines|
|
|
28
|
+
lines << '' if "#{lines.last} #{word}".strip.length > width
|
|
29
|
+
lines[-1] = "#{lines.last} #{word}".strip
|
|
30
|
+
end.join("\n")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'vega'
|
|
4
|
+
require_relative 'vega_visualizer/tooltip_helpers'
|
|
5
|
+
require_relative 'vega_visualizer/basic_charts'
|
|
6
|
+
require_relative 'vega_visualizer/cfd_charts'
|
|
7
|
+
require_relative 'vega_visualizer/aging_wip_visualizer'
|
|
8
|
+
require_relative 'vega_visualizer/cfd_layout'
|
|
9
|
+
|
|
10
|
+
module PredictabilityEngine
|
|
11
|
+
module VegaVisualizer
|
|
12
|
+
CHART_WIDTH = 500
|
|
13
|
+
CHART_HEIGHT = 300
|
|
14
|
+
LABEL_AXIS = { labelAngle: -45, labelOverlap: 'parity' }.freeze
|
|
15
|
+
|
|
16
|
+
extend TooltipHelpers
|
|
17
|
+
|
|
18
|
+
def self.apply_standard_dims(chart, title: nil, padding: nil)
|
|
19
|
+
chart = chart.title(title) if title
|
|
20
|
+
cfg = { autosize: { type: 'fit', contains: 'padding' }, axis: { grid: false } }
|
|
21
|
+
cfg[:padding] = padding if padding
|
|
22
|
+
chart.width('container').height('container').config(cfg)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.date_axis_base(title: 'Date')
|
|
26
|
+
{ field: 'date', type: 'temporal', title: title, axis: { format: '%Y-%m-%d' } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.date_x_axis(title: 'Date', **opts)
|
|
30
|
+
base = date_axis_base(title: title)
|
|
31
|
+
base[:axis] = base[:axis].merge(labelAngle: -45).merge(opts)
|
|
32
|
+
base
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.quantitative_y_axis(...) = quantitative_axis(...)
|
|
36
|
+
def self.quantitative_x_axis(...) = quantitative_axis(...)
|
|
37
|
+
|
|
38
|
+
def self.quantitative_axis(field, title: :auto, **opts)
|
|
39
|
+
res = { field: field.to_s, type: 'quantitative' }
|
|
40
|
+
res[:title] = title == :auto ? field.to_s.capitalize : title
|
|
41
|
+
res.merge(opts)
|
|
42
|
+
end
|
|
43
|
+
private_class_method :quantitative_axis
|
|
44
|
+
|
|
45
|
+
def self.cycle_time_scatter(items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
|
|
46
|
+
title: 'Cycle Time Scatter Plot', **)
|
|
47
|
+
BasicCharts.cycle_time_scatter(items, percentiles, title: title)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.throughput_histogram(items, title: 'Throughput Histogram', **)
|
|
51
|
+
BasicCharts.throughput_histogram(items, title: title)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.cycle_time_bands(items, title: 'Cycle Time Bands Over Time', **)
|
|
55
|
+
BasicCharts.cycle_time_bands(items, title: title)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.aging_wip(items, title: 'Aging Work In Progress',
|
|
59
|
+
percentiles: PredictabilityEngine::DEFAULT_PERCENTILES, **)
|
|
60
|
+
AgingWipVisualizer.aging_wip(items, title: title, percentiles: percentiles, **)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.cfd(work_items, title: 'Cumulative Flow Diagram', history_range: nil, **_opts)
|
|
64
|
+
CfdCharts.cfd(work_items, title: title, history_range: history_range)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.forecasted_cfd(work_items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
|
|
68
|
+
title: 'Forecasted Cumulative Flow Diagram', history_range: nil, **_opts)
|
|
69
|
+
CfdCharts.forecasted_cfd(work_items, percentiles, title, history_range: history_range)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.build_cfd_unified_data(data, percentiles)
|
|
73
|
+
CfdLayout.build_unified_data(data, percentiles)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.cfd_color_scale(pcts)
|
|
77
|
+
CfdLayout.color_scale(pcts)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.cfd_area_layer(pcts, legend: true)
|
|
81
|
+
CfdLayout.area_layer(pcts, legend: legend)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.cfd_line_layer(_pcts)
|
|
85
|
+
CfdLayout.line_layer
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.cfd_vert_layers(forecast, percentiles)
|
|
89
|
+
CfdLayout.vert_layers(forecast, percentiles)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.format_cfd_data(cfd)
|
|
93
|
+
cfd.flat_map do |d|
|
|
94
|
+
[{ date: PredictabilityEngine.format_date(d[:date]), count: d[:arrived], type: 'Arrivals', order: 0 },
|
|
95
|
+
{ date: PredictabilityEngine.format_date(d[:date]), count: d[:departed], type: 'Departures', order: 1 }]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.dashboard(items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
100
|
+
charts = [aging_wip(items), forecasted_cfd(items, percentiles: percentiles), cfd(items),
|
|
101
|
+
cycle_time_scatter(items, percentiles: percentiles),
|
|
102
|
+
throughput_histogram(items)]
|
|
103
|
+
Vega.lite.vconcat(charts.map { |c| c.spec.except('$schema') })
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'terminal_visualizer'
|
|
4
|
+
require_relative 'vega_visualizer'
|
|
5
|
+
require_relative 'summary_visualizer'
|
|
6
|
+
require_relative 'html_style'
|
|
7
|
+
require_relative 'html_templates'
|
|
8
|
+
|
|
9
|
+
module PredictabilityEngine
|
|
10
|
+
class Visualizer
|
|
11
|
+
def self.cycle_time_scatter(items, color: false, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
12
|
+
TerminalVisualizer.cycle_time_scatter(items, color: color, percentiles: percentiles)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.throughput_histogram(items, color: false)
|
|
16
|
+
TerminalVisualizer.throughput_histogram(items, color: color)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.cfd_plot(items, color: false)
|
|
20
|
+
TerminalVisualizer.cfd_plot(items, color: color)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.forecasted_cfd_plot(items, color: false, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
24
|
+
TerminalVisualizer.forecasted_cfd_plot(items, color: color, percentiles: percentiles)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.aging_wip(items, color: false, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
28
|
+
TerminalVisualizer.aging_wip(items, color: color, percentiles: percentiles)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
%i[cycle_time_scatter throughput_histogram cfd forecasted_cfd aging_wip dashboard].each do |m|
|
|
32
|
+
define_singleton_method("vega_#{m}") { |items, **opts| VegaVisualizer.send(m, items, **opts) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.to_full_html(content_or_chart, work_items = nil, **opts)
|
|
36
|
+
title = opts.fetch(:title, 'Predictability Engine Dashboard')
|
|
37
|
+
percentiles = opts.fetch(:percentiles, PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
38
|
+
sub_reports = opts.fetch(:sub_reports, nil)
|
|
39
|
+
|
|
40
|
+
style = HTML_STYLE_LANDSCAPE
|
|
41
|
+
body = HTML_LANDSCAPE_BODY
|
|
42
|
+
summary = work_items ? SummaryVisualizer.metrics_html(work_items, percentiles: percentiles) : ''
|
|
43
|
+
|
|
44
|
+
nav_bar = build_nav_bar(sub_reports)
|
|
45
|
+
|
|
46
|
+
html = HTML_BASE.gsub('{{STYLE}}', style).gsub('{{BODY}}', body)
|
|
47
|
+
html.gsub!('{{TITLE}}', title)
|
|
48
|
+
html.gsub!('{{DATE}}', PredictabilityEngine.format_datetime(Time.now))
|
|
49
|
+
html.gsub!('{{SUMMARY_CONTENT}}', summary)
|
|
50
|
+
html.gsub!('{{NAV_BAR}}', nav_bar)
|
|
51
|
+
|
|
52
|
+
content = prepare_html_content(content_or_chart, :landscape, html)
|
|
53
|
+
# If it was a single chart, it might still have {{CHART_PANELS}} placeholder
|
|
54
|
+
if html.include?('{{CHART_PANELS}}')
|
|
55
|
+
panel = "<div class='chart-panel' style='grid-column: span 2; grid-row: span 2;'>" \
|
|
56
|
+
"<div class='panel-header'>" \
|
|
57
|
+
"<button class='chart-expand' onclick='toggleFullscreen(this)' title='Expand'></button></div>" \
|
|
58
|
+
"<div class='chart-container'>#{content}</div></div>"
|
|
59
|
+
html.gsub!('{{CHART_PANELS}}', panel)
|
|
60
|
+
end
|
|
61
|
+
html
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.build_nav_bar(sub_reports)
|
|
65
|
+
return '' unless sub_reports&.any?
|
|
66
|
+
|
|
67
|
+
view_items, dl_items = sub_reports.partition { |r| !r[:download] }
|
|
68
|
+
html = view_nav_section(view_items) + export_nav_section(dl_items, view_items.any?)
|
|
69
|
+
"<ul class='nav-links'>#{html}</ul>"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.view_nav_section(view_items)
|
|
73
|
+
return '' unless view_items.any?
|
|
74
|
+
|
|
75
|
+
"<li><strong>View:</strong></li>#{render_nav_items(view_items)}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.export_nav_section(dl_items, has_view)
|
|
79
|
+
return '' unless dl_items.any?
|
|
80
|
+
|
|
81
|
+
sep = has_view ? "<li class='nav-sep' aria-hidden='true'>|</li>" : ''
|
|
82
|
+
"#{sep}<li><strong>Export:</strong></li>#{render_nav_items(dl_items)}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.render_nav_items(items)
|
|
86
|
+
items.map { |r| nav_item(r) }.join
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.nav_item(entry)
|
|
90
|
+
return "<li class='nav-sep' aria-hidden='true'>|</li>" if entry[:separator]
|
|
91
|
+
|
|
92
|
+
dl = entry[:download] ? ' download' : ''
|
|
93
|
+
"<li><a href='#{entry[:url]}' class='#{'active' if entry[:active]}'#{dl}>#{entry[:label]}</a></li>"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.prepare_html_content(content_or_chart, layout, html)
|
|
97
|
+
if layout == :landscape && content_or_chart.is_a?(Array)
|
|
98
|
+
panels = content_or_chart.map do |cfg|
|
|
99
|
+
"<div class='chart-panel'>" \
|
|
100
|
+
"<div class='panel-header'><h2>#{cfg[:title]}</h2>" \
|
|
101
|
+
"<button class='chart-expand' onclick='toggleFullscreen(this)' title='Expand'></button></div>" \
|
|
102
|
+
"<div class='chart-container'>#{cfg[:chart].to_html}</div></div>"
|
|
103
|
+
end.join("\n")
|
|
104
|
+
html.gsub!('{{CHART_PANELS}}', panels)
|
|
105
|
+
''
|
|
106
|
+
else
|
|
107
|
+
content = content_or_chart.respond_to?(:to_html) ? content_or_chart.to_html : content_or_chart
|
|
108
|
+
content.is_a?(Array) ? content.join("\n") : content
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private_class_method :prepare_html_content, :view_nav_section, :export_nav_section, :render_nav_items
|
|
113
|
+
end
|
|
114
|
+
end
|