Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/routes/wpt_compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,16 @@ fn CompareHistoryChart(
"Percentage of subtests passing over time, one master run per day. Every engine's
percentage is relative to the same denominator: the total number of subtests known
to any engine in the latest runs (so lines are not distorted by tests being added
to WPT, and an engine that skips tests is not flattered for it)."
to WPT, and an engine that skips tests is not flattered for it). The dashed line
shows the maximum total subtest count across browsers, using the same denominator
and percentage scale as the passing scores, and each browser's latest available
run at that date."
}
ChartRangeSelector {
current_range: range,
base_path: if area.is_empty() { "/wpt".to_string() } else { format!("/wpt/{area}") },
}
HistoryLineChart { lines: history, range, height: 320.0 }
HistoryLineChart { lines: history, range, height: 320.0, show_subtest_totals: true }
}
}
}
Expand Down
154 changes: 132 additions & 22 deletions src/routes/wpt_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,65 @@ pub struct ChartLine {
}

struct Series {
label: String,
color: &'static str,
metric: ChartMetric,
points: Vec<(f64, f64)>,
}

#[derive(Clone, Copy, PartialEq)]
enum ChartMetric {
PassPercent,
SubtestTotal,
}

fn max_subtest_line(lines: &[ChartLine]) -> ChartLine {
let mut updates = Vec::new();
let mut subtest_total = None;
for (browser, line) in lines.iter().enumerate() {
let Some(area_idx) = line.history.focus_areas.iter().position(|a| *a == line.series.area) else {
continue;
};
subtest_total = subtest_total.max(line.history.subtest_total(area_idx));
for run in &line.history.runs {
if let (Some(x), Some(Some((_, _, total, _)))) =
(parse_date(&run.date), run.scores.get(area_idx))
{
updates.push((x, &run.date, browser, *total));
}
}
}
updates.sort_by(|a, b| a.0.total_cmp(&b.0));

// Carry each browser's most recent count forward between its runs.
let mut totals = vec![0; lines.len()];
let mut runs = Vec::new();
for (i, &(x, date, browser, total)) in updates.iter().enumerate() {
totals[browser] = total;
if updates.get(i + 1).is_some_and(|next| next.0 == x) {
continue;
}
let max = totals.iter().copied().max().unwrap_or(0);
runs.push(HistoryRun {
date: date.clone(),
product_revision: String::new(),
commit_message: None,
scores: vec![Some((0, 0.0, max, 0))],
});
}
ChartLine {
history: ArcWptHistory(Arc::new(WptHistory {
focus_areas: vec![String::new()],
union_totals: vec![subtest_total],
runs,
})),
series: ChartSeries {
area: String::new(),
label: "Maximum across browsers".into(),
color: "#666666",
},
}
}

/// The run identifier shown in a tooltip: browser versions as-is, long
/// nightly versions and commit shas shortened
fn short_revision(revision: &str) -> &str {
Expand All @@ -213,7 +267,12 @@ fn subtest_pass_percent(run: &HistoryRun, area_idx: usize, subtest_total: u32) -
Some(total_subtests_passed as f64 / subtest_total as f64 * 100.0)
}

fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option<Series> {
fn area_series(
history: &WptHistory,
min_x: f64,
spec: &ChartSeries,
metric: ChartMetric,
) -> Option<Series> {
let area_idx = history.focus_areas.iter().position(|a| *a == spec.area)?;
let subtest_total = history.subtest_total(area_idx)?;
let points: Vec<(f64, f64)> = history
Expand All @@ -222,7 +281,15 @@ fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option<S
.filter_map(|run| {
Some((
parse_date(&run.date)?,
subtest_pass_percent(run, area_idx, subtest_total)?,
match metric {
ChartMetric::PassPercent => {
subtest_pass_percent(run, area_idx, subtest_total)?
}
ChartMetric::SubtestTotal => {
let (_, _, total, _) = (*run.scores.get(area_idx)?)?;
total as f64 / subtest_total as f64 * 100.0
}
},
))
})
.filter(|(x, _)| *x >= min_x)
Expand All @@ -231,8 +298,8 @@ fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option<S
return None;
}
Some(Series {
label: spec.label.clone(),
color: spec.color,
metric,
points,
})
}
Expand All @@ -241,6 +308,7 @@ fn polyline_points(
points: &[(f64, f64)],
x_min: f64,
x_max: f64,
y_max: f64,
plot: (f64, f64, f64, f64), // (x, y, width, height) of plot area
) -> String {
if points.is_empty() {
Expand All @@ -255,7 +323,7 @@ fn polyline_points(
let mut out = String::new();
let mut plot_point = |&(x, y): &(f64, f64)| {
let sx = px + (x - x_min) / x_range * pw;
let sy = py + (1.0 - y / 100.0) * ph;
let sy = py + (1.0 - y / y_max) * ph;
write!(out, "{sx:.1},{sy:.1} ").unwrap();
};
for point in points.iter().step_by(stride) {
Expand Down Expand Up @@ -392,7 +460,7 @@ pub fn WptHistoryChart(

/// Per-run tooltip data for one line: the runs from just before the visible
/// range onwards, with the line's `[passed, total]` subtest counts
fn tooltip_runs(line: &ChartLine, min_x: f64) -> serde_json::Value {
fn tooltip_runs(line: &ChartLine, min_x: f64, metric: ChartMetric) -> serde_json::Value {
let history = &line.history;
let area_idx = history
.focus_areas
Expand All @@ -415,7 +483,9 @@ fn tooltip_runs(line: &ChartLine, min_x: f64) -> serde_json::Value {
let x = parse_date(&run.date)?;
let value = area_idx
.and_then(|idx| *run.scores.get(idx)?)
.filter(|(_, _, total_subtests, _)| *total_subtests != 0)
.filter(|(_, _, total_subtests, _)| {
metric == ChartMetric::SubtestTotal || *total_subtests != 0
})
.map(|(_, _, total_subtests, total_subtests_passed)| {
serde_json::json!([total_subtests_passed, total_subtests])
})
Expand All @@ -432,6 +502,7 @@ fn tooltip_runs(line: &ChartLine, min_x: f64) -> serde_json::Value {
serde_json::json!({
"name": line.series.label,
"color": line.series.color,
"metric": if metric == ChartMetric::SubtestTotal { "total" } else { "percent" },
"total": subtest_total,
"first": first_visible - start,
"runs": runs,
Expand All @@ -444,17 +515,29 @@ pub fn HistoryLineChart(
lines: Vec<ChartLine>,
range: ChartRange,
#[props(default = 440.0)] height: f64,
#[props(default = false)] show_subtest_totals: bool,
) -> Element {
const WIDTH: f64 = 900.0;
let plot: (f64, f64, f64, f64) = (50.0, 15.0, WIDTH - 65.0, height - 55.0);
let plot: (f64, f64, f64, f64) = if show_subtest_totals {
(50.0, 15.0, WIDTH - 65.0, height - 75.0)
} else {
(50.0, 15.0, WIDTH - 65.0, height - 55.0)
};
let (px, py, pw, ph) = plot;

let min_x = range.min_x_of(lines.iter().map(|line| &*line.history));
let series: Vec<Series> = lines
let total_line = show_subtest_totals.then(|| max_subtest_line(&lines));
let chart_lines: Vec<_> = lines
.iter()
.filter_map(|line| area_series(&line.history, min_x, &line.series))
.map(|line| (line, ChartMetric::PassPercent))
.chain(total_line.iter().map(|line| (line, ChartMetric::SubtestTotal)))
.collect();
let series: Vec<Series> = chart_lines
.iter()
.filter_map(|(line, metric)| {
area_series(&line.history, min_x, &line.series, *metric)
})
.collect();

let x_min = series
.iter()
.filter_map(|s| s.points.first().map(|p| p.0))
Expand All @@ -470,17 +553,19 @@ pub fn HistoryLineChart(

let ticks = month_ticks(x_min, x_max);
let x_range = (x_max - x_min).max(f64::EPSILON);
let legend_step = (pw / series.len().max(1) as f64).min(140.0);
let legend_step = pw / lines.len().max(1) as f64;
let legend_step = if show_subtest_totals { legend_step } else { legend_step.min(140.0) };
let legend_y = height - if show_subtest_totals { 30.0 } else { 10.0 };

// Per-run data for the hover tooltip (a JS progressive enhancement)
let tooltip_data = serde_json::json!({
"width": WIDTH,
"plot": [px, py, pw, ph],
"xMin": x_min,
"xMax": x_max,
"series": lines
"series": chart_lines
.iter()
.map(|line| tooltip_runs(line, min_x))
.map(|(line, metric)| tooltip_runs(line, min_x, *metric))
.collect::<Vec<_>>(),
})
.to_string()
Expand Down Expand Up @@ -542,29 +627,54 @@ pub fn HistoryLineChart(
// Data series
for (i, s) in series.iter().enumerate() {
polyline {
points: polyline_points(&s.points, x_min, x_max, plot),
points: polyline_points(&s.points, x_min, x_max, 100.0, plot),
fill: "none",
stroke: s.color,
stroke_width: if i == 0 { "2.5" } else { "1.5" },
stroke_dasharray: if s.metric == ChartMetric::SubtestTotal { "5,4" } else { "none" },
}
}

// Legend, spread across the plot width when there are many series
for (i, s) in series.iter().enumerate() {
for (i, line_spec) in lines.iter().enumerate() {
line {
x1: "{px + 10.0 + (i as f64) * legend_step}",
x2: "{px + 34.0 + (i as f64) * legend_step}",
y1: "{height - 10.0}",
y2: "{height - 10.0}",
stroke: s.color,
y1: "{legend_y}",
y2: "{legend_y}",
stroke: line_spec.series.color,
stroke_width: "3",
}
text {
x: "{px + 40.0 + (i as f64) * legend_step}",
y: "{height - 6.0}",
y: "{legend_y + 4.0}",
font_size: "12",
fill: "#333",
{s.label.clone()}
{line_spec.series.label.clone()}
}
}
if show_subtest_totals {
for (i, metric) in [ChartMetric::PassPercent, ChartMetric::SubtestTotal].iter().enumerate() {
line {
x1: "{px + 10.0 + i as f64 * 220.0}",
x2: "{px + 34.0 + i as f64 * 220.0}",
y1: "{height - 10.0}",
y2: "{height - 10.0}",
stroke: "#666",
stroke_width: "2",
stroke_dasharray: if *metric == ChartMetric::SubtestTotal { "5,4" } else { "none" },
}
text {
x: "{px + 40.0 + i as f64 * 220.0}",
y: "{height - 6.0}",
font_size: "12",
fill: "#333",
if *metric == ChartMetric::SubtestTotal {
"Max total subtests"
} else {
"Passing subtests"
}
}
}
}
}
Expand Down Expand Up @@ -638,7 +748,7 @@ pub fn WptHistorySparklines(history: ArcWptHistory, range: ChartRange) -> Elemen
width: "100%",
style: "border: 1px solid #ddd",
polyline {
points: polyline_points(&points, x_min, x_max, PLOT),
points: polyline_points(&points, x_min, x_max, 100.0, PLOT),
fill: "none",
stroke: "#7986cb",
stroke_width: "1.5",
Expand Down
53 changes: 33 additions & 20 deletions static/wpt-history-tooltip.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Hover tooltip for WPT history charts (progressive enhancement): reads run
// data from each chart's JSON blob and shows the nearest run of the nearest
// line: its revision, commit message, and pass percentage. Each series has
// its own list of runs (lines for different products are recorded on
// different dates).
// line: its revision, commit message, and pass percentage or subtest total.
// Each series has its own list of runs (lines for different products are
// recorded on different dates).
document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dataEl) {
if (dataEl.dataset.tooltipInit) return;
dataEl.dataset.tooltipInit = "1";
Expand Down Expand Up @@ -34,14 +34,15 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat
svg.appendChild(dot);

var px = data.plot[0], py = data.plot[1], pw = data.plot[2], ph = data.plot[3];
var xRange = data.xMax - data.xMin;
var xRange = Math.max(data.xMax - data.xMin, Number.EPSILON);

function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
}

function screenX(run) { return px + ((run.x - data.xMin) / xRange) * pw; }
function screenY(s, run) { return py + (1 - run.v[0] / s.total) * ph; }
function value(s, run) { return run.v[s.metric === "total" ? 1 : 0]; }
function screenY(s, run) { return py + (1 - value(s, run) / s.total) * ph; }

// Nearest hoverable run of a series to the x position `x` (in data
// units); runs before `s.first` only serve as deltas
Expand Down Expand Up @@ -122,7 +123,7 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat
var cur = s.runs[i].v;
if (cur == null) return false;
var p = i > 0 ? s.runs[i - 1].v : null;
return p == null || cur[0] !== p[0] || cur[1] !== p[1];
return p == null || (s.metric === "total" ? cur[1] !== p[1] : cur[0] !== p[0] || cur[1] !== p[1]);
}
var snapped = -1, snappedDist = SNAP_PX;
for (var j = s.first; j < s.runs.length; j++) {
Expand All @@ -143,24 +144,36 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat
dot.setAttribute("fill", s.color);
dot.style.display = "";

var html = "<div style='font-weight:bold'>" + esc(run.rev) + " (" + esc(run.d) + ")</div>";
var html = "<div style='font-weight:bold'>" +
(run.rev ? esc(run.rev) + " (" + esc(run.d) + ")" : esc(run.d)) + "</div>";
if (run.msg) {
html += "<div style='margin-bottom:4px;white-space:nowrap;overflow:hidden;" +
"text-overflow:ellipsis'>" + esc(run.msg) + "</div>";
}
var pass = run.v[0], total = s.total;
html += "<div><span style='color:" + s.color + "'>\u25CF</span> " +
esc(s.name) + ": " + (100 * pass / total).toFixed(1) + "% (" +
pass.toLocaleString() + "/" + total.toLocaleString() + ")</div>";

// Change relative to the previous run
if (prev && prev.v != null) {
var dPass = pass - prev.v[0];
var dPct = 100 * (dPass / total);
var sign = dPass > 0 ? "+" : "";
var color = dPass > 0 ? "#2e7d32" : (dPass < 0 ? "#c62828" : "#666");
html += "<div style='color:" + color + "'>Change: " + sign +
dPass.toLocaleString() + " (" + sign + dPct.toFixed(2) + "%)</div>";
if (s.metric === "total") {
html += "<div><span style='color:" + s.color + "'>\u2504</span> " +
esc(s.name) + ": " + (100 * run.v[1] / s.total).toFixed(1) + "% (" +
run.v[1].toLocaleString() + " total subtests)</div>";
if (prev && prev.v != null) {
var dTotal = run.v[1] - prev.v[1];
html += "<div>Change: " + (dTotal > 0 ? "+" : "") +
dTotal.toLocaleString() + " subtests</div>";
}
} else {
var pass = run.v[0], total = s.total;
html += "<div><span style='color:" + s.color + "'>\u25CF</span> " +
esc(s.name) + ": " + (100 * pass / total).toFixed(1) + "% (" +
pass.toLocaleString() + "/" + total.toLocaleString() + ")</div>";

// Change relative to the previous run
if (prev && prev.v != null) {
var dPass = pass - prev.v[0];
var dPct = 100 * (dPass / total);
var sign = dPass > 0 ? "+" : "";
var color = dPass > 0 ? "#2e7d32" : (dPass < 0 ? "#c62828" : "#666");
html += "<div style='color:" + color + "'>Change: " + sign +
dPass.toLocaleString() + " (" + sign + dPct.toFixed(2) + "%)</div>";
}
}
tip.innerHTML = html;
tip.style.display = "block";
Expand Down
Loading