diff --git a/src/routes/wpt_compare.rs b/src/routes/wpt_compare.rs index fdf12c4..086679b 100644 --- a/src/routes/wpt_compare.rs +++ b/src/routes/wpt_compare.rs @@ -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 } } } } diff --git a/src/routes/wpt_history.rs b/src/routes/wpt_history.rs index 2d8ffb4..212b463 100644 --- a/src/routes/wpt_history.rs +++ b/src/routes/wpt_history.rs @@ -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 { @@ -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 { +fn area_series( + history: &WptHistory, + min_x: f64, + spec: &ChartSeries, + metric: ChartMetric, +) -> Option { 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 @@ -222,7 +281,15 @@ fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option { + 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) @@ -231,8 +298,8 @@ fn area_series(history: &WptHistory, min_x: f64, spec: &ChartSeries) -> Option String { if points.is_empty() { @@ -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) { @@ -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 @@ -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]) }) @@ -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, @@ -444,17 +515,29 @@ pub fn HistoryLineChart( lines: Vec, 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 = 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 = 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)) @@ -470,7 +553,9 @@ 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!({ @@ -478,9 +563,9 @@ pub fn HistoryLineChart( "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::>(), }) .to_string() @@ -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" + } + } } } } @@ -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", diff --git a/static/wpt-history-tooltip.js b/static/wpt-history-tooltip.js index ae0cc22..3f16c3d 100644 --- a/static/wpt-history-tooltip.js +++ b/static/wpt-history-tooltip.js @@ -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"; @@ -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, "&").replace(/ 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++) { @@ -143,24 +144,36 @@ document.querySelectorAll("script[data-wpt-history-data]").forEach(function (dat dot.setAttribute("fill", s.color); dot.style.display = ""; - var html = "
" + esc(run.rev) + " (" + esc(run.d) + ")
"; + var html = "
" + + (run.rev ? esc(run.rev) + " (" + esc(run.d) + ")" : esc(run.d)) + "
"; if (run.msg) { html += "
" + esc(run.msg) + "
"; } - var pass = run.v[0], total = s.total; - html += "
\u25CF " + - esc(s.name) + ": " + (100 * pass / total).toFixed(1) + "% (" + - pass.toLocaleString() + "/" + total.toLocaleString() + ")
"; - - // 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 += "
Change: " + sign + - dPass.toLocaleString() + " (" + sign + dPct.toFixed(2) + "%)
"; + if (s.metric === "total") { + html += "
\u2504 " + + esc(s.name) + ": " + (100 * run.v[1] / s.total).toFixed(1) + "% (" + + run.v[1].toLocaleString() + " total subtests)
"; + if (prev && prev.v != null) { + var dTotal = run.v[1] - prev.v[1]; + html += "
Change: " + (dTotal > 0 ? "+" : "") + + dTotal.toLocaleString() + " subtests
"; + } + } else { + var pass = run.v[0], total = s.total; + html += "
\u25CF " + + esc(s.name) + ": " + (100 * pass / total).toFixed(1) + "% (" + + pass.toLocaleString() + "/" + total.toLocaleString() + ")
"; + + // 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 += "
Change: " + sign + + dPass.toLocaleString() + " (" + sign + dPct.toFixed(2) + "%)
"; + } } tip.innerHTML = html; tip.style.display = "block";