# 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