@@ -6,34 +6,256 @@ 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 lineColorClasses = [
16+ 'downloads-chart-line-color-blue' ,
17+ 'downloads-chart-line-color-red' ,
18+ 'downloads-chart-line-color-green' ,
19+ 'downloads-chart-line-color-purple' ,
20+ 'downloads-chart-line-color-orange' ,
21+ 'downloads-chart-line-color-turquoise' ,
22+ ];
23+
24+ const legendColorClasses = [
25+ 'downloads-chart-legend-color-blue' ,
26+ 'downloads-chart-legend-color-red' ,
27+ 'downloads-chart-legend-color-green' ,
28+ 'downloads-chart-legend-color-purple' ,
29+ 'downloads-chart-legend-color-orange' ,
30+ 'downloads-chart-legend-color-turquoise' ,
31+ ];
32+
1333void create (HTMLElement element, Map <String , String > options) {
1434 final dataPoints = options['points' ];
1535 if (dataPoints == null ) {
1636 throw UnsupportedError ('data-downloads-chart-points required' );
1737 }
1838
1939 final svg = document.createElementNS ('http://www.w3.org/2000/svg' , 'svg' );
40+ svg.setAttribute ('height' , '100%' );
41+ svg.setAttribute ('width' , '100%' );
42+
2043 element.append (svg);
2144 final data = WeeklyVersionDownloadCounts .fromJson ((utf8.decoder
2245 .fuse (json.decoder)
2346 .convert (base64Decode (dataPoints)) as Map <String , dynamic >));
2447
25- final weeksToDisplay = math.min (28 , data.totalWeeklyDownloads.length);
48+ final weeksToDisplay = math.min (40 , data.totalWeeklyDownloads.length);
2649
2750 final majorDisplayLists = prepareWeekLists (
2851 data.totalWeeklyDownloads,
2952 data.majorRangeWeeklyDownloads,
3053 weeksToDisplay,
3154 );
32- final majorRanges = data.majorRangeWeeklyDownloads.map ((e) => e.versionRange);
55+ final majorRanges =
56+ data.majorRangeWeeklyDownloads.map ((e) => e.versionRange).toList ();
3357
3458 drawChart (svg, majorRanges, majorDisplayLists, data.newestDate);
3559}
3660
37- void drawChart (Element svg, Iterable <String > ranges, Iterable <List <int >> values,
61+ void drawChart (Element svg, List <String > ranges, List <List <int >> values,
3862 DateTime newestDate,
39- {bool stacked = true }) {}
63+ {bool stacked = false }) {
64+ final width = 775 ; // TODO(zarah): make this width dynamic
65+ final topPadding = 30 ;
66+ final leftPadding = 30 ;
67+ final rightPadding = 70 ; // make extra room for labels on y-axis
68+ final drawingWidth = width - 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 tics 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!
96+ return (maxDownloads, firstDiv);
97+ }
98+
99+ final (maxY, interval) = computeMaxYAndInterval (values);
100+ final firstDate = computeDateForWeekNumber (newestDate, values.length, 0 );
101+
102+ (double , double ) computeCoordinates (DateTime date, int downloads) {
103+ final xAxisSpan = newestDate.difference (firstDate);
104+ final duration = date.difference (firstDate);
105+ final x = leftPadding +
106+ drawingWidth * duration.inMilliseconds / xAxisSpan.inMilliseconds;
107+ final y = topPadding + (chartheight - chartheight * (downloads / maxY));
108+ return (x, y);
109+ }
110+
111+ final chart = SVGGElement ();
112+ svg.append (chart);
113+
114+ // Axis and tics
115+
116+ final (xZero, yZero) = computeCoordinates (firstDate, 0 );
117+ final (xMax, yMax) = computeCoordinates (newestDate, maxY);
118+ final lineThickness = 1 ;
119+ final padding = 8 ;
120+ final ticLength = 10 ;
121+ final ticLabelYCoor = yZero + ticLength + 2 * padding;
122+
123+ final xaxis = SVGPathElement ();
124+ xaxis.setAttribute ('class' , 'downloads-chart-x-axis' );
125+ // We add half of the line thickness at both ends of the x-axis so that it
126+ // covers the vertical tics at the end.
127+ xaxis.setAttribute ('d' ,
128+ 'M${xZero - (lineThickness / 2 )} $yZero L${xMax + (lineThickness / 2 )} $yZero ' );
129+ chart.append (xaxis);
130+
131+ var firstTicLabel = SVGTextElement ();
132+ for (int week = 0 ; week < values.length; week += 4 ) {
133+ final date = computeDateForWeekNumber (newestDate, values.length, week);
134+ final (x, y) = computeCoordinates (date, 0 );
135+
136+ final tic = SVGPathElement ();
137+ tic.setAttribute ('class' , 'downloads-chart-x-axis' );
138+ tic.setAttribute ('d' , 'M$x $y l0 $ticLength ' );
139+ chart.append (tic);
140+
141+ final ticLabel = SVGTextElement ();
142+ chart.append (ticLabel);
143+ ticLabel.setAttribute (
144+ 'class' , 'downloads-chart-tic-label downloads-chart-tic-label-x' );
145+ ticLabel.text = formatAbbrMonthDay (date);
146+ ticLabel.setAttribute ('y' , '$ticLabelYCoor ' );
147+ ticLabel.setAttribute ('x' , '$x ' );
148+
149+ if (week == 0 ) {
150+ firstTicLabel = ticLabel;
151+ }
152+ }
153+
154+ for (int i = 0 ; i <= maxY / interval; i++ ) {
155+ final (x, y) = computeCoordinates (firstDate, i * interval);
156+
157+ final ticLabel = SVGTextElement ();
158+ ticLabel.setAttribute (
159+ 'class' , 'downloads-chart-tic-label downloads-chart-tic-label-y' );
160+ ticLabel.text =
161+ '${compactFormat (i * interval ).value }${compactFormat (i * interval ).suffix }' ;
162+ ticLabel.setAttribute ('x' , '${xMax + padding }' );
163+ ticLabel.setAttribute ('y' , '$y ' );
164+ chart.append (ticLabel);
165+
166+ if (i == 0 ) {
167+ // No long tic in the bottom, we have the x-axis here.
168+ continue ;
169+ }
170+
171+ final longTic = SVGPathElement ();
172+ longTic.setAttribute ('class' , 'downloads-chart-frame' );
173+ longTic.setAttribute ('d' ,
174+ 'M${xZero - (lineThickness / 2 )} $y L${xMax - (lineThickness / 2 )} $y ' );
175+ chart.append (longTic);
176+ }
177+
178+ // We use the clipPath to cut the ends of the chart lines so that we don't
179+ // draw outside the frame of the chart.
180+ final clipPath = SVGClipPathElement ();
181+ clipPath.setAttribute ('id' , 'clipRect' );
182+ final clipRect = SVGRectElement ();
183+ clipRect.setAttribute ('y' , '$yMax ' );
184+ clipRect.setAttribute ('height' , '${chartheight - (lineThickness / 2 )}' );
185+ clipRect.setAttribute ('x' , '${xZero - (lineThickness / 2 )}' );
186+ clipRect.setAttribute ('width' , '${drawingWidth + lineThickness }' );
187+ clipPath.append (clipRect);
188+ chart.append (clipPath);
189+
190+ // Chart lines and legends
191+
192+ final lines = < StringBuffer > [];
193+ for (int versionRange = 0 ; versionRange < values[0 ].length; versionRange++ ) {
194+ final line = StringBuffer ();
195+ var c = 'M' ;
196+ for (int week = 0 ; week < values.length; week++ ) {
197+ final (x, y) = computeCoordinates (
198+ computeDateForWeekNumber (newestDate, values.length, week),
199+ values[week][versionRange]);
200+ line.write (' $c $x $y ' );
201+ c = 'L' ;
202+ }
203+ lines.add (line);
204+ }
205+
206+ double legendXCoor = xZero - firstTicLabel.getBBox ().width / 2 ;
207+ double legendYCoor =
208+ ticLabelYCoor + firstTicLabel.getBBox ().height + 2 * padding;
209+ final legendWidth = 20 ;
210+ final legendHeight = 8 ;
211+
212+ for (int j = 0 ; j < lines.length; j++ ) {
213+ final path = SVGPathElement ();
214+ path.setAttribute ('class' , '${lineColorClasses [j ]} downloads-chart-line ' );
215+ // We assign colors in revers order so that main colors are chosen first for
216+ // the newest versions.
217+ path.setAttribute ('d' , '${lines [lines .length - 1 - j ]}' );
218+ path.setAttribute ('clip-path' , 'url(#clipRect)' );
219+ chart.append (path);
220+
221+ final legend = SVGRectElement ();
222+ chart.append (legend);
223+ legend.setAttribute (
224+ 'class' , 'downloads-chart-legend ${legendColorClasses [j ]}' );
225+ legend.setAttribute ('height' , '$legendHeight ' );
226+ legend.setAttribute ('width' , '$legendWidth ' );
227+
228+ final legendLabel = SVGTextElement ();
229+ chart.append (legendLabel);
230+ legendLabel.setAttribute (
231+ 'class' , 'downloads-chart-tic-label downloads-chart-tic-label-y' );
232+ legendLabel.text = ranges[j];
233+
234+ if (legendXCoor + padding + legendWidth + legendLabel.getBBox ().width >
235+ xMax) {
236+ // There is no room for the legend and label.
237+ // Make a new line and update legendXCoor and legendYCoor accordingly.
238+
239+ legendXCoor = xZero - firstTicLabel.getBBox ().width / 2 ;
240+ legendYCoor += 2 * padding + legendHeight;
241+ }
242+
243+ legend.setAttribute ('x' , '$legendXCoor ' );
244+ legend.setAttribute ('y' , '$legendYCoor ' );
245+ legendLabel.setAttribute ('y' , '${legendYCoor + legendHeight / 2 }' );
246+ legendLabel.setAttribute ('x' , '${legendXCoor + padding + legendWidth }' );
247+
248+ // Update x coordinate for next legend
249+ legendXCoor +=
250+ legendWidth + padding + legendLabel.getBBox ().width + 2 * padding;
251+ }
252+
253+ final height = legendYCoor + 3 * padding;
254+ final frame = SVGRectElement ();
255+ chart.append (frame);
256+ frame.setAttribute ('height' , '$height ' );
257+ frame.setAttribute ('width' , '$width ' );
258+ frame.setAttribute ('rx' , '15' );
259+ frame.setAttribute ('ry' , '15' );
260+ frame.setAttribute ('class' , 'downloads-chart-frame' );
261+ }
0 commit comments