Skip to content

Commit 733a793

Browse files
committed
Experimental: show weekly downloads sparkline chart
1 parent 8a8de69 commit 733a793

File tree

5 files changed

+233
-2
lines changed

5 files changed

+233
-2
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'
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 = 75;
42+
final width = 190;
43+
final lastDay = data.last.date;
44+
final firstDay = data.first.date;
45+
final xAxisSpan = lastDay.difference(firstDay);
46+
final maxDownloads = data.fold<int>(0, (a, b) => max<int>(a, b.downloads));
47+
48+
(double, double) computeCoordinates(DateTime date, int downloads) {
49+
final duration = date.difference(firstDay);
50+
final x = width * duration.inMilliseconds / xAxisSpan.inMilliseconds;
51+
final y = 5 + (height) - (height) * (downloads / maxDownloads);
52+
return (x, y);
53+
}
54+
55+
String generateSparkline(List<({DateTime date, int downloads})> data) {
56+
final line = StringBuffer();
57+
var c = 'M';
58+
for (final d in data) {
59+
final (x, y) = computeCoordinates(d.date, d.downloads);
60+
line.write(' $c$x $y');
61+
c = 'L';
62+
}
63+
return line.toString();
64+
}
65+
66+
String formatDate(DateTime date) {
67+
final year = '${date.year}';
68+
final month = date.month < 10 ? '0${date.month}' : '${date.month}';
69+
final day = date.day < 10 ? '0${date.day}' : '${date.day}';
70+
return '$year.$month.$day';
71+
}
72+
73+
// Render chart
74+
75+
chartSubText.text = '${formatDate(firstDay)} - ${formatDate(lastDay)}';
76+
77+
final chart = SVGGElement();
78+
final frame = SVGRectElement();
79+
frame.setAttribute('height', '$height');
80+
frame.setAttribute('width', '$width');
81+
frame.setAttribute('style', 'fill:white;stroke-width:1;stroke:white');
82+
chart.append(frame);
83+
84+
final sparklineBar = SVGLineElement();
85+
final sparklineCursor = SVGGElement();
86+
final sparklineSpot = SVGCircleElement();
87+
88+
sparklineCursor
89+
..appendChild(sparklineBar)
90+
..appendChild(sparklineSpot);
91+
92+
sparklineSpot
93+
..setAttribute('class', 'weekly-sparkline')
94+
..setAttribute('r', '2');
95+
96+
sparklineBar.setAttribute('class', 'weekly-sparkline-bar');
97+
sparklineBar.setAttribute('x1', '0');
98+
sparklineBar.setAttribute('x2', '0');
99+
sparklineBar.setAttribute('y1', '0');
100+
sparklineBar.setAttribute('y2', '$height');
101+
chart.append(sparklineCursor);
102+
103+
final line = generateSparkline(data);
104+
final sparkline = SVGPathElement();
105+
sparkline.setAttribute('class', 'weekly-sparkline-line');
106+
sparkline.setAttribute('d', '$line');
107+
108+
final area = SVGPathElement();
109+
area.setAttribute('class', 'weekly-sparkline-area');
110+
area.setAttribute('d', '$line L$width $height L0 $height Z');
111+
112+
chart.append(sparkline);
113+
chart.append(area);
114+
115+
svg.append(chart);
116+
117+
// Setup mouse handling
118+
119+
DateTime? lastSelectedDay;
120+
chart.onMouseMove.listen((e) {
121+
sparklineCursor.setAttribute('style', 'opacity:1');
122+
toolTip.setAttribute(
123+
'style',
124+
'top:${e.y + 15 + document.scrollingElement!.scrollTop}px;'
125+
'left:${e.x}px;');
126+
127+
final s = (e.x - chart.getBoundingClientRect().x) / width;
128+
final selectedDayIndex =
129+
lowerBoundBy<({DateTime date, int downloads}), double>(
130+
data,
131+
(e) =>
132+
e.date.difference(firstDay).inMilliseconds /
133+
xAxisSpan.inMilliseconds,
134+
(a, b) => a.compareTo(b),
135+
s);
136+
final selectedDay = data[selectedDayIndex];
137+
if (selectedDay.date == lastSelectedDay) return;
138+
139+
final coords = computeCoordinates(selectedDay.date, selectedDay.downloads);
140+
sparklineSpot.setAttribute('cy', '${coords.$2}');
141+
sparklineCursor.setAttribute('transform', 'translate(${coords.$1}, 0)');
142+
143+
toolTip.text = '${selectedDay.downloads}';
144+
145+
final endDate = selectedDay.date.add(Duration(days: 7));
146+
chartSubText.text =
147+
' ${formatDate(selectedDay.date)} - ${formatDate(endDate)}';
148+
149+
lastSelectedDay = selectedDay.date;
150+
});
151+
152+
void erase(_) {
153+
sparklineCursor.setAttribute('style', 'opacity:0');
154+
toolTip.setAttribute('style', 'opacity:0;position:absolute;');
155+
chartSubText.text = '${formatDate(firstDay)} - ${formatDate(lastDay)}';
156+
}
157+
158+
erase(1);
159+
chart.onMouseLeave.listen(erase);
160+
}
161+
162+
int lowerBoundBy<E, K>(List<E> sortedList, K Function(E element) keyOf,
163+
int Function(K, K) compare, K value) {
164+
var min = 0;
165+
var max = sortedList.length - 1;
166+
final key = value;
167+
while (min < max) {
168+
final mid = min + ((max - min) >> 1);
169+
final element = sortedList[mid];
170+
final comp = compare(keyOf(element), key);
171+
if (comp < 0) {
172+
min = mid + 1;
173+
} else {
174+
max = mid;
175+
}
176+
}
177+
return min;
178+
}

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 weeklySparkline;
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': () => weeklySparkline.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: 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)