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 @@

popularity
-

Publisher

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;