Skip to content

Commit 29eed4e

Browse files
authored
Experimental: show weekly downloads sparkline chart (#8242)
1 parent 06302af commit 29eed4e

File tree

6 files changed

+236
-4
lines changed

6 files changed

+236
-4
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'package:_pub_shared/format/encoding.dart';
66
import 'package:pana/pana.dart';
7+
import 'package:pub_dev/frontend/request_context.dart';
78
import 'package:pub_dev/service/download_counts/download_counts.dart';
89
import 'package:pubspec_parse/pubspec_parse.dart' as pubspek;
910

@@ -71,8 +72,9 @@ d.Node packageInfoBoxNode({
7172
}
7273
return d.fragment([
7374
labeledScores,
74-
if (data.weeklyDownloadCounts != null)
75-
_downloadsChart(data.weeklyDownloadCounts!),
75+
if (data.weeklyDownloadCounts != null &&
76+
requestContext.experimentalFlags.showDownloadCounts)
77+
_block('Weekly Downloads', _downloadsChart(data.weeklyDownloadCounts!)),
7678
if (thumbnailUrl != null)
7779
d.div(classes: [
7880
'detail-screenshot-thumbnail'

app/test/frontend/golden/pkg_score_page_with_downloads_chart.html

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,6 @@ <h3>
333333
<div class="packages-score-label">popularity</div>
334334
</div>
335335
</a>
336-
<div id="-weekly-downloads-sparkline" class="weekly-downloads-sparkline" data-widget="weekly-sparkline" data-weekly-sparkline-points="gOmZZdQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="></div>
337336
<h3 class="title">Publisher</h3>
338337
<p>
339338
<span>unverified uploader</span>
@@ -411,7 +410,6 @@ <h3 class="detail-metadata-title">
411410
<div class="packages-score-label">popularity</div>
412411
</div>
413412
</a>
414-
<div id="-weekly-downloads-sparkline" class="weekly-downloads-sparkline" data-widget="weekly-sparkline" data-weekly-sparkline-points="gOmZZdQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="></div>
415413
<h3 class="title">Publisher</h3>
416414
<p>
417415
<span>unverified uploader</span>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import 'dart:math';
2+
3+
import 'package:_pub_shared/format/encoding.dart';
4+
import 'package:web/web.dart';
5+
6+
void create(HTMLElement element, Map<String, String> options) {
7+
final dataPoints = options['points'];
8+
if (dataPoints == null) {
9+
throw UnsupportedError('data-weekly-sparkline-points required');
10+
}
11+
12+
final svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
13+
element.append(svg);
14+
15+
final toolTip = HTMLDivElement()
16+
..setAttribute('class', 'weekly-downloads-sparkline-tooltip');
17+
document.body!.appendChild(toolTip);
18+
19+
final chartSubText = HTMLDivElement()
20+
..setAttribute('class', 'weekly-downloads-sparkline-text');
21+
element.append(chartSubText);
22+
23+
List<({DateTime date, int downloads})> prepareDataForSparkline(
24+
String dataPoints) {
25+
final decoded = decodeIntsFromLittleEndianBase64String(dataPoints);
26+
final newestDate = DateTime.fromMillisecondsSinceEpoch(decoded[0] * 1000);
27+
final weeklyDownloads = decoded.sublist(1);
28+
return List.generate(
29+
weeklyDownloads.length,
30+
(i) => (
31+
date: newestDate.copyWith(day: newestDate.day - 7 * i),
32+
downloads: weeklyDownloads[i]
33+
)).reversed.toList();
34+
}
35+
36+
drawChart(svg, toolTip, chartSubText, prepareDataForSparkline(dataPoints));
37+
}
38+
39+
void drawChart(Element svg, HTMLDivElement toolTip, HTMLDivElement chartSubText,
40+
List<({DateTime date, int downloads})> data) {
41+
final height = 80;
42+
final width = 190;
43+
final drawingHeight = 75;
44+
final lastDay = data.last.date;
45+
final firstDay = data.first.date;
46+
final xAxisSpan = lastDay.difference(firstDay);
47+
final maxDownloads = data.fold<int>(0, (a, b) => max<int>(a, b.downloads));
48+
49+
final toolTipOffsetFromMouse = 15;
50+
51+
(double, double) computeCoordinates(DateTime date, int downloads) {
52+
final duration = date.difference(firstDay);
53+
final x = width * duration.inMilliseconds / xAxisSpan.inMilliseconds;
54+
final y = height - drawingHeight * (downloads / maxDownloads);
55+
return (x, y);
56+
}
57+
58+
String generateSparkline(List<({DateTime date, int downloads})> data) {
59+
final line = StringBuffer();
60+
var c = 'M';
61+
for (final d in data) {
62+
final (x, y) = computeCoordinates(d.date, d.downloads);
63+
line.write(' $c$x $y');
64+
c = 'L';
65+
}
66+
return line.toString();
67+
}
68+
69+
String formatDate(DateTime date) {
70+
final year = '${date.year}';
71+
final month = date.month < 10 ? '0${date.month}' : '${date.month}';
72+
final day = date.day < 10 ? '0${date.day}' : '${date.day}';
73+
return '$year.$month.$day';
74+
}
75+
76+
// Render chart
77+
78+
chartSubText.text = '${formatDate(firstDay)} - ${formatDate(lastDay)}';
79+
80+
final chart = SVGGElement();
81+
final frame = SVGRectElement();
82+
frame.setAttribute('height', '$height');
83+
frame.setAttribute('width', '$width');
84+
frame.setAttribute('style', 'fill:white;stroke-width:1;stroke:white');
85+
chart.append(frame);
86+
87+
final sparklineBar = SVGLineElement();
88+
final sparklineCursor = SVGGElement();
89+
final sparklineSpot = SVGCircleElement();
90+
91+
sparklineCursor
92+
..appendChild(sparklineBar)
93+
..appendChild(sparklineSpot);
94+
95+
sparklineSpot
96+
..setAttribute('class', 'weekly-sparkline')
97+
..setAttribute('r', '2');
98+
99+
sparklineBar.setAttribute('class', 'weekly-sparkline-bar');
100+
sparklineBar.setAttribute('x1', '0');
101+
sparklineBar.setAttribute('x2', '0');
102+
sparklineBar.setAttribute('y1', '0');
103+
sparklineBar.setAttribute('y2', '$height');
104+
chart.append(sparklineCursor);
105+
106+
final line = generateSparkline(data);
107+
final sparkline = SVGPathElement();
108+
sparkline.setAttribute('class', 'weekly-sparkline-line');
109+
sparkline.setAttribute('d', '$line');
110+
111+
final area = SVGPathElement();
112+
area.setAttribute('class', 'weekly-sparkline-area');
113+
area.setAttribute('d', '$line L$width $height L0 $height Z');
114+
115+
chart.append(sparkline);
116+
chart.append(area);
117+
118+
svg.append(chart);
119+
120+
// Setup mouse handling
121+
122+
DateTime? lastSelectedDay;
123+
chart.onMouseMove.listen((e) {
124+
sparklineCursor.setAttribute('style', 'opacity:1');
125+
toolTip.setAttribute(
126+
'style',
127+
'top:${e.y + toolTipOffsetFromMouse + document.scrollingElement!.scrollTop}px;'
128+
'left:${e.x}px;');
129+
130+
final s = (e.x - chart.getBoundingClientRect().x) / width;
131+
final selectedDayIndex =
132+
lowerBoundBy<({DateTime date, int downloads}), double>(
133+
data,
134+
(e) =>
135+
e.date.difference(firstDay).inMilliseconds /
136+
xAxisSpan.inMilliseconds,
137+
(a, b) => a.compareTo(b),
138+
s);
139+
final selectedDay = data[selectedDayIndex];
140+
if (selectedDay.date == lastSelectedDay) return;
141+
142+
final coords = computeCoordinates(selectedDay.date, selectedDay.downloads);
143+
sparklineSpot.setAttribute('cy', '${coords.$2}');
144+
sparklineCursor.setAttribute('transform', 'translate(${coords.$1}, 0)');
145+
146+
toolTip.text = '${selectedDay.downloads}';
147+
148+
final endDate = selectedDay.date.add(Duration(days: 7));
149+
chartSubText.text =
150+
' ${formatDate(selectedDay.date)} - ${formatDate(endDate)}';
151+
152+
lastSelectedDay = selectedDay.date;
153+
});
154+
155+
void erase(_) {
156+
sparklineCursor.setAttribute('style', 'opacity:0');
157+
toolTip.setAttribute('style', 'opacity:0;position:absolute;');
158+
chartSubText.text = '${formatDate(firstDay)} - ${formatDate(lastDay)}';
159+
}
160+
161+
erase(1);
162+
chart.onMouseLeave.listen(erase);
163+
}
164+
165+
int lowerBoundBy<E, K>(List<E> sortedList, K Function(E element) keyOf,
166+
int Function(K, K) compare, K value) {
167+
var min = 0;
168+
var max = sortedList.length - 1;
169+
final key = value;
170+
while (min < max) {
171+
final mid = min + ((max - min) >> 1);
172+
final element = sortedList[mid];
173+
final comp = compare(keyOf(element), key);
174+
if (comp < 0) {
175+
min = mid + 1;
176+
} else {
177+
max = mid;
178+
}
179+
}
180+
return min;
181+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:web/web.dart';
1111
import '../web_util.dart';
1212
import 'completion/widget.dart' deferred as completion;
1313
import 'switch/widget.dart' as switch_;
14+
import 'weekly_sparkline/widget.dart' as weekly_sparkline;
1415

1516
/// Function to create an instance of the widget given an element and options.
1617
///
@@ -33,6 +34,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
3334
final _widgets = <String, _WidgetLoaderFn>{
3435
'completion': () => completion.loadLibrary().then((_) => completion.create),
3536
'switch': () => switch_.create,
37+
'weekly-sparkline': () => weekly_sparkline.create,
3638
};
3739

3840
Future<_WidgetFn> _noSuchWidget() async =>

pkg/web_css/lib/src/_detail_page.scss

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,54 @@ $detail-tabs-tablet-width: calc(100% - 240px);
234234
filter: brightness(50%);
235235
}
236236

237+
238+
.weekly-downloads-sparkline {
239+
display: flex;
240+
height: 105px;
241+
width: 190px;
242+
flex-direction: column;
243+
margin-top: 10px;
244+
}
245+
246+
.weekly-downloads-sparkline-text {
247+
font-family: monospace;
248+
font-size: 13px;
249+
color: var(--pub-weekly-chart-main-color);
250+
}
251+
252+
.weekly-downloads-sparkline-tooltip {
253+
border-radius: 5px;
254+
margin: 0px;
255+
padding: 2px 8px;
256+
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
257+
color: var(--pub-color-white);
258+
background-color: var(--pub-weekly-chart-main-color);
259+
opacity: 1;
260+
z-index: 100000;
261+
position: absolute;
262+
}
263+
264+
.weekly-sparkline {
265+
fill: var(--pub-weekly-chart-main-color);
266+
}
267+
268+
.weekly-sparkline-bar {
269+
stroke: var(--pub-weekly-chart-main-color);
270+
stroke-width: 1.5;
271+
}
272+
273+
.weekly-sparkline-line {
274+
fill: none;
275+
stroke: var(--pub-weekly-chart-main-color);
276+
stroke-width: 2;
277+
}
278+
279+
.weekly-sparkline-area {
280+
fill: var(--pub-weekly-chart-main-color);
281+
opacity: 0.3;
282+
stroke-width: 0;
283+
}
284+
237285
@media (min-width: $device-desktop-min-width) {
238286
.detail-body {
239287

pkg/web_css/lib/src/_variables.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
--pub-detail_tab-text-color: var(--pub-neutral-textColor);
5757
--pub-detail_tab-underline-color: #dddddd;
5858
--pub-detail_tab-active-color: #1967d2;
59+
--pub-weekly-chart-main-color: #0175c2;
5960
--pub-detail_tab-admin-color: #990000;
6061
--pub-hash_link-text-color: #ccc;
6162
--pub-footer-background-color: #27323a;

0 commit comments

Comments
 (0)