Skip to content

Commit be57d06

Browse files
authored
Experimental: Add downloads version chart - noninteractive (#8474)
1 parent fe78e7f commit be57d06

File tree

8 files changed

+380
-11
lines changed

8 files changed

+380
-11
lines changed

app/lib/frontend/templates/views/pkg/score_tab.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ d.Node _section(ReportSection section) {
177177
}
178178

179179
d.Node _downloadsChart(WeeklyVersionDownloadCounts weeklyVersionDownloads) {
180-
return d.div(
180+
final container = d.div(
181181
classes: ['downloads-chart'],
182182
id: '-downloads-chart',
183183
attributes: {
@@ -186,6 +186,11 @@ d.Node _downloadsChart(WeeklyVersionDownloadCounts weeklyVersionDownloads) {
186186
base64Encode(jsonUtf8Encoder.convert(weeklyVersionDownloads))
187187
},
188188
);
189+
190+
return d.fragment([
191+
d.h1(text: 'Weekly Downloads over the last 40 weeks'),
192+
container,
193+
]);
189194
}
190195

191196
final _statusIconUrls = {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
// Formats a DateTime into abbreviated month and day
6+
7+
String formatAbbrMonthDay(DateTime date) {
8+
final month = switch (date.month) {
9+
1 => 'Jan',
10+
2 => 'Feb',
11+
3 => 'Mar',
12+
4 => 'Apr',
13+
5 => 'May',
14+
6 => 'Jun',
15+
7 => 'Jul',
16+
8 => 'Aug',
17+
9 => 'Sep',
18+
10 => 'Oct',
19+
11 => 'Nov',
20+
_ => 'Dec'
21+
};
22+
23+
return '$month ${date.day}';
24+
}

pkg/web_app/lib/src/widget/downloads_chart/computations.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,22 @@ Iterable<String> prepareRanges(List<VersionRangeCount> rangeDownloads) {
1313
///
1414
/// The 'i'th entry in the iterable is a list of the download values
1515
/// (y coordinates) for the 'i'th week (x coordinate).
16-
List<List<int>> prepareWeekLists(
16+
({List<String> ranges, List<List<int>> weekLists}) prepareWeekLists(
1717
List<int> totals,
1818
List<VersionRangeCount> rangeDownloads,
1919
int displayLength,
2020
) {
2121
final result = <List<int>>[];
22+
final ranges = <String>[];
2223

2324
final showOther =
2425
totals[0] > rangeDownloads.fold(0, (sum, d) => sum + d.counts[0]);
2526

27+
if (showOther) {
28+
ranges.add('Other');
29+
}
30+
rangeDownloads.forEach((d) => ranges.add(d.versionRange));
31+
2632
for (int week = 0; week < displayLength; week++) {
2733
final weekList = <int>[];
2834
if (showOther) {
@@ -32,5 +38,6 @@ List<List<int>> prepareWeekLists(
3238
rangeDownloads.forEach((d) => weekList.add(d.counts[week]));
3339
result.add(weekList);
3440
}
35-
return result.reversed.toList();
41+
42+
return (ranges: ranges, weekLists: result.reversed.toList());
3643
}

pkg/web_app/lib/src/widget/downloads_chart/widget.dart

Lines changed: 232 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,261 @@ import 'dart:convert';
66
import 'dart:math' as math;
77

88
import 'package:_pub_shared/data/download_counts_data.dart';
9+
import 'package:_pub_shared/format/date_format.dart';
10+
import 'package:_pub_shared/format/number_format.dart';
911
import 'package:web/web.dart';
1012

1113
import 'computations.dart';
1214

15+
const colors = [
16+
'blue',
17+
'red',
18+
'green',
19+
'purple',
20+
'orange',
21+
'turquoise',
22+
];
23+
24+
String strokeColorClass(int i) => 'downloads-chart-stroke-${colors[i]}';
25+
String fillColorClass(int i) => 'downloads-chart-fill-${colors[i]}';
26+
1327
void create(HTMLElement element, Map<String, String> options) {
1428
final dataPoints = options['points'];
1529
if (dataPoints == null) {
1630
throw UnsupportedError('data-downloads-chart-points required');
1731
}
1832

1933
final svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
34+
svg.setAttribute('height', '100%');
35+
svg.setAttribute('width', '100%');
36+
2037
element.append(svg);
2138
final data = WeeklyVersionDownloadCounts.fromJson((utf8.decoder
2239
.fuse(json.decoder)
2340
.convert(base64Decode(dataPoints)) as Map<String, dynamic>));
2441

25-
final weeksToDisplay = math.min(28, data.totalWeeklyDownloads.length);
42+
final weeksToDisplay = math.min(40, data.totalWeeklyDownloads.length);
2643

2744
final majorDisplayLists = prepareWeekLists(
2845
data.totalWeeklyDownloads,
2946
data.majorRangeWeeklyDownloads,
3047
weeksToDisplay,
3148
);
32-
final majorRanges = data.majorRangeWeeklyDownloads.map((e) => e.versionRange);
3349

34-
drawChart(svg, majorRanges, majorDisplayLists, data.newestDate);
50+
drawChart(svg, majorDisplayLists, data.newestDate);
3551
}
3652

37-
void drawChart(Element svg, Iterable<String> ranges, Iterable<List<int>> values,
53+
void drawChart(
54+
Element svg,
55+
({List<String> ranges, List<List<int>> weekLists}) displayLists,
3856
DateTime newestDate,
39-
{bool stacked = true}) {}
57+
{bool stacked = false}) {
58+
final ranges = displayLists.ranges;
59+
final values = displayLists.weekLists;
60+
61+
if (values.isEmpty) return;
62+
63+
final frameWidth =
64+
775; // TODO(zarah): Investigate if this width can be dynamic
65+
final topPadding = 30;
66+
final leftPadding = 30;
67+
final rightPadding = 70; // Make extra room for labels on y-axis
68+
final chartWidth = frameWidth - leftPadding - rightPadding;
69+
final chartheight = 420;
70+
71+
DateTime computeDateForWeekNumber(
72+
DateTime newestDate, int totalWeeks, int weekNumber) {
73+
return newestDate.copyWith(
74+
day: newestDate.day - 7 * (totalWeeks - weekNumber - 1));
75+
}
76+
77+
/// Computes max value on y-axis such that we get a nice division for the
78+
/// interval length between the numbers shown by the ticks on the y axis.
79+
(int maxY, int interval) computeMaxYAndInterval(List<List<int>> values) {
80+
final maxDownloads =
81+
values.fold<int>(1, (a, b) => math.max<int>(a, b.reduce(math.max)));
82+
final digits = maxDownloads.toString().length;
83+
final buffer = StringBuffer()..write('1');
84+
if (digits > 2) {
85+
buffer.writeAll(List<String>.filled(digits - 2, '0'));
86+
}
87+
final firstDiv = int.parse(buffer.toString());
88+
final candidates = [firstDiv, 2 * firstDiv, 5 * firstDiv, 10 * firstDiv];
89+
90+
for (final d in candidates) {
91+
if (maxDownloads / d <= 10) {
92+
return ((maxDownloads / d).ceil() * d, d);
93+
}
94+
}
95+
// This should not happen! But we don't want to break if it does.
96+
return (maxDownloads, firstDiv);
97+
}
98+
99+
final (maxY, interval) = computeMaxYAndInterval(values);
100+
final firstDate = computeDateForWeekNumber(newestDate, values.length, 0);
101+
final xAxisSpan = newestDate.difference(firstDate);
102+
103+
(double, double) computeCoordinates(DateTime date, int downloads) {
104+
final duration = date.difference(firstDate);
105+
// We don't risk division by 0 here, since `xAxisSpan` is a non-zero duration.
106+
final x = leftPadding +
107+
chartWidth * duration.inMilliseconds / xAxisSpan.inMilliseconds;
108+
109+
final y = topPadding + (chartheight - chartheight * (downloads / maxY));
110+
return (x, y);
111+
}
112+
113+
final chart = SVGGElement();
114+
svg.append(chart);
115+
116+
// Axis and ticks
117+
118+
final (xZero, yZero) = computeCoordinates(firstDate, 0);
119+
final (xMax, yMax) = computeCoordinates(newestDate, maxY);
120+
final lineThickness = 1;
121+
final marginPadding = 8;
122+
final labelPadding = 16;
123+
final tickLength = 10;
124+
final tickLabelYCoordinate = yZero + tickLength + labelPadding;
125+
126+
final xaxis = SVGPathElement();
127+
xaxis.setAttribute('class', 'downloads-chart-x-axis');
128+
// We add half of the line thickness at both ends of the x-axis so that it
129+
// covers the vertical ticks at the end.
130+
final xAxisStart = xZero - (lineThickness / 2);
131+
final xAxisEnd = xMax + (lineThickness / 2);
132+
xaxis.setAttribute('d', 'M$xAxisStart $yZero L$xAxisEnd $yZero');
133+
chart.append(xaxis);
134+
135+
late SVGTextElement firstTickLabel;
136+
// Place a tick every 4 weeks
137+
for (int week = 0; week < values.length; week += 4) {
138+
final date = computeDateForWeekNumber(newestDate, values.length, week);
139+
final (x, y) = computeCoordinates(date, 0);
140+
141+
final tick = SVGPathElement();
142+
tick.setAttribute('class', 'downloads-chart-x-axis');
143+
tick.setAttribute('d', 'M$x $y l0 $tickLength');
144+
chart.append(tick);
145+
146+
final tickLabel = SVGTextElement();
147+
chart.append(tickLabel);
148+
tickLabel.setAttribute(
149+
'class', 'downloads-chart-tick-label downloads-chart-tick-label-x');
150+
tickLabel.text = formatAbbrMonthDay(date);
151+
tickLabel.setAttribute('y', '$tickLabelYCoordinate');
152+
tickLabel.setAttribute('x', '$x');
153+
154+
if (week == 0) {
155+
firstTickLabel = tickLabel;
156+
}
157+
}
158+
159+
for (int i = 0; i <= maxY / interval; i++) {
160+
final (x, y) = computeCoordinates(firstDate, i * interval);
161+
162+
final tickLabel = SVGTextElement();
163+
tickLabel.setAttribute(
164+
'class', 'downloads-chart-tick-label downloads-chart-tick-label-y');
165+
tickLabel.text =
166+
'${compactFormat(i * interval).value}${compactFormat(i * interval).suffix}';
167+
tickLabel.setAttribute('x', '${xMax + marginPadding}');
168+
tickLabel.setAttribute('y', '$y');
169+
chart.append(tickLabel);
170+
171+
if (i == 0) {
172+
// No long tick in the bottom, we have the x-axis here.
173+
continue;
174+
}
175+
176+
final longTick = SVGPathElement();
177+
longTick.setAttribute('class', 'downloads-chart-frame');
178+
longTick.setAttribute('d', 'M$xAxisStart $y L$xAxisEnd $y');
179+
chart.append(longTick);
180+
}
181+
182+
// We use the clipPath to cut the ends of the chart lines so that we don't
183+
// draw outside the frame of the chart.
184+
final clipPath = SVGClipPathElement();
185+
clipPath.setAttribute('id', 'clipRect');
186+
final clipRect = SVGRectElement();
187+
clipRect.setAttribute('y', '$yMax');
188+
clipRect.setAttribute('height', '${chartheight - (lineThickness / 2)}');
189+
clipRect.setAttribute('x', '$xZero');
190+
clipRect.setAttribute('width', '$chartWidth');
191+
clipPath.append(clipRect);
192+
chart.append(clipPath);
193+
194+
// Chart lines and legends
195+
196+
final lines = <StringBuffer>[];
197+
for (int versionRange = 0; versionRange < values[0].length; versionRange++) {
198+
final line = StringBuffer();
199+
var c = 'M';
200+
for (int week = 0; week < values.length; week++) {
201+
final (x, y) = computeCoordinates(
202+
computeDateForWeekNumber(newestDate, values.length, week),
203+
values[week][versionRange]);
204+
line.write(' $c$x $y');
205+
c = 'L';
206+
}
207+
lines.add(line);
208+
}
209+
210+
double legendX = xZero;
211+
double legendY =
212+
tickLabelYCoordinate + firstTickLabel.getBBox().height + labelPadding;
213+
final legendWidth = 20;
214+
final legendHeight = 8;
215+
216+
for (int i = 0; i < lines.length; i++) {
217+
final path = SVGPathElement();
218+
path.setAttribute('class', '${strokeColorClass(i)} downloads-chart-line ');
219+
// We assign colors in reverse order so that main colors are chosen first for
220+
// the newest versions.
221+
path.setAttribute('d', '${lines[lines.length - 1 - i]}');
222+
path.setAttribute('clip-path', 'url(#clipRect)');
223+
chart.append(path);
224+
225+
final legend = SVGRectElement();
226+
chart.append(legend);
227+
legend.setAttribute('class',
228+
'downloads-chart-legend ${fillColorClass(i)} ${strokeColorClass(i)}');
229+
legend.setAttribute('height', '$legendHeight');
230+
legend.setAttribute('width', '$legendWidth');
231+
232+
final legendLabel = SVGTextElement();
233+
chart.append(legendLabel);
234+
legendLabel.setAttribute('class', 'downloads-chart-tick-label');
235+
legendLabel.text = ranges[ranges.length - 1 - i];
236+
237+
if (legendX + marginPadding + legendWidth + legendLabel.getBBox().width >
238+
xMax) {
239+
// There is no room for the legend and label.
240+
// Make a new line and update legendXCoor and legendYCoor accordingly.
241+
242+
legendX = xZero;
243+
legendY += 2 * marginPadding + legendHeight;
244+
}
245+
246+
legend.setAttribute('x', '$legendX');
247+
legend.setAttribute('y', '$legendY');
248+
legendLabel.setAttribute('y', '${legendY + legendHeight}');
249+
legendLabel.setAttribute('x', '${legendX + marginPadding + legendWidth}');
250+
251+
// Update x coordinate for next legend
252+
legendX += legendWidth +
253+
marginPadding +
254+
legendLabel.getBBox().width +
255+
labelPadding;
256+
}
257+
258+
final frameHeight = legendY + marginPadding + labelPadding;
259+
final frame = SVGRectElement()
260+
..setAttribute('class', 'downloads-chart-frame')
261+
..setAttribute('height', '$frameHeight')
262+
..setAttribute('width', '$frameWidth')
263+
..setAttribute('rx', '15')
264+
..setAttribute('ry', '15');
265+
chart.append(frame);
266+
}

pkg/web_app/test/widget/downloads_chart/downloads_chart_test.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ void main() {
5252
(counts: l3, versionRange: '>=6.0.0-0 <7.0.0')
5353
];
5454

55-
final w1 = prepareWeekLists(totals, majorRangeDownloads, 52).toList();
56-
final w2 = prepareWeekLists(totals, minorRangeDownloads, 52).toList();
57-
final w3 = prepareWeekLists(totals, patchRangeDownloads, 52).toList();
55+
final w1 = prepareWeekLists(totals, majorRangeDownloads, 52).weekLists;
56+
final w2 = prepareWeekLists(totals, minorRangeDownloads, 52).weekLists;
57+
final w3 = prepareWeekLists(totals, patchRangeDownloads, 52).weekLists;
5858

5959
for (int i = 42; i < 52; i++) {
6060
expect(w1[i], [10, 10, 10, 10, 70]);

0 commit comments

Comments
 (0)