Skip to content

Commit ca0aed8

Browse files
committed
Experimental: Add downloads version chart - noninteractive
1 parent 33ee8c8 commit ca0aed8

File tree

5 files changed

+379
-5
lines changed

5 files changed

+379
-5
lines changed

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'package:pub_dev/shared/utils.dart';
1212
import '../../../../scorecard/models.dart' hide ReportStatus;
1313
import '../../../../shared/urls.dart' as urls;
1414
import '../../../dom/dom.dart' as d;
15+
1516
import '../../../request_context.dart';
1617
import '../../../static_files.dart';
1718

@@ -177,7 +178,7 @@ d.Node _section(ReportSection section) {
177178
}
178179

179180
d.Node _downloadsChart(WeeklyVersionDownloadCounts weeklyVersionDownloads) {
180-
return d.div(
181+
final container = d.div(
181182
classes: ['downloads-chart'],
182183
id: '-downloads-chart',
183184
attributes: {
@@ -186,6 +187,13 @@ d.Node _downloadsChart(WeeklyVersionDownloadCounts weeklyVersionDownloads) {
186187
base64Encode(jsonUtf8Encoder.convert(weeklyVersionDownloads))
187188
},
188189
);
190+
191+
return d.fragment([
192+
d.h1(
193+
classes: ['hash-header'],
194+
text: 'Weekly Downloads over the last 40 weeks'),
195+
container,
196+
]);
189197
}
190198

191199
final _statusIconUrls = {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 abbriviated month and day
6+
7+
String formatAbbrMonthDay(DateTime date) {
8+
final String month;
9+
switch (date.month) {
10+
case 1:
11+
month = 'Jan';
12+
case 2:
13+
month = 'Feb';
14+
case 3:
15+
month = 'Mar';
16+
case 4:
17+
month = 'Apr';
18+
case 5:
19+
month = 'May';
20+
case 6:
21+
month = 'Jun';
22+
case 7:
23+
month = 'Jul';
24+
case 8:
25+
month = 'Aug';
26+
case 9:
27+
month = 'Sep';
28+
case 10:
29+
month = 'Oct';
30+
case 11:
31+
month = 'Nov';
32+
case 12:
33+
month = 'Dec';
34+
default:
35+
month = '';
36+
}
37+
return '$month ${date.day}';
38+
}

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

Lines changed: 226 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,256 @@ 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 lineColorClasses = [
16+
'downloads-chart-line-color-blue',
17+
'downloads-chart-line-color-red',
18+
'downloads-chart-line-color-green',
19+
'downloads-chart-line-color-purple',
20+
'downloads-chart-line-color-orange',
21+
'downloads-chart-line-color-turquoise',
22+
];
23+
24+
const legendColorClasses = [
25+
'downloads-chart-legend-color-blue',
26+
'downloads-chart-legend-color-red',
27+
'downloads-chart-legend-color-green',
28+
'downloads-chart-legend-color-purple',
29+
'downloads-chart-legend-color-orange',
30+
'downloads-chart-legend-color-turquoise',
31+
];
32+
1333
void create(HTMLElement element, Map<String, String> options) {
1434
final dataPoints = options['points'];
1535
if (dataPoints == null) {
1636
throw UnsupportedError('data-downloads-chart-points required');
1737
}
1838

1939
final svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
40+
svg.setAttribute('height', '100%');
41+
svg.setAttribute('width', '100%');
42+
2043
element.append(svg);
2144
final data = WeeklyVersionDownloadCounts.fromJson((utf8.decoder
2245
.fuse(json.decoder)
2346
.convert(base64Decode(dataPoints)) as Map<String, dynamic>));
2447

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

2750
final majorDisplayLists = prepareWeekLists(
2851
data.totalWeeklyDownloads,
2952
data.majorRangeWeeklyDownloads,
3053
weeksToDisplay,
3154
);
32-
final majorRanges = data.majorRangeWeeklyDownloads.map((e) => e.versionRange);
55+
final majorRanges =
56+
data.majorRangeWeeklyDownloads.map((e) => e.versionRange).toList();
3357

3458
drawChart(svg, majorRanges, majorDisplayLists, data.newestDate);
3559
}
3660

37-
void drawChart(Element svg, Iterable<String> ranges, Iterable<List<int>> values,
61+
void drawChart(Element svg, List<String> ranges, List<List<int>> values,
3862
DateTime newestDate,
39-
{bool stacked = true}) {}
63+
{bool stacked = false}) {
64+
final width = 775; // TODO(zarah): make this width dynamic
65+
final topPadding = 30;
66+
final leftPadding = 30;
67+
final rightPadding = 70; // make extra room for labels on y-axis
68+
final drawingWidth = width - 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 tics 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!
96+
return (maxDownloads, firstDiv);
97+
}
98+
99+
final (maxY, interval) = computeMaxYAndInterval(values);
100+
final firstDate = computeDateForWeekNumber(newestDate, values.length, 0);
101+
102+
(double, double) computeCoordinates(DateTime date, int downloads) {
103+
final xAxisSpan = newestDate.difference(firstDate);
104+
final duration = date.difference(firstDate);
105+
final x = leftPadding +
106+
drawingWidth * duration.inMilliseconds / xAxisSpan.inMilliseconds;
107+
final y = topPadding + (chartheight - chartheight * (downloads / maxY));
108+
return (x, y);
109+
}
110+
111+
final chart = SVGGElement();
112+
svg.append(chart);
113+
114+
// Axis and tics
115+
116+
final (xZero, yZero) = computeCoordinates(firstDate, 0);
117+
final (xMax, yMax) = computeCoordinates(newestDate, maxY);
118+
final lineThickness = 1;
119+
final padding = 8;
120+
final ticLength = 10;
121+
final ticLabelYCoor = yZero + ticLength + 2 * padding;
122+
123+
final xaxis = SVGPathElement();
124+
xaxis.setAttribute('class', 'downloads-chart-x-axis');
125+
// We add half of the line thickness at both ends of the x-axis so that it
126+
// covers the vertical tics at the end.
127+
xaxis.setAttribute('d',
128+
'M${xZero - (lineThickness / 2)} $yZero L${xMax + (lineThickness / 2)} $yZero');
129+
chart.append(xaxis);
130+
131+
var firstTicLabel = SVGTextElement();
132+
for (int week = 0; week < values.length; week += 4) {
133+
final date = computeDateForWeekNumber(newestDate, values.length, week);
134+
final (x, y) = computeCoordinates(date, 0);
135+
136+
final tic = SVGPathElement();
137+
tic.setAttribute('class', 'downloads-chart-x-axis');
138+
tic.setAttribute('d', 'M$x $y l0 $ticLength');
139+
chart.append(tic);
140+
141+
final ticLabel = SVGTextElement();
142+
chart.append(ticLabel);
143+
ticLabel.setAttribute(
144+
'class', 'downloads-chart-tic-label downloads-chart-tic-label-x');
145+
ticLabel.text = formatAbbrMonthDay(date);
146+
ticLabel.setAttribute('y', '$ticLabelYCoor');
147+
ticLabel.setAttribute('x', '$x');
148+
149+
if (week == 0) {
150+
firstTicLabel = ticLabel;
151+
}
152+
}
153+
154+
for (int i = 0; i <= maxY / interval; i++) {
155+
final (x, y) = computeCoordinates(firstDate, i * interval);
156+
157+
final ticLabel = SVGTextElement();
158+
ticLabel.setAttribute(
159+
'class', 'downloads-chart-tic-label downloads-chart-tic-label-y');
160+
ticLabel.text =
161+
'${compactFormat(i * interval).value}${compactFormat(i * interval).suffix}';
162+
ticLabel.setAttribute('x', '${xMax + padding}');
163+
ticLabel.setAttribute('y', '$y');
164+
chart.append(ticLabel);
165+
166+
if (i == 0) {
167+
// No long tic in the bottom, we have the x-axis here.
168+
continue;
169+
}
170+
171+
final longTic = SVGPathElement();
172+
longTic.setAttribute('class', 'downloads-chart-frame');
173+
longTic.setAttribute('d',
174+
'M${xZero - (lineThickness / 2)} $y L${xMax - (lineThickness / 2)} $y');
175+
chart.append(longTic);
176+
}
177+
178+
// We use the clipPath to cut the ends of the chart lines so that we don't
179+
// draw outside the frame of the chart.
180+
final clipPath = SVGClipPathElement();
181+
clipPath.setAttribute('id', 'clipRect');
182+
final clipRect = SVGRectElement();
183+
clipRect.setAttribute('y', '$yMax');
184+
clipRect.setAttribute('height', '${chartheight - (lineThickness / 2)}');
185+
clipRect.setAttribute('x', '${xZero - (lineThickness / 2)}');
186+
clipRect.setAttribute('width', '${drawingWidth + lineThickness}');
187+
clipPath.append(clipRect);
188+
chart.append(clipPath);
189+
190+
// Chart lines and legends
191+
192+
final lines = <StringBuffer>[];
193+
for (int versionRange = 0; versionRange < values[0].length; versionRange++) {
194+
final line = StringBuffer();
195+
var c = 'M';
196+
for (int week = 0; week < values.length; week++) {
197+
final (x, y) = computeCoordinates(
198+
computeDateForWeekNumber(newestDate, values.length, week),
199+
values[week][versionRange]);
200+
line.write(' $c$x $y');
201+
c = 'L';
202+
}
203+
lines.add(line);
204+
}
205+
206+
double legendXCoor = xZero - firstTicLabel.getBBox().width / 2;
207+
double legendYCoor =
208+
ticLabelYCoor + firstTicLabel.getBBox().height + 2 * padding;
209+
final legendWidth = 20;
210+
final legendHeight = 8;
211+
212+
for (int j = 0; j < lines.length; j++) {
213+
final path = SVGPathElement();
214+
path.setAttribute('class', '${lineColorClasses[j]} downloads-chart-line ');
215+
// We assign colors in revers order so that main colors are chosen first for
216+
// the newest versions.
217+
path.setAttribute('d', '${lines[lines.length - 1 - j]}');
218+
path.setAttribute('clip-path', 'url(#clipRect)');
219+
chart.append(path);
220+
221+
final legend = SVGRectElement();
222+
chart.append(legend);
223+
legend.setAttribute(
224+
'class', 'downloads-chart-legend ${legendColorClasses[j]}');
225+
legend.setAttribute('height', '$legendHeight');
226+
legend.setAttribute('width', '$legendWidth');
227+
228+
final legendLabel = SVGTextElement();
229+
chart.append(legendLabel);
230+
legendLabel.setAttribute(
231+
'class', 'downloads-chart-tic-label downloads-chart-tic-label-y');
232+
legendLabel.text = ranges[j];
233+
234+
if (legendXCoor + padding + legendWidth + legendLabel.getBBox().width >
235+
xMax) {
236+
// There is no room for the legend and label.
237+
// Make a new line and update legendXCoor and legendYCoor accordingly.
238+
239+
legendXCoor = xZero - firstTicLabel.getBBox().width / 2;
240+
legendYCoor += 2 * padding + legendHeight;
241+
}
242+
243+
legend.setAttribute('x', '$legendXCoor');
244+
legend.setAttribute('y', '$legendYCoor');
245+
legendLabel.setAttribute('y', '${legendYCoor + legendHeight / 2}');
246+
legendLabel.setAttribute('x', '${legendXCoor + padding + legendWidth}');
247+
248+
// Update x coordinate for next legend
249+
legendXCoor +=
250+
legendWidth + padding + legendLabel.getBBox().width + 2 * padding;
251+
}
252+
253+
final height = legendYCoor + 3 * padding;
254+
final frame = SVGRectElement();
255+
chart.append(frame);
256+
frame.setAttribute('height', '$height');
257+
frame.setAttribute('width', '$width');
258+
frame.setAttribute('rx', '15');
259+
frame.setAttribute('ry', '15');
260+
frame.setAttribute('class', 'downloads-chart-frame');
261+
}

0 commit comments

Comments
 (0)