Skip to content

Commit 694d7e3

Browse files
committed
Enlarge the coverage chart and show changes between versions
1 parent e710d5e commit 694d7e3

3 files changed

Lines changed: 76 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,12 @@ page shows a grouped bar chart with a fixed 0–100% axis. Each group is a
188188
candidate version, and each bar in a group is an SDK baseline. Numeric release
189189
labels, including prereleases, sort from oldest to newest. Custom version labels
190190
keep their manifest order. Select a bar to open its report, or select a legend
191-
item to isolate a baseline and show its values. Bars support keyboard focus and
192-
activation. Missing reports have no bar; empty references display `n/a` below
193-
the chart. The HTML embed and Markdown/SVG exports remain version tables.
191+
item to isolate a baseline. About four versions fill the visible width, the
192+
percentage axis stays in place while the chart scrolls, and wide bars show their
193+
values. When one baseline is shown, each version shows the change in matched
194+
APIs from the previous version, such as `+177 (+4.1%)`. The readout shows the
195+
same change for the selected bar. Bars support keyboard focus and activation. Missing reports have no bar;
196+
empty references display `n/a` below the chart. The HTML embed and Markdown/SVG exports remain version tables.
194197

195198
To collect OpenSwiftUI release reports, run
196199
[`Scripts/collect_openswiftui_releases.py`](Scripts/collect_openswiftui_releases.py).

‎Sources/InterfaceCoverage/SiteAssets/site.css‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
--selected: #eff8f3;
1010
--warning: #78561c;
1111
--warning-background: #fcf8ef;
12+
--positive: #087d4c;
13+
--negative: #b42318;
1214
--series-0: #087d4c;
1315
--series-1: #2563bc;
1416
--series-2: #b96612;
@@ -66,13 +68,19 @@ main { max-width: 1264px; margin: 0 auto; padding: 34px 32px 44px; }
6668
.chart-legend-item[aria-pressed="true"] { color: var(--text); }
6769
.chart-readout { display: flex; flex-direction: column; margin-left: auto; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
6870
.chart-readout strong { color: var(--text); font-size: 13px; font-weight: 500; }
69-
.chart-scroll { overflow-x: auto; }
71+
.chart-body { display: flex; }
72+
.chart-axis-column { flex: none; display: block; }
73+
.chart-scroll { flex: 1; min-width: 0; overflow-x: auto; }
7074
.chart-stage { position: relative; }
7175
.chart-stage > svg { display: block; overflow: visible; }
7276
.chart-grid { stroke: var(--line); stroke-width: 1; }
7377
.chart-axis { fill: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
7478
.chart-version { fill: var(--text); }
7579
.chart-value { fill: var(--text); font-size: 11px; font-variant-numeric: tabular-nums; }
80+
.chart-change { font-size: 11px; font-variant-numeric: tabular-nums; }
81+
.chart-change-up { color: var(--positive); fill: var(--positive); }
82+
.chart-change-down { color: var(--negative); fill: var(--negative); }
83+
.chart-change-flat { color: var(--muted); fill: var(--muted); }
7684
.chart-bar { position: absolute; display: flex; align-items: flex-end; padding: 0; border: 0; border-radius: 3px 3px 0 0; background: transparent; }
7785
.chart-bar:hover:not(:disabled) { background: var(--surface); }
7886
.chart-bar span { width: 100%; border-radius: 3px 3px 0 0; background: var(--series-color); }
@@ -187,6 +195,7 @@ body[data-embed="true"] .matrix-cell { box-shadow: none; }
187195
}
188196
@media (prefers-color-scheme: dark) {
189197
:root { color-scheme: dark; --background: #161b1e; --surface: #20272c; --text: #e7edef; --muted: #a1adb7; --line: #354047; --accent: #68cba0; --selected: #213a30; --warning: #d9b778; --warning-background: #322d22; }
198+
:root { --positive: #68cba0; --negative: #f97066; }
190199
:root { --series-0: #68cba0; --series-1: #79a9ef; --series-2: #e9ac66; --series-3: #bda0ed; --series-4: #69ced1; --series-5: #ec95b0; }
191200
button.primary { color: #12281e; }
192201
}

‎Sources/InterfaceCoverage/SiteAssets/site.js‎

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@
1515
const unique = values => [...new Set(values)];
1616
const number = value => value.toLocaleString("en-US");
1717
const percent = count => count.reference ? `${(100 * count.matched / count.reference).toFixed(2)}%` : "n/a";
18+
const ratio = count => 100 * count.matched / count.reference;
19+
const sign = value => value > 0 ? "+" : value < 0 ? "−" : "±";
20+
const changeClass = value => value > 0 ? "chart-change-up" : value < 0 ? "chart-change-down" : "chart-change-flat";
21+
22+
// Formats a change in matched declarations, such as `+177 (+4.1%)`.
23+
function changeText(difference, withRate = true) {
24+
const count = `${sign(difference.matched)}${number(Math.abs(difference.matched))}`;
25+
if (!withRate || difference.rate === null) return count;
26+
return `${count} (${sign(difference.rate)}${Math.abs(difference.rate).toFixed(1)}%)`;
27+
}
28+
1829
const cache = new Map();
1930
const pools = new Map();
2031
let downloadURL = null;
@@ -124,12 +135,29 @@
124135
return node;
125136
}
126137

138+
// Compares a result with the previous version that has a result for the same baseline.
139+
function change(record) {
140+
if (!record.summary.reference) return null;
141+
const series = rows.filter(row => row.baseline === record.baseline && row.summary.reference > 0);
142+
const order = versionOrder(unique(series.map(row => row.version)));
143+
const index = order.indexOf(record.version);
144+
const previous = index > 0 ? series.find(row => row.version === order[index - 1]) : null;
145+
if (!previous) return null;
146+
const matched = record.summary.matched - previous.summary.matched;
147+
return {previous, matched, rate: previous.summary.matched ? matched / previous.summary.matched * 100 : null};
148+
}
149+
127150
function chartReadout(record) {
128151
if (!record || !$("chart-readout")) return;
129-
$("chart-readout").replaceChildren(
152+
const lines = [
130153
element("strong", `${record.version} · ${record.baseline}`),
131-
element("span", `${percent(record.summary)} · ${number(record.summary.matched)} / ${number(record.summary.reference)} declarations`)
132-
);
154+
element("span", `${percent(record.summary)} · ${number(record.summary.matched)} / ${number(record.summary.reference)} declarations`),
155+
];
156+
const difference = change(record);
157+
if (difference) {
158+
lines.push(element("span", `${changeText(difference)} matched APIs since ${difference.previous.version}`, changeClass(difference.matched)));
159+
}
160+
$("chart-readout").replaceChildren(...lines);
133161
}
134162

135163
function updateChartSelection() {
@@ -154,6 +182,9 @@
154182
const scrollLeft = $("chart-scroll")?.scrollLeft || 0;
155183
const baselines = unique(groupRows.map(row => row.baseline));
156184
const versions = versionOrder(unique(groupRows.map(row => row.version)));
185+
const visible = baselines.filter(baseline => !state.baseline || baseline === state.baseline);
186+
// With one baseline, each version also shows the change from the previous version.
187+
const showChanges = visible.length === 1;
157188
const header = element("div", undefined, "chart-header");
158189
const legend = element("div", undefined, "chart-legend");
159190
legend.setAttribute("aria-label", "Chart baselines");
@@ -176,32 +207,42 @@
176207
legend.append(item);
177208
}
178209
header.append(legend, readout);
210+
const results = new Map(rows.filter(row => visible.includes(row.baseline)).map(row => [`${row.baseline}|${row.version}`, row]));
211+
const changes = new Map(showChanges ? rows.filter(row => visible.includes(row.baseline)).map(row => [row.id, change(row)]) : []);
212+
// The axis stays in place while the plot scrolls horizontally.
213+
// Leave room for the change labels and an overlay scroll bar below the plot.
214+
const axisWidth = 48, top = 28, bottom = 286, height = showChanges ? bottom + 68 : bottom + 54;
215+
const axis = svgElement("svg", {width: axisWidth, height, "aria-hidden": "true", class: "chart-axis-column"});
179216
const scroll = element("div", undefined, "chart-scroll");
180217
scroll.id = "chart-scroll";
181218
const stage = element("div", undefined, "chart-stage");
182219
stage.setAttribute("role", "group");
183220
stage.setAttribute("aria-label", `${state.project} coverage by version`);
184221
scroll.append(stage);
185-
$("chart").replaceChildren(header, scroll);
222+
const body = element("div", undefined, "chart-body");
223+
body.append(axis, scroll);
224+
$("chart").replaceChildren(header, body);
186225
chartWidth = $("chart").clientWidth;
187226
// Group bars by version. Each bar in a group is one visible SDK baseline.
188-
const visible = baselines.filter(baseline => !state.baseline || baseline === state.baseline);
189-
const gap = 4, left = 56, right = 16, height = 292, top = 28, bottom = 238;
227+
// About four versions fill the visible width, or two on a narrow screen.
228+
const gap = 6, left = 8, right = 16;
229+
const available = scroll.clientWidth - left - right;
230+
const perView = Math.min(Math.max(versions.length, 1), available < 520 ? 2 : 4);
190231
const minimumBand = Math.max(visible.length * 14 + (visible.length - 1) * gap + 24, ...versions.map(version => version.length * 7 + 20));
191-
const band = Math.max(minimumBand, (scroll.clientWidth - left - right) / Math.max(versions.length, 1));
232+
const band = Math.max(minimumBand, available / perView);
192233
const width = left + right + band * versions.length;
193-
const barWidth = Math.min(visible.length === 1 ? 48 : 32, (band - 24 - (visible.length - 1) * gap) / Math.max(visible.length, 1));
234+
const barWidth = Math.min(visible.length === 1 ? 96 : 64, (band - 32 - (visible.length - 1) * gap) / Math.max(visible.length, 1));
194235
const groupWidth = visible.length * barWidth + (visible.length - 1) * gap;
195236
const center = index => left + band * (index + 0.5);
196237
const y = value => bottom - value / 100 * (bottom - top);
197238
stage.style.width = `${width}px`;
198239
stage.style.height = `${height}px`;
199240
const svg = svgElement("svg", {viewBox: `0 0 ${width} ${height}`, width, height, role: "img", "aria-labelledby": "chart-svg-title chart-svg-description"});
200241
svg.append(svgElement("title", {id: "chart-svg-title"}, `${state.project} interface coverage`),
201-
svgElement("desc", {id: "chart-svg-description"}, "Coverage from 0 to 100 percent for each candidate version. Each bar in a group is an SDK baseline. Missing reports and empty references have no bar. Use the bar buttons to open a report."));
242+
svgElement("desc", {id: "chart-svg-description"}, "Coverage from 0 to 100 percent for each candidate version. Each bar in a group is an SDK baseline. With one baseline, each version shows the change in matched APIs from the previous version. Missing reports and empty references have no bar. Use the bar buttons to open a report."));
202243
for (let value = 0; value <= 100; value += 25) {
203-
svg.append(svgElement("line", {x1: left, x2: width - right, y1: y(value), y2: y(value), class: "chart-grid"}),
204-
svgElement("text", {x: left - 12, y: y(value) + 4, "text-anchor": "end", class: "chart-axis"}, `${value}%`));
244+
svg.append(svgElement("line", {x1: 0, x2: width, y1: y(value), y2: y(value), class: "chart-grid"}));
245+
axis.append(svgElement("text", {x: axisWidth - 10, y: y(value) + 4, "text-anchor": "end", class: "chart-axis"}, `${value}%`));
205246
}
206247
versions.forEach((version, index) => {
207248
svg.append(svgElement("text", {x: center(index), y: bottom + 28, "text-anchor": "middle", class: "chart-axis chart-version"}, version));
@@ -211,9 +252,8 @@
211252
for (const [index, baseline] of baselines.entries()) {
212253
const slot = visible.indexOf(baseline);
213254
if (slot < 0) continue;
214-
const series = new Map(rows.filter(row => row.baseline === baseline).map(row => [row.version, row]));
215255
versions.forEach((version, versionIndex) => {
216-
const record = series.get(version);
256+
const record = results.get(`${baseline}|${version}`);
217257
if (!record) return;
218258
const plottable = record.summary.reference > 0;
219259
const label = `${version}, ${baseline}: ${percent(record.summary)}, ${record.summary.matched} of ${record.summary.reference}`;
@@ -233,7 +273,8 @@
233273
unavailable.append(bar);
234274
return;
235275
}
236-
const value = 100 * record.summary.matched / record.summary.reference;
276+
const value = ratio(record.summary);
277+
const difference = changes.get(record.id);
237278
const x = center(versionIndex) - groupWidth / 2 + slot * (barWidth + gap);
238279
// The button covers the whole column, so a short bar is still easy to select.
239280
bar.style.left = `${x}px`;
@@ -244,10 +285,13 @@
244285
fill.style.height = `${Math.max(2, bottom - y(value))}px`;
245286
bar.append(fill);
246287
stage.append(bar);
247-
// Values fit above the bars when one baseline is shown.
248-
if (visible.length === 1) {
288+
// Values fit above bars that are wide enough.
289+
if (barWidth >= 40) {
249290
svg.append(svgElement("text", {x: x + barWidth / 2, y: y(value) - 6, "text-anchor": "middle", class: "chart-value"}, percent(record.summary)));
250291
}
292+
if (difference) {
293+
svg.append(svgElement("text", {x: x + barWidth / 2, y: bottom + 46, "text-anchor": "middle", class: `chart-change ${changeClass(difference.matched)}`}, changeText(difference)));
294+
}
251295
});
252296
}
253297
if (unavailable.childElementCount) {

0 commit comments

Comments
 (0)