# typed: strict
# frozen_string_literal: true
require_relative "base"
module Spoom
module Coverage
module D3
class Timeline < Base
extend T::Sig
extend T::Helpers
abstract!
sig { params(id: String, data: T.untyped, keys: T::Array[String]).void }
def initialize(id, data, keys)
super(id, data)
@keys = keys
end
sig { returns(String) }
def self.header_style
<<~CSS
.domain {
stroke: transparent;
}
.grid line {
stroke: #ccc;
}
.axis text {
font: 12px Arial, sans-serif;
fill: #333;
text-anchor: right;
pointer-events: none;
}
.inverted .grid line {
stroke: #777;
}
.inverted .axis text {
fill: #fff;
}
.inverted .axis line {
stroke: #fff;
}
CSS
end
sig { returns(String) }
def self.header_script
<<~JS
var parseVersion = function(version) {
if (!version) {
return null;
}
return parseFloat(version.replaceAll("0.", ""));
}
function tooltipTimeline(d, kind) {
moveTooltip(d)
.html("commit " + d.data.commit + "
"
+ d3.timeFormat("%y/%m/%d")(parseDate(d.data.timestamp)) + "
"
+ "typed: " + d.key + "
"
+ "" + (d.data.values[d.key] ? d.data.values[d.key] : 0) + " " + kind +"
"
+ "" + toPercent(d.data.values[d.key] ? d.data.values[d.key] : 0, d.data.total) + "%")
}
JS
end
sig { override.returns(String) }
def script
<<~HTML
#{tooltip}
var data_#{id} = #{@data.to_json};
function draw_#{id}() {
var width_#{id} = document.getElementById("#{id}").clientWidth;
var height_#{id} = 200;
d3.select("##{id}").selectAll("*").remove()
var svg_#{id} = d3.select("##{id}")
.attr("width", width_#{id})
.attr("height", height_#{id})
#{plot}
}
draw_#{id}();
window.addEventListener("resize", draw_#{id});
HTML
end
sig { abstract.returns(String) }
def plot; end
sig { returns(String) }
def x_scale
<<~HTML
var xScale_#{id} = d3.scaleTime()
.range([0, width_#{id}])
.domain(d3.extent(data_#{id}, (d) => parseDate(d.timestamp)));
svg_#{id}.append("g")
.attr("class", "grid")
.attr("transform", "translate(0," + height_#{id} + ")")
.call(d3.axisBottom(xScale_#{id})
.tickFormat("")
.tickSize(-height_#{id}))
HTML
end
sig { returns(String) }
def x_ticks
<<~HTML
svg_#{id}.append("g")
.attr("class", "axis x")
.attr("transform", "translate(0," + height_#{id} + ")")
.call(d3.axisBottom(xScale_#{id})
.tickFormat(d3.timeFormat("%y/%m/%d"))
.tickPadding(-15)
.tickSize(-3));
HTML
end
sig { params(min: String, max: String, ticks: String).returns(String) }
def y_scale(min:, max:, ticks:)
<<~HTML
var yScale_#{id} = d3.scaleLinear()
.range([height_#{id}, 0])
.domain([#{min}, #{max}]);
svg_#{id}.append("g")
.attr("class", "grid")
.call(d3.axisLeft(yScale_#{id})
.#{ticks}
.tickFormat("")
.tickSize(-width_#{id}))
HTML
end
sig { params(ticks: String, format: String, padding: Integer).returns(String) }
def y_ticks(ticks:, format:, padding:)
<<~HTML
svg_#{id}.append("g")
.attr("class", "axis y")
.call(d3.axisLeft(yScale_#{id})
.#{ticks}
.tickSize(-3)
.tickFormat((d) => #{format})
.tickPadding(-#{padding}))
HTML
end
sig { params(y: String, color: String, curve: String).returns(String) }
def area(y:, color: "#ccc", curve: "curveCatmullRom.alpha(1)")
<<~HTML
svg_#{id}.append("path")
.datum(data_#{id}.filter((d) => #{y}))
.attr("class", "area")
.attr("d", d3.area()
.defined((d) => #{y})
.x((d) => xScale_#{id}(parseDate(d.timestamp)))
.y0(yScale_#{id}(0))
.y1((d) => yScale_#{id}(#{y}))
.curve(d3.#{curve}))
.attr("fill", "#{color}")
.attr("fill-opacity", 0.5)
HTML
end
sig { params(y: String, color: String, curve: String).returns(String) }
def line(y:, color: "#ccc", curve: "curveCatmullRom.alpha(1)")
<<~HTML
svg_#{id}.append("path")
.datum(data_#{id}.filter((d) => #{y}))
.attr("class", "line")
.attr("d", d3.line()
.x((d) => xScale_#{id}(parseDate(d.timestamp)))
.y((d) => yScale_#{id}(#{y}))
.curve(d3.#{curve}))
.attr("stroke", "#{color}")
.attr("stroke-width", 3)
.attr("fill", "transparent")
HTML
end
sig { params(y: String).returns(String) }
def points(y:)
<<~HTML
svg_#{id}.selectAll("circle")
.data(data_#{id})
.enter()
.append("circle")
.attr("class", "dot")
.attr("r", 3)
.attr("cx", (d) => xScale_#{id}(parseDate(d.timestamp)))
.attr("cy", (d, i) => yScale_#{id}(#{y}))
.attr("fill", "#aaa")
.on("mouseover", (d) => tooltip.style("opacity", 1))
.on("mousemove", tooltip_#{id})
.on("mouseleave", (d) => tooltip.style("opacity", 0));
HTML
end
class Versions < Timeline
extend T::Sig
sig { params(id: String, snapshots: T::Array[Snapshot]).void }
def initialize(id, snapshots)
data = snapshots.map do |snapshot|
{
timestamp: snapshot.commit_timestamp,
commit: snapshot.commit_sha,
static: snapshot.version_static,
runtime: snapshot.version_runtime,
}
end
super(id, data, [])
end
sig { override.returns(String) }
def tooltip
<<~JS
function tooltip_#{id}(d) {
moveTooltip(d)
.html("commit " + d.commit + "
"
+ d3.timeFormat("%y/%m/%d")(parseDate(d.timestamp)) + "
"
+ "static: v" + d.static + "
"
+ "runtime: v" + d.runtime + "
"
+ "versions from
Gemfile.lock")
}
JS
end
sig { override.returns(String) }
def plot
<<~JS
#{x_scale}
#{y_scale(
min: "d3.min([d3.min(data_#{id}, (d) => parseVersion(d.static)),
d3.min(data_#{id}, (d) => parseVersion(d.runtime))]) - 0.01",
max: "d3.max([d3.max(data_#{id}, (d) => parseVersion(d.static)),
d3.max(data_#{id}, (d) => parseVersion(d.runtime))]) + 0.01",
ticks: 'ticks(8)'
)}
#{line(y: 'parseVersion(d.runtime)', color: '#e83e8c', curve: 'curveStepAfter')}
#{line(y: 'parseVersion(d.static)', color: '#007bff', curve: 'curveStepAfter')}
#{points(y: 'parseVersion(d.static)')}
#{x_ticks}
#{y_ticks(ticks: 'ticks(4)', format: "'v0.' + d.toFixed(2)", padding: 50)}
JS
end
end
class Runtimes < Timeline
extend T::Sig
sig { params(id: String, snapshots: T::Array[Snapshot]).void }
def initialize(id, snapshots)
data = snapshots.map do |snapshot|
{
timestamp: snapshot.commit_timestamp,
commit: snapshot.commit_sha,
runtime: snapshot.duration.to_f / 1000.0 / 1000.0,
}
end
super(id, data, [])
end
sig { override.returns(String) }
def tooltip
<<~JS
function tooltip_#{id}(d) {
moveTooltip(d)
.html("commit " + d.commit + "
"
+ d3.timeFormat("%y/%m/%d")(parseDate(d.timestamp)) + "
"
+ "" + d.runtime + "s
"
+ "(sorbet user + system time)")
}
JS
end
sig { override.returns(String) }
def plot
<<~JS
#{x_scale}
#{y_scale(
min: '0',
max: "d3.max(data_#{id}, (d) => d.runtime)",
ticks: 'ticks(10)'
)}
#{area(y: 'd.runtime')}
#{line(y: 'd.runtime')}
#{points(y: 'd.runtime')}
#{x_ticks}
#{y_ticks(ticks: 'ticks(5)', format: 'd.toFixed(2) + "s"', padding: 40)}
.call(g => g.selectAll(".tick:first-of-type text").remove())
JS
end
end
class Stacked < Timeline
extend T::Sig
extend T::Helpers
abstract!
sig { override.returns(String) }
def script
<<~JS
#{tooltip}
var data_#{id} = #{@data.to_json};
var keys_#{id} = #{T.unsafe(@keys).to_json};
var stack_#{id} = d3.stack()
.keys(keys_#{id})
.value((d, key) => toPercent(d.values[key], d.total));
var layers_#{id} = stack_#{id}(data_#{id});
var points_#{id} = []
layers_#{id}.forEach(function(d) {
d.forEach(function(p) {
p.key = d.key
points_#{id}.push(p);
});
})
function draw_#{id}() {
var width_#{id} = document.getElementById("#{id}").clientWidth;
var height_#{id} = 200;
d3.select("##{id}").selectAll("*").remove()
var svg_#{id} = d3.select("##{id}")
.attr("class", "inverted")
.attr("width", width_#{id})
.attr("height", height_#{id});
#{plot}
}
draw_#{id}();
window.addEventListener("resize", draw_#{id});
JS
end
sig { override.returns(String) }
def plot
<<~JS
#{x_scale}
#{y_scale(min: '0', max: '100', ticks: 'tickValues([0, 25, 50, 75, 100])')}
#{line(y: 'd.data.timestamp')}
#{x_ticks}
#{y_ticks(ticks: 'tickValues([25, 50, 75])', format: "d + '%'", padding: 30)}
JS
end
sig { override.params(y: String, color: String, curve: String).returns(String) }
def line(y:, color: 'strictnessColor(d.key)', curve: 'curveCatmullRom.alpha(1)')
<<~JS
var area_#{id} = d3.area()
.x((d) => xScale_#{id}(parseDate(#{y})))
.y0((d) => yScale_#{id}(d[0]))
.y1((d) => yScale_#{id}(d[1]))
.curve(d3.#{curve});
var layer = svg_#{id}.selectAll(".layer")
.data(layers_#{id})
.enter().append("g")
.attr("class", "layer")
.attr("fill", (d, i) => #{color})
layer.append("path")
.attr("class", "area")
.attr("d", area_#{id})
.attr("fill", (d) => strictnessColor(d.key))
.attr("fill-opacity", 0.9)
svg_#{id}.selectAll("circle")
.data(points_#{id})
.enter()
.append("circle")
.attr("class", "dot")
.attr("r", 2)
.attr("cx", (d) => xScale_#{id}(parseDate(#{y})))
.attr("cy", (d, i) => yScale_#{id}(d[1]))
.attr("fill", "#fff")
.on("mouseover", (d) => tooltip.style("opacity", 1))
.on("mousemove", tooltip_#{id})
.on("mouseleave", (d) => tooltip.style("opacity", 0));
JS
end
end
class Sigils < Stacked
extend T::Sig
sig { params(id: String, snapshots: T::Array[Snapshot]).void }
def initialize(id, snapshots)
keys = Snapshot::STRICTNESSES
data = snapshots.map do |snapshot|
{
timestamp: snapshot.commit_timestamp,
commit: snapshot.commit_sha,
total: snapshot.files,
values: snapshot.sigils,
}
end
super(id, data, keys)
end
sig { override.returns(String) }
def tooltip
<<~JS
function tooltip_#{id}(d) {
tooltipTimeline(d, "files");
}
JS
end
end
class Calls < Stacked
extend T::Sig
sig { params(id: String, snapshots: T::Array[Snapshot]).void }
def initialize(id, snapshots)
keys = ['false', 'true']
data = snapshots.map do |snapshot|
{
timestamp: snapshot.commit_timestamp,
commit: snapshot.commit_sha,
total: snapshot.calls_typed + snapshot.calls_untyped,
values: { true: snapshot.calls_typed, false: snapshot.calls_untyped },
}
end
super(id, data, keys)
end
sig { override.returns(String) }
def tooltip
<<~JS
function tooltip_#{id}(d) {
tooltipTimeline(d, "calls");
}
JS
end
end
class Sigs < Stacked
extend T::Sig
sig { params(id: String, snapshots: T::Array[Snapshot]).void }
def initialize(id, snapshots)
keys = ['false', 'true']
data = snapshots.map do |snapshot|
{
timestamp: snapshot.commit_timestamp,
commit: snapshot.commit_sha,
total: snapshot.methods_with_sig + snapshot.methods_without_sig,
values: { true: snapshot.methods_with_sig, false: snapshot.methods_without_sig },
}
end
super(id, data, keys)
end
sig { override.returns(String) }
def tooltip
<<~JS
function tooltip_#{id}(d) {
tooltipTimeline(d, "methods");
}
JS
end
end
end
end
end
end