|
15 | 15 | const unique = values => [...new Set(values)]; |
16 | 16 | const number = value => value.toLocaleString("en-US"); |
17 | 17 | 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 | + |
18 | 29 | const cache = new Map(); |
19 | 30 | const pools = new Map(); |
20 | 31 | let downloadURL = null; |
|
124 | 135 | return node; |
125 | 136 | } |
126 | 137 |
|
| 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 | + |
127 | 150 | function chartReadout(record) { |
128 | 151 | if (!record || !$("chart-readout")) return; |
129 | | - $("chart-readout").replaceChildren( |
| 152 | + const lines = [ |
130 | 153 | 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); |
133 | 161 | } |
134 | 162 |
|
135 | 163 | function updateChartSelection() { |
|
154 | 182 | const scrollLeft = $("chart-scroll")?.scrollLeft || 0; |
155 | 183 | const baselines = unique(groupRows.map(row => row.baseline)); |
156 | 184 | 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; |
157 | 188 | const header = element("div", undefined, "chart-header"); |
158 | 189 | const legend = element("div", undefined, "chart-legend"); |
159 | 190 | legend.setAttribute("aria-label", "Chart baselines"); |
|
176 | 207 | legend.append(item); |
177 | 208 | } |
178 | 209 | 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"}); |
179 | 216 | const scroll = element("div", undefined, "chart-scroll"); |
180 | 217 | scroll.id = "chart-scroll"; |
181 | 218 | const stage = element("div", undefined, "chart-stage"); |
182 | 219 | stage.setAttribute("role", "group"); |
183 | 220 | stage.setAttribute("aria-label", `${state.project} coverage by version`); |
184 | 221 | 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); |
186 | 225 | chartWidth = $("chart").clientWidth; |
187 | 226 | // 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); |
190 | 231 | 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); |
192 | 233 | 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)); |
194 | 235 | const groupWidth = visible.length * barWidth + (visible.length - 1) * gap; |
195 | 236 | const center = index => left + band * (index + 0.5); |
196 | 237 | const y = value => bottom - value / 100 * (bottom - top); |
197 | 238 | stage.style.width = `${width}px`; |
198 | 239 | stage.style.height = `${height}px`; |
199 | 240 | const svg = svgElement("svg", {viewBox: `0 0 ${width} ${height}`, width, height, role: "img", "aria-labelledby": "chart-svg-title chart-svg-description"}); |
200 | 241 | 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.")); |
202 | 243 | 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}%`)); |
205 | 246 | } |
206 | 247 | versions.forEach((version, index) => { |
207 | 248 | svg.append(svgElement("text", {x: center(index), y: bottom + 28, "text-anchor": "middle", class: "chart-axis chart-version"}, version)); |
|
211 | 252 | for (const [index, baseline] of baselines.entries()) { |
212 | 253 | const slot = visible.indexOf(baseline); |
213 | 254 | if (slot < 0) continue; |
214 | | - const series = new Map(rows.filter(row => row.baseline === baseline).map(row => [row.version, row])); |
215 | 255 | versions.forEach((version, versionIndex) => { |
216 | | - const record = series.get(version); |
| 256 | + const record = results.get(`${baseline}|${version}`); |
217 | 257 | if (!record) return; |
218 | 258 | const plottable = record.summary.reference > 0; |
219 | 259 | const label = `${version}, ${baseline}: ${percent(record.summary)}, ${record.summary.matched} of ${record.summary.reference}`; |
|
233 | 273 | unavailable.append(bar); |
234 | 274 | return; |
235 | 275 | } |
236 | | - const value = 100 * record.summary.matched / record.summary.reference; |
| 276 | + const value = ratio(record.summary); |
| 277 | + const difference = changes.get(record.id); |
237 | 278 | const x = center(versionIndex) - groupWidth / 2 + slot * (barWidth + gap); |
238 | 279 | // The button covers the whole column, so a short bar is still easy to select. |
239 | 280 | bar.style.left = `${x}px`; |
|
244 | 285 | fill.style.height = `${Math.max(2, bottom - y(value))}px`; |
245 | 286 | bar.append(fill); |
246 | 287 | 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) { |
249 | 290 | svg.append(svgElement("text", {x: x + barWidth / 2, y: y(value) - 6, "text-anchor": "middle", class: "chart-value"}, percent(record.summary))); |
250 | 291 | } |
| 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 | + } |
251 | 295 | }); |
252 | 296 | } |
253 | 297 | if (unavailable.childElementCount) { |
|
0 commit comments