spoom 1.0.4 → 1.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +0 -1
  3. data/README.md +296 -1
  4. data/Rakefile +1 -0
  5. data/lib/spoom.rb +21 -2
  6. data/lib/spoom/cli.rb +56 -10
  7. data/lib/spoom/cli/bump.rb +138 -0
  8. data/lib/spoom/cli/config.rb +51 -0
  9. data/lib/spoom/cli/coverage.rb +206 -0
  10. data/lib/spoom/cli/helper.rb +149 -0
  11. data/lib/spoom/cli/lsp.rb +165 -0
  12. data/lib/spoom/cli/run.rb +109 -0
  13. data/lib/spoom/coverage.rb +89 -0
  14. data/lib/spoom/coverage/d3.rb +110 -0
  15. data/lib/spoom/coverage/d3/base.rb +50 -0
  16. data/lib/spoom/coverage/d3/circle_map.rb +195 -0
  17. data/lib/spoom/coverage/d3/pie.rb +175 -0
  18. data/lib/spoom/coverage/d3/timeline.rb +486 -0
  19. data/lib/spoom/coverage/report.rb +308 -0
  20. data/lib/spoom/coverage/snapshot.rb +132 -0
  21. data/lib/spoom/file_tree.rb +196 -0
  22. data/lib/spoom/git.rb +98 -0
  23. data/lib/spoom/printer.rb +80 -0
  24. data/lib/spoom/sorbet.rb +99 -47
  25. data/lib/spoom/sorbet/config.rb +30 -0
  26. data/lib/spoom/sorbet/errors.rb +33 -15
  27. data/lib/spoom/sorbet/lsp.rb +2 -4
  28. data/lib/spoom/sorbet/lsp/structures.rb +108 -14
  29. data/lib/spoom/sorbet/metrics.rb +10 -79
  30. data/lib/spoom/sorbet/sigils.rb +98 -0
  31. data/lib/spoom/test_helpers/project.rb +112 -0
  32. data/lib/spoom/timeline.rb +53 -0
  33. data/lib/spoom/version.rb +2 -2
  34. data/templates/card.erb +8 -0
  35. data/templates/card_snapshot.erb +22 -0
  36. data/templates/page.erb +50 -0
  37. metadata +28 -11
  38. data/lib/spoom/cli/commands/base.rb +0 -36
  39. data/lib/spoom/cli/commands/config.rb +0 -67
  40. data/lib/spoom/cli/commands/lsp.rb +0 -156
  41. data/lib/spoom/cli/commands/run.rb +0 -92
  42. data/lib/spoom/cli/symbol_printer.rb +0 -71
  43. data/lib/spoom/config.rb +0 -11
@@ -0,0 +1,50 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Coverage
6
+ module D3
7
+ class Base
8
+ extend T::Sig
9
+ extend T::Helpers
10
+
11
+ abstract!
12
+
13
+ sig { returns(String) }
14
+ attr_reader :id
15
+
16
+ sig { params(id: String, data: T.untyped).void }
17
+ def initialize(id, data)
18
+ @id = id
19
+ @data = data
20
+ end
21
+
22
+ sig { returns(String) }
23
+ def self.header_style
24
+ ""
25
+ end
26
+
27
+ sig { returns(String) }
28
+ def self.header_script
29
+ ""
30
+ end
31
+
32
+ sig { returns(String) }
33
+ def html
34
+ <<~HTML
35
+ <svg id="#{id}"></svg>
36
+ <script>#{script}</script>
37
+ HTML
38
+ end
39
+
40
+ sig { returns(String) }
41
+ def tooltip
42
+ ""
43
+ end
44
+
45
+ sig { abstract.returns(String) }
46
+ def script; end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,195 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Spoom
7
+ module Coverage
8
+ module D3
9
+ class CircleMap < Base
10
+ extend T::Sig
11
+
12
+ sig { returns(String) }
13
+ def self.header_style
14
+ <<~CSS
15
+ .node {
16
+ cursor: pointer;
17
+ }
18
+
19
+ .node:hover {
20
+ stroke: #333;
21
+ stroke-width: 1px;
22
+ }
23
+
24
+ .label.dir {
25
+ fill: #333;
26
+ }
27
+
28
+ .label.file {
29
+ font: 12px Arial, sans-serif;
30
+ }
31
+
32
+ .node.root, .node.file {
33
+ pointer-events: none;
34
+ }
35
+ CSS
36
+ end
37
+
38
+ sig { returns(String) }
39
+ def self.header_script
40
+ <<~JS
41
+ function treeHeight(root, height = 0) {
42
+ height += 1;
43
+ if (root.children && root.children.length > 0)
44
+ return Math.max(...root.children.map(child => treeHeight(child, height)));
45
+ else
46
+ return height;
47
+ }
48
+
49
+ function tooltipMap(d) {
50
+ moveTooltip(d)
51
+ .html("<b>" + d.data.name + "</b>")
52
+ }
53
+ JS
54
+ end
55
+
56
+ sig { override.returns(String) }
57
+ def script
58
+ <<~JS
59
+ var root = {children: #{@data.to_json}}
60
+ var dataHeight = treeHeight(root)
61
+
62
+ var opacity = d3.scaleLinear()
63
+ .domain([0, dataHeight])
64
+ .range([0, 0.2])
65
+
66
+ root = d3.hierarchy(root)
67
+ .sum((d) => d.children ? d.children.length : 1)
68
+ .sort((a, b) => b.value - a.value);
69
+
70
+ var dirColor = d3.scaleLinear()
71
+ .domain([1, 0])
72
+ .range([strictnessColor("true"), strictnessColor("false")])
73
+ .interpolate(d3.interpolateRgb);
74
+
75
+ function redraw() {
76
+ var diameter = document.getElementById("#{id}").clientWidth - 20;
77
+ d3.select("##{id}").selectAll("*").remove()
78
+
79
+ var svg_#{id} = d3.select("##{id}")
80
+ .attr("width", diameter)
81
+ .attr("height", diameter)
82
+ .append("g")
83
+ .attr("transform", "translate(" + diameter / 2 + "," + diameter / 2 + ")");
84
+
85
+ var pack = d3.pack()
86
+ .size([diameter, diameter])
87
+ .padding(2);
88
+
89
+ var focus = root,
90
+ nodes = pack(root).descendants(),
91
+ view;
92
+
93
+ var circle = svg_#{id}.selectAll("circle")
94
+ .data(nodes)
95
+ .enter().append("circle")
96
+ .attr("class", (d) => d.parent ? d.children ? "node" : "node file" : "node root")
97
+ .attr("fill", (d) => d.children ? dirColor(d.data.score) : strictnessColor(d.data.strictness))
98
+ .attr("fill-opacity", (d) => d.children ? opacity(d.depth) : 1)
99
+ .on("click", function(d) { if (focus !== d) zoom(d), d3.event.stopPropagation(); })
100
+ .on("mouseover", (d) => tooltip.style("opacity", 1))
101
+ .on("mousemove", tooltipMap)
102
+ .on("mouseleave", (d) => tooltip.style("opacity", 0));
103
+
104
+ var text = svg_#{id}.selectAll("text")
105
+ .data(nodes)
106
+ .enter().append("text")
107
+ .attr("class", (d) => d.children ? "label dir" : "label file")
108
+ .attr("fill-opacity", (d) => d.depth <= 1 ? 1 : 0)
109
+ .attr("display", (d) => d.depth <= 1 ? "inline" : "none")
110
+ .text((d) => d.data.name);
111
+
112
+ var node = svg_#{id}.selectAll("circle,text");
113
+
114
+ function zoom(d) {
115
+ var focus0 = focus; focus = d;
116
+
117
+ var transition = d3.transition()
118
+ .duration(d3.event.altKey ? 7500 : 750)
119
+ .tween("zoom", function(d) {
120
+ var i = d3.interpolateZoom(view, [focus.x, focus.y, focus.r * 2]);
121
+ return (t) => zoomTo(i(t));
122
+ });
123
+
124
+ transition.selectAll("text")
125
+ .filter(function(d) { return d && d.parent === focus || this.style.display === "inline"; })
126
+ .attr("fill-opacity", function(d) { return d.parent === focus ? 1 : 0; })
127
+ .on("start", function(d) { if (d.parent === focus) this.style.display = "inline"; })
128
+ .on("end", function(d) { if (d.parent !== focus) this.style.display = "none"; });
129
+ }
130
+
131
+ function zoomTo(v) {
132
+ var k = diameter / v[2]; view = v;
133
+ node.attr("transform", (d) => "translate(" + (d.x - v[0]) * k + "," + (d.y - v[1]) * k + ")");
134
+ circle.attr("r", (d) => d.r * k);
135
+ }
136
+
137
+ zoomTo([root.x, root.y, root.r * 2]);
138
+ d3.select("##{id}").on("click", () => zoom(root));
139
+ }
140
+
141
+ redraw();
142
+ window.addEventListener("resize", redraw);
143
+ JS
144
+ end
145
+
146
+ class Sigils < CircleMap
147
+ extend T::Sig
148
+
149
+ sig { params(id: String, sigils_tree: FileTree).void }
150
+ def initialize(id, sigils_tree)
151
+ @scores = T.let({}, T::Hash[FileTree::Node, Float])
152
+ @strictnesses = T.let({}, T::Hash[FileTree::Node, T.nilable(String)])
153
+ @sigils_tree = sigils_tree
154
+ super(id, sigils_tree.roots.map { |r| tree_node_to_json(r) })
155
+ end
156
+
157
+ sig { params(node: FileTree::Node).returns(T::Hash[Symbol, T.untyped]) }
158
+ def tree_node_to_json(node)
159
+ if node.children.empty?
160
+ return { name: node.name, strictness: tree_node_strictness(node) }
161
+ end
162
+ {
163
+ name: node.name,
164
+ children: node.children.values.map { |n| tree_node_to_json(n) },
165
+ score: tree_node_score(node),
166
+ }
167
+ end
168
+
169
+ sig { params(node: FileTree::Node).returns(T.nilable(String)) }
170
+ def tree_node_strictness(node)
171
+ prefix = @sigils_tree.strip_prefix
172
+ path = node.path
173
+ path = "#{prefix}/#{path}" if prefix
174
+ @strictnesses[node] ||= Spoom::Sorbet::Sigils.file_strictness(path)
175
+ end
176
+
177
+ sig { params(node: FileTree::Node).returns(Float) }
178
+ def tree_node_score(node)
179
+ unless @scores.key?(node)
180
+ if node.name =~ /\.rbi?$/
181
+ case tree_node_strictness(node)
182
+ when "true", "strict", "strong"
183
+ @scores[node] = 1.0
184
+ end
185
+ elsif !node.children.empty?
186
+ @scores[node] = node.children.values.sum { |n| tree_node_score(n) } / node.children.size.to_f
187
+ end
188
+ end
189
+ @scores[node] || 0.0
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,175 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Spoom
7
+ module Coverage
8
+ module D3
9
+ class Pie < Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ abstract!
14
+
15
+ sig { params(id: String, title: String, data: T.untyped).void }
16
+ def initialize(id, title, data)
17
+ super(id, data)
18
+ @title = title
19
+ end
20
+
21
+ sig { returns(String) }
22
+ def self.header_style
23
+ <<~CSS
24
+ .pie .title {
25
+ font: 18px Arial, sans-serif;
26
+ font-weight: bold;
27
+ fill: #212529;
28
+ text-anchor: middle;
29
+ pointer-events: none;
30
+ }
31
+
32
+ .pie .arc {
33
+ stroke: #fff;
34
+ stroke-width: 2px;
35
+ }
36
+ CSS
37
+ end
38
+
39
+ sig { returns(String) }
40
+ def self.header_script
41
+ <<~JS
42
+ function tooltipPie(d, title, kind, sum) {
43
+ moveTooltip(d)
44
+ .html("<b>" + title + "</b><br><br>"
45
+ + "<b>" + d.data.value + "</b> " + kind + "<br>"
46
+ + "<b>" + toPercent(d.data.value, sum) + "</b>%")
47
+ }
48
+ JS
49
+ end
50
+
51
+ sig { override.returns(String) }
52
+ def script
53
+ <<~JS
54
+ #{tooltip}
55
+
56
+ var json_#{id} = #{@data.to_json};
57
+ var pie_#{id} = d3.pie().value((d) => d.value);
58
+ var data_#{id} = pie_#{id}(d3.entries(json_#{id}));
59
+ var sum_#{id} = d3.sum(data_#{id}, (d) => d.data.value);
60
+ var title_#{id} = #{@title.to_json};
61
+
62
+ function draw_#{id}() {
63
+ var pieSize_#{id} = document.getElementById("#{id}").clientWidth - 10;
64
+
65
+ var arcGenerator_#{id} = d3.arc()
66
+ .innerRadius(pieSize_#{id} / 4)
67
+ .outerRadius(pieSize_#{id} / 2);
68
+
69
+ d3.select("##{id}").selectAll("*").remove()
70
+
71
+ var svg_#{id} = d3.select("##{id}")
72
+ .attr("width", pieSize_#{id})
73
+ .attr("height", pieSize_#{id})
74
+ .attr("class", "pie")
75
+ .append("g")
76
+ .attr("transform", "translate(" + pieSize_#{id} / 2 + "," + pieSize_#{id} / 2 + ")");
77
+
78
+ svg_#{id}.selectAll("arcs")
79
+ .data(data_#{id})
80
+ .enter()
81
+ .append('path')
82
+ .attr("class", "arc")
83
+ .attr('fill', (d) => strictnessColor(d.data.key))
84
+ .attr('d', arcGenerator_#{id})
85
+ .on("mouseover", (d) => tooltip.style("opacity", 1))
86
+ .on("mousemove", tooltip_#{id})
87
+ .on("mouseleave", (d) => tooltip.style("opacity", 0));
88
+
89
+ svg_#{id}.selectAll("labels")
90
+ .data(data_#{id})
91
+ .enter()
92
+ .append('text')
93
+ .attr("class", "label")
94
+ .attr("transform", (d) => "translate(" + arcGenerator_#{id}.centroid(d) + ")")
95
+ .filter(d => (d.endAngle - d.startAngle) > 0.25)
96
+ .append("tspan")
97
+ .attr("x", 0)
98
+ .attr("y", -3)
99
+ .text((d) => d.data.value)
100
+ .append("tspan")
101
+ .attr("class", "small")
102
+ .attr("x", 0)
103
+ .attr("y", 13)
104
+ .text((d) => toPercent(d.data.value, sum_#{id}) + "%");
105
+
106
+ svg_#{id}
107
+ .append("text")
108
+ .attr("class", "title")
109
+ .append("tspan")
110
+ .attr("y", 7)
111
+ .text(title_#{id});
112
+ }
113
+
114
+ draw_#{id}();
115
+ window.addEventListener("resize", draw_#{id});
116
+ JS
117
+ end
118
+
119
+ class Sigils < Pie
120
+ extend T::Sig
121
+
122
+ sig { params(id: String, title: String, snapshot: Snapshot).void }
123
+ def initialize(id, title, snapshot)
124
+ super(id, title, snapshot.sigils.select { |_k, v| v })
125
+ end
126
+
127
+ sig { override.returns(String) }
128
+ def tooltip
129
+ <<~JS
130
+ function tooltip_#{id}(d) {
131
+ tooltipPie(d, "typed: " + d.data.key, "files", sum_#{id});
132
+ }
133
+ JS
134
+ end
135
+ end
136
+
137
+ class Calls < Pie
138
+ extend T::Sig
139
+
140
+ sig { params(id: String, title: String, snapshot: Snapshot).void }
141
+ def initialize(id, title, snapshot)
142
+ super(id, title, { true: snapshot.calls_typed, false: snapshot.calls_untyped })
143
+ end
144
+
145
+ sig { override.returns(String) }
146
+ def tooltip
147
+ <<~JS
148
+ function tooltip_#{id}(d) {
149
+ tooltipPie(d, d.data.key == "true" ? " checked" : " unchecked", "calls", sum_#{id})
150
+ }
151
+ JS
152
+ end
153
+ end
154
+
155
+ class Sigs < Pie
156
+ extend T::Sig
157
+
158
+ sig { params(id: String, title: String, snapshot: Snapshot).void }
159
+ def initialize(id, title, snapshot)
160
+ super(id, title, { true: snapshot.methods_with_sig, false: snapshot.methods_without_sig })
161
+ end
162
+
163
+ sig { override.returns(String) }
164
+ def tooltip
165
+ <<~JS
166
+ function tooltip_#{id}(d) {
167
+ tooltipPie(d, (d.data.key == "true" ? " with" : " without") + " a signature", "methods", sum_#{id})
168
+ }
169
+ JS
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,486 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Spoom
7
+ module Coverage
8
+ module D3
9
+ class Timeline < Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ abstract!
14
+
15
+ sig { params(id: String, data: T.untyped, keys: T::Array[String]).void }
16
+ def initialize(id, data, keys)
17
+ super(id, data)
18
+ @keys = keys
19
+ end
20
+
21
+ sig { returns(String) }
22
+ def self.header_style
23
+ <<~CSS
24
+ .domain {
25
+ stroke: transparent;
26
+ }
27
+
28
+ .grid line {
29
+ stroke: #ccc;
30
+ }
31
+
32
+ .axis text {
33
+ font: 12px Arial, sans-serif;
34
+ fill: #333;
35
+ text-anchor: right;
36
+ pointer-events: none;
37
+ }
38
+
39
+ .inverted .grid line {
40
+ stroke: #777;
41
+ }
42
+
43
+ .inverted .axis text {
44
+ fill: #fff;
45
+ }
46
+
47
+ .inverted .axis line {
48
+ stroke: #fff;
49
+ }
50
+ CSS
51
+ end
52
+
53
+ sig { returns(String) }
54
+ def self.header_script
55
+ <<~JS
56
+ var parseVersion = function(version) {
57
+ if (!version) {
58
+ return null;
59
+ }
60
+ return parseFloat(version.replaceAll("0.", ""));
61
+ }
62
+
63
+ function tooltipTimeline(d, kind) {
64
+ moveTooltip(d)
65
+ .html("commit <b>" + d.data.commit + "</b><br>"
66
+ + d3.timeFormat("%y/%m/%d")(parseDate(d.data.timestamp)) + "<br><br>"
67
+ + "<b>typed: " + d.key + "</b><br><br>"
68
+ + "<b>" + (d.data.values[d.key] ? d.data.values[d.key] : 0) + "</b> " + kind +"<br>"
69
+ + "<b>" + toPercent(d.data.values[d.key] ? d.data.values[d.key] : 0, d.data.total) + "%")
70
+ }
71
+ JS
72
+ end
73
+
74
+ sig { override.returns(String) }
75
+ def script
76
+ <<~HTML
77
+ #{tooltip}
78
+
79
+ var data_#{id} = #{@data.to_json};
80
+
81
+ function draw_#{id}() {
82
+ var width_#{id} = document.getElementById("#{id}").clientWidth;
83
+ var height_#{id} = 200;
84
+
85
+ d3.select("##{id}").selectAll("*").remove()
86
+
87
+ var svg_#{id} = d3.select("##{id}")
88
+ .attr("width", width_#{id})
89
+ .attr("height", height_#{id})
90
+
91
+ #{plot}
92
+ }
93
+
94
+ draw_#{id}();
95
+ window.addEventListener("resize", draw_#{id});
96
+ HTML
97
+ end
98
+
99
+ sig { abstract.returns(String) }
100
+ def plot; end
101
+
102
+ sig { returns(String) }
103
+ def x_scale
104
+ <<~HTML
105
+ var xScale_#{id} = d3.scaleTime()
106
+ .range([0, width_#{id}])
107
+ .domain(d3.extent(data_#{id}, (d) => parseDate(d.timestamp)));
108
+
109
+ svg_#{id}.append("g")
110
+ .attr("class", "grid")
111
+ .attr("transform", "translate(0," + height_#{id} + ")")
112
+ .call(d3.axisBottom(xScale_#{id})
113
+ .tickFormat("")
114
+ .tickSize(-height_#{id}))
115
+ HTML
116
+ end
117
+
118
+ sig { returns(String) }
119
+ def x_ticks
120
+ <<~HTML
121
+ svg_#{id}.append("g")
122
+ .attr("class", "axis x")
123
+ .attr("transform", "translate(0," + height_#{id} + ")")
124
+ .call(d3.axisBottom(xScale_#{id})
125
+ .tickFormat(d3.timeFormat("%y/%m/%d"))
126
+ .tickPadding(-15)
127
+ .tickSize(-3));
128
+ HTML
129
+ end
130
+
131
+ sig { params(min: String, max: String, ticks: String).returns(String) }
132
+ def y_scale(min:, max:, ticks:)
133
+ <<~HTML
134
+ var yScale_#{id} = d3.scaleLinear()
135
+ .range([height_#{id}, 0])
136
+ .domain([#{min}, #{max}]);
137
+
138
+ svg_#{id}.append("g")
139
+ .attr("class", "grid")
140
+ .call(d3.axisLeft(yScale_#{id})
141
+ .#{ticks}
142
+ .tickFormat("")
143
+ .tickSize(-width_#{id}))
144
+ HTML
145
+ end
146
+
147
+ sig { params(ticks: String, format: String, padding: Integer).returns(String) }
148
+ def y_ticks(ticks:, format:, padding:)
149
+ <<~HTML
150
+ svg_#{id}.append("g")
151
+ .attr("class", "axis y")
152
+ .call(d3.axisLeft(yScale_#{id})
153
+ .#{ticks}
154
+ .tickSize(-3)
155
+ .tickFormat((d) => #{format})
156
+ .tickPadding(-#{padding}))
157
+ HTML
158
+ end
159
+
160
+ sig { params(y: String, color: String, curve: String).returns(String) }
161
+ def area(y:, color: "#ccc", curve: "curveCatmullRom.alpha(1)")
162
+ <<~HTML
163
+ svg_#{id}.append("path")
164
+ .datum(data_#{id}.filter((d) => #{y}))
165
+ .attr("class", "area")
166
+ .attr("d", d3.area()
167
+ .defined((d) => #{y})
168
+ .x((d) => xScale_#{id}(parseDate(d.timestamp)))
169
+ .y0(yScale_#{id}(0))
170
+ .y1((d) => yScale_#{id}(#{y}))
171
+ .curve(d3.#{curve}))
172
+ .attr("fill", "#{color}")
173
+ .attr("fill-opacity", 0.5)
174
+ HTML
175
+ end
176
+
177
+ sig { params(y: String, color: String, curve: String).returns(String) }
178
+ def line(y:, color: "#ccc", curve: "curveCatmullRom.alpha(1)")
179
+ <<~HTML
180
+ svg_#{id}.append("path")
181
+ .datum(data_#{id}.filter((d) => #{y}))
182
+ .attr("class", "line")
183
+ .attr("d", d3.line()
184
+ .x((d) => xScale_#{id}(parseDate(d.timestamp)))
185
+ .y((d) => yScale_#{id}(#{y}))
186
+ .curve(d3.#{curve}))
187
+ .attr("stroke", "#{color}")
188
+ .attr("stroke-width", 3)
189
+ .attr("fill", "transparent")
190
+ HTML
191
+ end
192
+
193
+ sig { params(y: String).returns(String) }
194
+ def points(y:)
195
+ <<~HTML
196
+ svg_#{id}.selectAll("circle")
197
+ .data(data_#{id})
198
+ .enter()
199
+ .append("circle")
200
+ .attr("class", "dot")
201
+ .attr("r", 3)
202
+ .attr("cx", (d) => xScale_#{id}(parseDate(d.timestamp)))
203
+ .attr("cy", (d, i) => yScale_#{id}(#{y}))
204
+ .attr("fill", "#aaa")
205
+ .on("mouseover", (d) => tooltip.style("opacity", 1))
206
+ .on("mousemove", tooltip_#{id})
207
+ .on("mouseleave", (d) => tooltip.style("opacity", 0));
208
+ HTML
209
+ end
210
+
211
+ class Versions < Timeline
212
+ extend T::Sig
213
+
214
+ sig { params(id: String, snapshots: T::Array[Snapshot]).void }
215
+ def initialize(id, snapshots)
216
+ data = snapshots.map do |snapshot|
217
+ {
218
+ timestamp: snapshot.commit_timestamp,
219
+ commit: snapshot.commit_sha,
220
+ static: snapshot.version_static,
221
+ runtime: snapshot.version_runtime,
222
+ }
223
+ end
224
+ super(id, data, [])
225
+ end
226
+
227
+ sig { override.returns(String) }
228
+ def tooltip
229
+ <<~JS
230
+ function tooltip_#{id}(d) {
231
+ moveTooltip(d)
232
+ .html("commit <b>" + d.commit + "</b><br>"
233
+ + d3.timeFormat("%y/%m/%d")(parseDate(d.timestamp)) + "<br><br>"
234
+ + "static: v<b>" + d.static + "</b><br>"
235
+ + "runtime: v<b>" + d.runtime + "</b><br><br>"
236
+ + "versions from<br>Gemfile.lock")
237
+ }
238
+ JS
239
+ end
240
+
241
+ sig { override.returns(String) }
242
+ def plot
243
+ <<~JS
244
+ #{x_scale}
245
+ #{y_scale(
246
+ min: "d3.min([d3.min(data_#{id}, (d) => parseVersion(d.static)),
247
+ d3.min(data_#{id}, (d) => parseVersion(d.runtime))]) - 0.01",
248
+ max: "d3.max([d3.max(data_#{id}, (d) => parseVersion(d.static)),
249
+ d3.max(data_#{id}, (d) => parseVersion(d.runtime))]) + 0.01",
250
+ ticks: 'ticks(8)'
251
+ )}
252
+ #{line(y: 'parseVersion(d.runtime)', color: '#e83e8c', curve: 'curveStepAfter')}
253
+ #{line(y: 'parseVersion(d.static)', color: '#007bff', curve: 'curveStepAfter')}
254
+ #{points(y: 'parseVersion(d.static)')}
255
+ #{x_ticks}
256
+ #{y_ticks(ticks: 'ticks(4)', format: "'v0.' + d.toFixed(2)", padding: 50)}
257
+ JS
258
+ end
259
+ end
260
+
261
+ class Runtimes < Timeline
262
+ extend T::Sig
263
+
264
+ sig { params(id: String, snapshots: T::Array[Snapshot]).void }
265
+ def initialize(id, snapshots)
266
+ data = snapshots.map do |snapshot|
267
+ {
268
+ timestamp: snapshot.commit_timestamp,
269
+ commit: snapshot.commit_sha,
270
+ runtime: snapshot.duration.to_f / 1000.0 / 1000.0,
271
+ }
272
+ end
273
+ super(id, data, [])
274
+ end
275
+
276
+ sig { override.returns(String) }
277
+ def tooltip
278
+ <<~JS
279
+ function tooltip_#{id}(d) {
280
+ moveTooltip(d)
281
+ .html("commit <b>" + d.commit + "</b><br>"
282
+ + d3.timeFormat("%y/%m/%d")(parseDate(d.timestamp)) + "<br><br>"
283
+ + "<b>" + d.runtime + "</b>s<br><br>"
284
+ + "(sorbet user + system time)")
285
+ }
286
+ JS
287
+ end
288
+
289
+ sig { override.returns(String) }
290
+ def plot
291
+ <<~JS
292
+ #{x_scale}
293
+ #{y_scale(
294
+ min: '0',
295
+ max: "d3.max(data_#{id}, (d) => d.runtime)",
296
+ ticks: 'ticks(10)'
297
+ )}
298
+ #{area(y: 'd.runtime')}
299
+ #{line(y: 'd.runtime')}
300
+ #{points(y: 'd.runtime')}
301
+ #{x_ticks}
302
+ #{y_ticks(ticks: 'ticks(5)', format: 'd.toFixed(2) + "s"', padding: 40)}
303
+ .call(g => g.selectAll(".tick:first-of-type text").remove())
304
+ JS
305
+ end
306
+ end
307
+
308
+ class Stacked < Timeline
309
+ extend T::Sig
310
+ extend T::Helpers
311
+
312
+ abstract!
313
+
314
+ sig { override.returns(String) }
315
+ def script
316
+ <<~JS
317
+ #{tooltip}
318
+
319
+ var data_#{id} = #{@data.to_json};
320
+ var keys_#{id} = #{T.unsafe(@keys).to_json};
321
+
322
+ var stack_#{id} = d3.stack()
323
+ .keys(keys_#{id})
324
+ .value((d, key) => toPercent(d.values[key], d.total));
325
+
326
+ var layers_#{id} = stack_#{id}(data_#{id});
327
+
328
+ var points_#{id} = []
329
+ layers_#{id}.forEach(function(d) {
330
+ d.forEach(function(p) {
331
+ p.key = d.key
332
+ points_#{id}.push(p);
333
+ });
334
+ })
335
+
336
+ function draw_#{id}() {
337
+ var width_#{id} = document.getElementById("#{id}").clientWidth;
338
+ var height_#{id} = 200;
339
+
340
+ d3.select("##{id}").selectAll("*").remove()
341
+
342
+ var svg_#{id} = d3.select("##{id}")
343
+ .attr("class", "inverted")
344
+ .attr("width", width_#{id})
345
+ .attr("height", height_#{id});
346
+
347
+ #{plot}
348
+ }
349
+
350
+ draw_#{id}();
351
+ window.addEventListener("resize", draw_#{id});
352
+ JS
353
+ end
354
+
355
+ sig { override.returns(String) }
356
+ def plot
357
+ <<~JS
358
+ #{x_scale}
359
+ #{y_scale(min: '0', max: '100', ticks: 'tickValues([0, 25, 50, 75, 100])')}
360
+ #{line(y: 'd.data.timestamp')}
361
+ #{x_ticks}
362
+ #{y_ticks(ticks: 'tickValues([25, 50, 75])', format: "d + '%'", padding: 30)}
363
+ JS
364
+ end
365
+
366
+ sig { override.params(y: String, color: String, curve: String).returns(String) }
367
+ def line(y:, color: 'strictnessColor(d.key)', curve: 'curveCatmullRom.alpha(1)')
368
+ <<~JS
369
+ var area_#{id} = d3.area()
370
+ .x((d) => xScale_#{id}(parseDate(#{y})))
371
+ .y0((d) => yScale_#{id}(d[0]))
372
+ .y1((d) => yScale_#{id}(d[1]))
373
+ .curve(d3.#{curve});
374
+
375
+ var layer = svg_#{id}.selectAll(".layer")
376
+ .data(layers_#{id})
377
+ .enter().append("g")
378
+ .attr("class", "layer")
379
+ .attr("fill", (d, i) => #{color})
380
+
381
+ layer.append("path")
382
+ .attr("class", "area")
383
+ .attr("d", area_#{id})
384
+ .attr("fill", (d) => strictnessColor(d.key))
385
+ .attr("fill-opacity", 0.9)
386
+
387
+ svg_#{id}.selectAll("circle")
388
+ .data(points_#{id})
389
+ .enter()
390
+ .append("circle")
391
+ .attr("class", "dot")
392
+ .attr("r", 2)
393
+ .attr("cx", (d) => xScale_#{id}(parseDate(#{y})))
394
+ .attr("cy", (d, i) => yScale_#{id}(d[1]))
395
+ .attr("fill", "#fff")
396
+ .on("mouseover", (d) => tooltip.style("opacity", 1))
397
+ .on("mousemove", tooltip_#{id})
398
+ .on("mouseleave", (d) => tooltip.style("opacity", 0));
399
+ JS
400
+ end
401
+ end
402
+
403
+ class Sigils < Stacked
404
+ extend T::Sig
405
+
406
+ sig { params(id: String, snapshots: T::Array[Snapshot]).void }
407
+ def initialize(id, snapshots)
408
+ keys = Snapshot::STRICTNESSES
409
+ data = snapshots.map do |snapshot|
410
+ {
411
+ timestamp: snapshot.commit_timestamp,
412
+ commit: snapshot.commit_sha,
413
+ total: snapshot.files,
414
+ values: snapshot.sigils,
415
+ }
416
+ end
417
+ super(id, data, keys)
418
+ end
419
+
420
+ sig { override.returns(String) }
421
+ def tooltip
422
+ <<~JS
423
+ function tooltip_#{id}(d) {
424
+ tooltipTimeline(d, "files");
425
+ }
426
+ JS
427
+ end
428
+ end
429
+
430
+ class Calls < Stacked
431
+ extend T::Sig
432
+
433
+ sig { params(id: String, snapshots: T::Array[Snapshot]).void }
434
+ def initialize(id, snapshots)
435
+ keys = ['false', 'true']
436
+ data = snapshots.map do |snapshot|
437
+ {
438
+ timestamp: snapshot.commit_timestamp,
439
+ commit: snapshot.commit_sha,
440
+ total: snapshot.calls_typed + snapshot.calls_untyped,
441
+ values: { true: snapshot.calls_typed, false: snapshot.calls_untyped },
442
+ }
443
+ end
444
+ super(id, data, keys)
445
+ end
446
+
447
+ sig { override.returns(String) }
448
+ def tooltip
449
+ <<~JS
450
+ function tooltip_#{id}(d) {
451
+ tooltipTimeline(d, "calls");
452
+ }
453
+ JS
454
+ end
455
+ end
456
+
457
+ class Sigs < Stacked
458
+ extend T::Sig
459
+
460
+ sig { params(id: String, snapshots: T::Array[Snapshot]).void }
461
+ def initialize(id, snapshots)
462
+ keys = ['false', 'true']
463
+ data = snapshots.map do |snapshot|
464
+ {
465
+ timestamp: snapshot.commit_timestamp,
466
+ commit: snapshot.commit_sha,
467
+ total: snapshot.methods_with_sig + snapshot.methods_without_sig,
468
+ values: { true: snapshot.methods_with_sig, false: snapshot.methods_without_sig },
469
+ }
470
+ end
471
+ super(id, data, keys)
472
+ end
473
+
474
+ sig { override.returns(String) }
475
+ def tooltip
476
+ <<~JS
477
+ function tooltip_#{id}(d) {
478
+ tooltipTimeline(d, "methods");
479
+ }
480
+ JS
481
+ end
482
+ end
483
+ end
484
+ end
485
+ end
486
+ end