diff --git a/app/lib/frontend/templates/views/pkg/info_box.dart b/app/lib/frontend/templates/views/pkg/info_box.dart
index 8054df8034..d8dda5ab0b 100644
--- a/app/lib/frontend/templates/views/pkg/info_box.dart
+++ b/app/lib/frontend/templates/views/pkg/info_box.dart
@@ -4,6 +4,7 @@
import 'package:_pub_shared/format/encoding.dart';
import 'package:pana/pana.dart';
+import 'package:pub_dev/frontend/request_context.dart';
import 'package:pub_dev/service/download_counts/download_counts.dart';
import 'package:pubspec_parse/pubspec_parse.dart' as pubspek;
@@ -71,8 +72,9 @@ d.Node packageInfoBoxNode({
}
return d.fragment([
labeledScores,
- if (data.weeklyDownloadCounts != null)
- _downloadsChart(data.weeklyDownloadCounts!),
+ if (data.weeklyDownloadCounts != null &&
+ requestContext.experimentalFlags.showDownloadCounts)
+ _block('Weekly Downloads', _downloadsChart(data.weeklyDownloadCounts!)),
if (thumbnailUrl != null)
d.div(classes: [
'detail-screenshot-thumbnail'
diff --git a/app/test/frontend/golden/pkg_score_page_with_downloads_chart.html b/app/test/frontend/golden/pkg_score_page_with_downloads_chart.html
index d3129818cd..f14945828c 100644
--- a/app/test/frontend/golden/pkg_score_page_with_downloads_chart.html
+++ b/app/test/frontend/golden/pkg_score_page_with_downloads_chart.html
@@ -333,7 +333,6 @@
popularity
-
Publisher
unverified uploader
@@ -411,7 +410,6 @@
unverified uploader
diff --git a/pkg/web_app/lib/src/widget/weekly_sparkline/widget.dart b/pkg/web_app/lib/src/widget/weekly_sparkline/widget.dart
new file mode 100644
index 0000000000..f1494f8d32
--- /dev/null
+++ b/pkg/web_app/lib/src/widget/weekly_sparkline/widget.dart
@@ -0,0 +1,181 @@
+import 'dart:math';
+
+import 'package:_pub_shared/format/encoding.dart';
+import 'package:web/web.dart';
+
+void create(HTMLElement element, Map options) {
+ final dataPoints = options['points'];
+ if (dataPoints == null) {
+ throw UnsupportedError('data-weekly-sparkline-points required');
+ }
+
+ final svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ element.append(svg);
+
+ final toolTip = HTMLDivElement()
+ ..setAttribute('class', 'weekly-downloads-sparkline-tooltip');
+ document.body!.appendChild(toolTip);
+
+ final chartSubText = HTMLDivElement()
+ ..setAttribute('class', 'weekly-downloads-sparkline-text');
+ element.append(chartSubText);
+
+ List<({DateTime date, int downloads})> prepareDataForSparkline(
+ String dataPoints) {
+ final decoded = decodeIntsFromLittleEndianBase64String(dataPoints);
+ final newestDate = DateTime.fromMillisecondsSinceEpoch(decoded[0] * 1000);
+ final weeklyDownloads = decoded.sublist(1);
+ return List.generate(
+ weeklyDownloads.length,
+ (i) => (
+ date: newestDate.copyWith(day: newestDate.day - 7 * i),
+ downloads: weeklyDownloads[i]
+ )).reversed.toList();
+ }
+
+ drawChart(svg, toolTip, chartSubText, prepareDataForSparkline(dataPoints));
+}
+
+void drawChart(Element svg, HTMLDivElement toolTip, HTMLDivElement chartSubText,
+ List<({DateTime date, int downloads})> data) {
+ final height = 80;
+ final width = 190;
+ final drawingHeight = 75;
+ final lastDay = data.last.date;
+ final firstDay = data.first.date;
+ final xAxisSpan = lastDay.difference(firstDay);
+ final maxDownloads = data.fold(0, (a, b) => max(a, b.downloads));
+
+ final toolTipOffsetFromMouse = 15;
+
+ (double, double) computeCoordinates(DateTime date, int downloads) {
+ final duration = date.difference(firstDay);
+ final x = width * duration.inMilliseconds / xAxisSpan.inMilliseconds;
+ final y = height - drawingHeight * (downloads / maxDownloads);
+ return (x, y);
+ }
+
+ String generateSparkline(List<({DateTime date, int downloads})> data) {
+ final line = StringBuffer();
+ var c = 'M';
+ for (final d in data) {
+ final (x, y) = computeCoordinates(d.date, d.downloads);
+ line.write(' $c$x $y');
+ c = 'L';
+ }
+ return line.toString();
+ }
+
+ String formatDate(DateTime date) {
+ final year = '${date.year}';
+ final month = date.month < 10 ? '0${date.month}' : '${date.month}';
+ final day = date.day < 10 ? '0${date.day}' : '${date.day}';
+ return '$year.$month.$day';
+ }
+
+ // Render chart
+
+ chartSubText.text = '${formatDate(firstDay)} - ${formatDate(lastDay)}';
+
+ final chart = SVGGElement();
+ final frame = SVGRectElement();
+ frame.setAttribute('height', '$height');
+ frame.setAttribute('width', '$width');
+ frame.setAttribute('style', 'fill:white;stroke-width:1;stroke:white');
+ chart.append(frame);
+
+ final sparklineBar = SVGLineElement();
+ final sparklineCursor = SVGGElement();
+ final sparklineSpot = SVGCircleElement();
+
+ sparklineCursor
+ ..appendChild(sparklineBar)
+ ..appendChild(sparklineSpot);
+
+ sparklineSpot
+ ..setAttribute('class', 'weekly-sparkline')
+ ..setAttribute('r', '2');
+
+ sparklineBar.setAttribute('class', 'weekly-sparkline-bar');
+ sparklineBar.setAttribute('x1', '0');
+ sparklineBar.setAttribute('x2', '0');
+ sparklineBar.setAttribute('y1', '0');
+ sparklineBar.setAttribute('y2', '$height');
+ chart.append(sparklineCursor);
+
+ final line = generateSparkline(data);
+ final sparkline = SVGPathElement();
+ sparkline.setAttribute('class', 'weekly-sparkline-line');
+ sparkline.setAttribute('d', '$line');
+
+ final area = SVGPathElement();
+ area.setAttribute('class', 'weekly-sparkline-area');
+ area.setAttribute('d', '$line L$width $height L0 $height Z');
+
+ chart.append(sparkline);
+ chart.append(area);
+
+ svg.append(chart);
+
+ // Setup mouse handling
+
+ DateTime? lastSelectedDay;
+ chart.onMouseMove.listen((e) {
+ sparklineCursor.setAttribute('style', 'opacity:1');
+ toolTip.setAttribute(
+ 'style',
+ 'top:${e.y + toolTipOffsetFromMouse + document.scrollingElement!.scrollTop}px;'
+ 'left:${e.x}px;');
+
+ final s = (e.x - chart.getBoundingClientRect().x) / width;
+ final selectedDayIndex =
+ lowerBoundBy<({DateTime date, int downloads}), double>(
+ data,
+ (e) =>
+ e.date.difference(firstDay).inMilliseconds /
+ xAxisSpan.inMilliseconds,
+ (a, b) => a.compareTo(b),
+ s);
+ final selectedDay = data[selectedDayIndex];
+ if (selectedDay.date == lastSelectedDay) return;
+
+ final coords = computeCoordinates(selectedDay.date, selectedDay.downloads);
+ sparklineSpot.setAttribute('cy', '${coords.$2}');
+ sparklineCursor.setAttribute('transform', 'translate(${coords.$1}, 0)');
+
+ toolTip.text = '${selectedDay.downloads}';
+
+ final endDate = selectedDay.date.add(Duration(days: 7));
+ chartSubText.text =
+ ' ${formatDate(selectedDay.date)} - ${formatDate(endDate)}';
+
+ lastSelectedDay = selectedDay.date;
+ });
+
+ void erase(_) {
+ sparklineCursor.setAttribute('style', 'opacity:0');
+ toolTip.setAttribute('style', 'opacity:0;position:absolute;');
+ chartSubText.text = '${formatDate(firstDay)} - ${formatDate(lastDay)}';
+ }
+
+ erase(1);
+ chart.onMouseLeave.listen(erase);
+}
+
+int lowerBoundBy(List sortedList, K Function(E element) keyOf,
+ int Function(K, K) compare, K value) {
+ var min = 0;
+ var max = sortedList.length - 1;
+ final key = value;
+ while (min < max) {
+ final mid = min + ((max - min) >> 1);
+ final element = sortedList[mid];
+ final comp = compare(keyOf(element), key);
+ if (comp < 0) {
+ min = mid + 1;
+ } else {
+ max = mid;
+ }
+ }
+ return min;
+}
diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart
index edbe556153..d34fd81fec 100644
--- a/pkg/web_app/lib/src/widget/widget.dart
+++ b/pkg/web_app/lib/src/widget/widget.dart
@@ -11,6 +11,7 @@ import 'package:web/web.dart';
import '../web_util.dart';
import 'completion/widget.dart' deferred as completion;
import 'switch/widget.dart' as switch_;
+import 'weekly_sparkline/widget.dart' as weekly_sparkline;
/// Function to create an instance of the widget given an element and options.
///
@@ -33,6 +34,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
final _widgets = {
'completion': () => completion.loadLibrary().then((_) => completion.create),
'switch': () => switch_.create,
+ 'weekly-sparkline': () => weekly_sparkline.create,
};
Future<_WidgetFn> _noSuchWidget() async =>
diff --git a/pkg/web_css/lib/src/_detail_page.scss b/pkg/web_css/lib/src/_detail_page.scss
index 242b6602e3..858b7b97a0 100644
--- a/pkg/web_css/lib/src/_detail_page.scss
+++ b/pkg/web_css/lib/src/_detail_page.scss
@@ -234,6 +234,54 @@ $detail-tabs-tablet-width: calc(100% - 240px);
filter: brightness(50%);
}
+
+.weekly-downloads-sparkline {
+ display: flex;
+ height: 105px;
+ width: 190px;
+ flex-direction: column;
+ margin-top: 10px;
+}
+
+.weekly-downloads-sparkline-text {
+ font-family: monospace;
+ font-size: 13px;
+ color: var(--pub-weekly-chart-main-color);
+}
+
+.weekly-downloads-sparkline-tooltip {
+ border-radius: 5px;
+ margin: 0px;
+ padding: 2px 8px;
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
+ color: var(--pub-color-white);
+ background-color: var(--pub-weekly-chart-main-color);
+ opacity: 1;
+ z-index: 100000;
+ position: absolute;
+}
+
+.weekly-sparkline {
+ fill: var(--pub-weekly-chart-main-color);
+}
+
+.weekly-sparkline-bar {
+ stroke: var(--pub-weekly-chart-main-color);
+ stroke-width: 1.5;
+}
+
+.weekly-sparkline-line {
+ fill: none;
+ stroke: var(--pub-weekly-chart-main-color);
+ stroke-width: 2;
+}
+
+.weekly-sparkline-area {
+ fill: var(--pub-weekly-chart-main-color);
+ opacity: 0.3;
+ stroke-width: 0;
+}
+
@media (min-width: $device-desktop-min-width) {
.detail-body {
diff --git a/pkg/web_css/lib/src/_variables.scss b/pkg/web_css/lib/src/_variables.scss
index f0f9452acb..0373ff816a 100644
--- a/pkg/web_css/lib/src/_variables.scss
+++ b/pkg/web_css/lib/src/_variables.scss
@@ -56,6 +56,7 @@
--pub-detail_tab-text-color: var(--pub-neutral-textColor);
--pub-detail_tab-underline-color: #dddddd;
--pub-detail_tab-active-color: #1967d2;
+ --pub-weekly-chart-main-color: #0175c2;
--pub-detail_tab-admin-color: #990000;
--pub-hash_link-text-color: #ccc;
--pub-footer-background-color: #27323a;