@@ -6,34 +6,261 @@ import 'dart:convert';
66import 'dart:math' as math;
77
88import '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' ;
911import 'package:web/web.dart' ;
1012
1113import 'computations.dart' ;
1214
15+ const colors = [
16+ 'blue' ,
17+ 'red' ,
18+ 'green' ,
19+ 'purple' ,
20+ 'orange' ,
21+ 'turquoise' ,
22+ ];
23+
24+ String strokeColorClass (int i) => 'downloads-chart-stroke-${colors [i ]}' ;
25+ String fillColorClass (int i) => 'downloads-chart-fill-${colors [i ]}' ;
26+
1327void create (HTMLElement element, Map <String , String > options) {
1428 final dataPoints = options['points' ];
1529 if (dataPoints == null ) {
1630 throw UnsupportedError ('data-downloads-chart-points required' );
1731 }
1832
1933 final svg = document.createElementNS ('http://www.w3.org/2000/svg' , 'svg' );
34+ svg.setAttribute ('height' , '100%' );
35+ svg.setAttribute ('width' , '100%' );
36+
2037 element.append (svg);
2138 final data = WeeklyVersionDownloadCounts .fromJson ((utf8.decoder
2239 .fuse (json.decoder)
2340 .convert (base64Decode (dataPoints)) as Map <String , dynamic >));
2441
25- final weeksToDisplay = math.min (28 , data.totalWeeklyDownloads.length);
42+ final weeksToDisplay = math.min (40 , data.totalWeeklyDownloads.length);
2643
2744 final majorDisplayLists = prepareWeekLists (
2845 data.totalWeeklyDownloads,
2946 data.majorRangeWeeklyDownloads,
3047 weeksToDisplay,
3148 );
32- final majorRanges = data.majorRangeWeeklyDownloads.map ((e) => e.versionRange);
3349
34- drawChart (svg, majorRanges, majorDisplayLists, data.newestDate);
50+ drawChart (svg, majorDisplayLists, data.newestDate);
3551}
3652
37- void drawChart (Element svg, Iterable <String > ranges, Iterable <List <int >> values,
53+ void drawChart (
54+ Element svg,
55+ ({List <String > ranges, List <List <int >> weekLists}) displayLists,
3856 DateTime newestDate,
39- {bool stacked = true }) {}
57+ {bool stacked = false }) {
58+ final ranges = displayLists.ranges;
59+ final values = displayLists.weekLists;
60+
61+ if (values.isEmpty) return ;
62+
63+ final frameWidth =
64+ 775 ; // TODO(zarah): Investigate if this width can be dynamic
65+ final topPadding = 30 ;
66+ final leftPadding = 30 ;
67+ final rightPadding = 70 ; // Make extra room for labels on y-axis
68+ final chartWidth = frameWidth - 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 ticks 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! But we don't want to break if it does.
96+ return (maxDownloads, firstDiv);
97+ }
98+
99+ final (maxY, interval) = computeMaxYAndInterval (values);
100+ final firstDate = computeDateForWeekNumber (newestDate, values.length, 0 );
101+ final xAxisSpan = newestDate.difference (firstDate);
102+
103+ (double , double ) computeCoordinates (DateTime date, int downloads) {
104+ final duration = date.difference (firstDate);
105+ // We don't risk division by 0 here, since `xAxisSpan` is a non-zero duration.
106+ final x = leftPadding +
107+ chartWidth * duration.inMilliseconds / xAxisSpan.inMilliseconds;
108+
109+ final y = topPadding + (chartheight - chartheight * (downloads / maxY));
110+ return (x, y);
111+ }
112+
113+ final chart = SVGGElement ();
114+ svg.append (chart);
115+
116+ // Axis and ticks
117+
118+ final (xZero, yZero) = computeCoordinates (firstDate, 0 );
119+ final (xMax, yMax) = computeCoordinates (newestDate, maxY);
120+ final lineThickness = 1 ;
121+ final marginPadding = 8 ;
122+ final labelPadding = 16 ;
123+ final tickLength = 10 ;
124+ final tickLabelYCoordinate = yZero + tickLength + labelPadding;
125+
126+ final xaxis = SVGPathElement ();
127+ xaxis.setAttribute ('class' , 'downloads-chart-x-axis' );
128+ // We add half of the line thickness at both ends of the x-axis so that it
129+ // covers the vertical ticks at the end.
130+ final xAxisStart = xZero - (lineThickness / 2 );
131+ final xAxisEnd = xMax + (lineThickness / 2 );
132+ xaxis.setAttribute ('d' , 'M$xAxisStart $yZero L$xAxisEnd $yZero ' );
133+ chart.append (xaxis);
134+
135+ late SVGTextElement firstTickLabel;
136+ // Place a tick every 4 weeks
137+ for (int week = 0 ; week < values.length; week += 4 ) {
138+ final date = computeDateForWeekNumber (newestDate, values.length, week);
139+ final (x, y) = computeCoordinates (date, 0 );
140+
141+ final tick = SVGPathElement ();
142+ tick.setAttribute ('class' , 'downloads-chart-x-axis' );
143+ tick.setAttribute ('d' , 'M$x $y l0 $tickLength ' );
144+ chart.append (tick);
145+
146+ final tickLabel = SVGTextElement ();
147+ chart.append (tickLabel);
148+ tickLabel.setAttribute (
149+ 'class' , 'downloads-chart-tick-label downloads-chart-tick-label-x' );
150+ tickLabel.text = formatAbbrMonthDay (date);
151+ tickLabel.setAttribute ('y' , '$tickLabelYCoordinate ' );
152+ tickLabel.setAttribute ('x' , '$x ' );
153+
154+ if (week == 0 ) {
155+ firstTickLabel = tickLabel;
156+ }
157+ }
158+
159+ for (int i = 0 ; i <= maxY / interval; i++ ) {
160+ final (x, y) = computeCoordinates (firstDate, i * interval);
161+
162+ final tickLabel = SVGTextElement ();
163+ tickLabel.setAttribute (
164+ 'class' , 'downloads-chart-tick-label downloads-chart-tick-label-y' );
165+ tickLabel.text =
166+ '${compactFormat (i * interval ).value }${compactFormat (i * interval ).suffix }' ;
167+ tickLabel.setAttribute ('x' , '${xMax + marginPadding }' );
168+ tickLabel.setAttribute ('y' , '$y ' );
169+ chart.append (tickLabel);
170+
171+ if (i == 0 ) {
172+ // No long tick in the bottom, we have the x-axis here.
173+ continue ;
174+ }
175+
176+ final longTick = SVGPathElement ();
177+ longTick.setAttribute ('class' , 'downloads-chart-frame' );
178+ longTick.setAttribute ('d' , 'M$xAxisStart $y L$xAxisEnd $y ' );
179+ chart.append (longTick);
180+ }
181+
182+ // We use the clipPath to cut the ends of the chart lines so that we don't
183+ // draw outside the frame of the chart.
184+ final clipPath = SVGClipPathElement ();
185+ clipPath.setAttribute ('id' , 'clipRect' );
186+ final clipRect = SVGRectElement ();
187+ clipRect.setAttribute ('y' , '$yMax ' );
188+ clipRect.setAttribute ('height' , '${chartheight - (lineThickness / 2 )}' );
189+ clipRect.setAttribute ('x' , '$xZero ' );
190+ clipRect.setAttribute ('width' , '$chartWidth ' );
191+ clipPath.append (clipRect);
192+ chart.append (clipPath);
193+
194+ // Chart lines and legends
195+
196+ final lines = < StringBuffer > [];
197+ for (int versionRange = 0 ; versionRange < values[0 ].length; versionRange++ ) {
198+ final line = StringBuffer ();
199+ var c = 'M' ;
200+ for (int week = 0 ; week < values.length; week++ ) {
201+ final (x, y) = computeCoordinates (
202+ computeDateForWeekNumber (newestDate, values.length, week),
203+ values[week][versionRange]);
204+ line.write (' $c $x $y ' );
205+ c = 'L' ;
206+ }
207+ lines.add (line);
208+ }
209+
210+ double legendX = xZero;
211+ double legendY =
212+ tickLabelYCoordinate + firstTickLabel.getBBox ().height + labelPadding;
213+ final legendWidth = 20 ;
214+ final legendHeight = 8 ;
215+
216+ for (int i = 0 ; i < lines.length; i++ ) {
217+ final path = SVGPathElement ();
218+ path.setAttribute ('class' , '${strokeColorClass (i )} downloads-chart-line ' );
219+ // We assign colors in reverse order so that main colors are chosen first for
220+ // the newest versions.
221+ path.setAttribute ('d' , '${lines [lines .length - 1 - i ]}' );
222+ path.setAttribute ('clip-path' , 'url(#clipRect)' );
223+ chart.append (path);
224+
225+ final legend = SVGRectElement ();
226+ chart.append (legend);
227+ legend.setAttribute ('class' ,
228+ 'downloads-chart-legend ${fillColorClass (i )} ${strokeColorClass (i )}' );
229+ legend.setAttribute ('height' , '$legendHeight ' );
230+ legend.setAttribute ('width' , '$legendWidth ' );
231+
232+ final legendLabel = SVGTextElement ();
233+ chart.append (legendLabel);
234+ legendLabel.setAttribute ('class' , 'downloads-chart-tick-label' );
235+ legendLabel.text = ranges[ranges.length - 1 - i];
236+
237+ if (legendX + marginPadding + legendWidth + legendLabel.getBBox ().width >
238+ xMax) {
239+ // There is no room for the legend and label.
240+ // Make a new line and update legendXCoor and legendYCoor accordingly.
241+
242+ legendX = xZero;
243+ legendY += 2 * marginPadding + legendHeight;
244+ }
245+
246+ legend.setAttribute ('x' , '$legendX ' );
247+ legend.setAttribute ('y' , '$legendY ' );
248+ legendLabel.setAttribute ('y' , '${legendY + legendHeight }' );
249+ legendLabel.setAttribute ('x' , '${legendX + marginPadding + legendWidth }' );
250+
251+ // Update x coordinate for next legend
252+ legendX += legendWidth +
253+ marginPadding +
254+ legendLabel.getBBox ().width +
255+ labelPadding;
256+ }
257+
258+ final frameHeight = legendY + marginPadding + labelPadding;
259+ final frame = SVGRectElement ()
260+ ..setAttribute ('class' , 'downloads-chart-frame' )
261+ ..setAttribute ('height' , '$frameHeight ' )
262+ ..setAttribute ('width' , '$frameWidth ' )
263+ ..setAttribute ('rx' , '15' )
264+ ..setAttribute ('ry' , '15' );
265+ chart.append (frame);
266+ }
0 commit comments