OLD | NEW |
| (Empty) |
1 // | |
2 // Copyright 2014 Google Inc. All rights reserved. | |
3 // | |
4 // Use of this source code is governed by a BSD-style | |
5 // license that can be found in the LICENSE file or at | |
6 // https://developers.google.com/open-source/licenses/bsd | |
7 // | |
8 | |
9 part of charted.charts; | |
10 | |
11 class StackedBarChartRenderer extends CartesianRendererBase { | |
12 static const RADIUS = 2; | |
13 | |
14 final Iterable<int> dimensionsUsingBand = const[0]; | |
15 final bool alwaysAnimate; | |
16 | |
17 @override | |
18 final String name = "stack-rdr"; | |
19 | |
20 /// Used to capture the last measure with data in a data row. This is used | |
21 /// to decided whether to round the cornor of the bar or not. | |
22 List<int> _lastMeasureWithData = []; | |
23 | |
24 StackedBarChartRenderer({this.alwaysAnimate: false}); | |
25 | |
26 /// Returns false if the number of dimension axes on the area is 0. | |
27 /// Otherwise, the first dimension scale is used to render the chart. | |
28 @override | |
29 bool prepare(CartesianArea area, ChartSeries series) { | |
30 _ensureAreaAndSeries(area, series); | |
31 return true; | |
32 } | |
33 | |
34 @override | |
35 void draw(Element element, {Future schedulePostRender}) { | |
36 _ensureReadyToDraw(element); | |
37 var verticalBars = !area.config.isLeftAxisPrimary; | |
38 | |
39 var measuresCount = series.measures.length, | |
40 measureScale = area.measureScales(series).first, | |
41 dimensionScale = area.dimensionScales.first; | |
42 | |
43 var rows = new List() | |
44 ..addAll(area.data.rows.map((e) => | |
45 new List.generate(measuresCount, | |
46 (i) => e.elementAt(series.measures.elementAt(_reverseIdx(i)))))); | |
47 | |
48 var dimensionVals = area.data.rows.map( | |
49 (row) => row.elementAt(area.config.dimensions.first)).toList(); | |
50 | |
51 var groups = root.selectAll('.stack-rdr-rowgroup').data(rows); | |
52 var animateBarGroups = alwaysAnimate || !groups.isEmpty; | |
53 groups.enter.append('g') | |
54 ..classed('stack-rdr-rowgroup') | |
55 ..attrWithCallback('transform', (d, i, c) => verticalBars ? | |
56 'translate(${dimensionScale.scale(dimensionVals[i])}, 0)' : | |
57 'translate(0, ${dimensionScale.scale(dimensionVals[i])})'); | |
58 groups.attrWithCallback('data-row', (d, i, e) => i); | |
59 groups.exit.remove(); | |
60 | |
61 if (animateBarGroups) { | |
62 groups.transition() | |
63 ..attrWithCallback('transform', (d, i, c) => verticalBars ? | |
64 'translate(${dimensionScale.scale(dimensionVals[i])}, 0)' : | |
65 'translate(0, ${dimensionScale.scale(dimensionVals[i])})') | |
66 ..duration(theme.transitionDurationMilliseconds); | |
67 } | |
68 | |
69 var bar = | |
70 groups.selectAll('.stack-rdr-bar').dataWithCallback((d, i, c) => d); | |
71 | |
72 var prevOffsetVal = new List(); | |
73 | |
74 // Keep track of "y" values. | |
75 // These are used to insert values in the middle of stack when necessary | |
76 if (animateBarGroups) { | |
77 bar.each((d, i, e) { | |
78 var offset = e.dataset['offset'], | |
79 offsetVal = offset != null ? int.parse(offset) : 0; | |
80 if (i == 0) { | |
81 prevOffsetVal.add(offsetVal); | |
82 } else { | |
83 prevOffsetVal[prevOffsetVal.length - 1] = offsetVal; | |
84 } | |
85 }); | |
86 } | |
87 | |
88 | |
89 var barWidth = dimensionScale.rangeBand - theme.defaultStrokeWidth; | |
90 | |
91 // Calculate height of each segment in the bar. | |
92 // Uses prevAllZeroHeight and prevOffset to track previous segments | |
93 var prevAllZeroHeight = true, | |
94 prevOffset = 0; | |
95 var getBarLength = (d, i) { | |
96 if (!verticalBars) return measureScale.scale(d).round(); | |
97 var retval = rect.height - measureScale.scale(d).round(); | |
98 if (i != 0) { | |
99 // If previous bars has 0 height, don't offset for spacing | |
100 // If any of the previous bar has non 0 height, do the offset. | |
101 retval -= prevAllZeroHeight | |
102 ? 1 | |
103 : (theme.defaultSeparatorWidth + theme.defaultStrokeWidth); | |
104 retval += prevOffset; | |
105 } else { | |
106 // When rendering next group of bars, reset prevZeroHeight. | |
107 prevOffset = 0; | |
108 prevAllZeroHeight = true; | |
109 retval -= 1; // -1 so bar does not overlap x axis. | |
110 } | |
111 | |
112 if (retval <= 0) { | |
113 prevOffset = prevAllZeroHeight | |
114 ? 0 | |
115 : theme.defaultSeparatorWidth + theme.defaultStrokeWidth + retval; | |
116 retval = 0; | |
117 } | |
118 prevAllZeroHeight = (retval == 0) && prevAllZeroHeight; | |
119 return retval; | |
120 }; | |
121 | |
122 // Initial "y" position of a bar that is being created. | |
123 // Only used when animateBarGroups is set to true. | |
124 var ic = 10000000, | |
125 order = 0; | |
126 var getInitialBarPos = (i) { | |
127 var tempY; | |
128 if (i <= ic && i > 0) { | |
129 tempY = prevOffsetVal[order]; | |
130 order++; | |
131 } else { | |
132 tempY = verticalBars ? rect.height : 0; | |
133 } | |
134 ic = i; | |
135 return tempY; | |
136 }; | |
137 | |
138 // Position of a bar in the stack. yPos is used to keep track of the | |
139 // offset based on previous calls to getBarY | |
140 var yPos = 0; | |
141 var getBarPos = (d, i) { | |
142 if (verticalBars) { | |
143 if (i == 0) { | |
144 yPos = measureScale.scale(0).round(); | |
145 } | |
146 return yPos -= (rect.height - measureScale.scale(d).round()); | |
147 } else { | |
148 if (i == 0) { | |
149 // 1 to not overlap the axis line. | |
150 yPos = 1; | |
151 } | |
152 var pos = yPos; | |
153 yPos += measureScale.scale(d).round(); | |
154 // Check if after adding the height of the bar, if y has changed, if | |
155 // changed, we offset for space between the bars. | |
156 if (yPos != pos) { | |
157 yPos += (theme.defaultSeparatorWidth + theme.defaultStrokeWidth); | |
158 } | |
159 return pos; | |
160 } | |
161 }; | |
162 | |
163 var buildPath = (d, int i, Element e, bool animate, int roundIdx) { | |
164 var position = animate ? getInitialBarPos(i) : getBarPos(d, i), | |
165 length = animate ? 0 : getBarLength(d, i), | |
166 radius = series.measures.elementAt(_reverseIdx(i)) == roundIdx ? RADIU
S : 0, | |
167 path = (length != 0) | |
168 ? verticalBars | |
169 ? topRoundedRect(0, position, barWidth, length, radius) | |
170 : rightRoundedRect(position, 0, length, barWidth, radius) | |
171 : ''; | |
172 e.attributes['data-offset'] = verticalBars ? | |
173 position.toString() : (position + length).toString(); | |
174 return path; | |
175 }; | |
176 | |
177 var enter = bar.enter.appendWithCallback((d, i, e) { | |
178 var rect = Namespace.createChildElement('path', e), | |
179 measure = series.measures.elementAt(_reverseIdx(i)), | |
180 row = int.parse(e.dataset['row']), | |
181 color = colorForValue(measure, row), | |
182 filter = filterForValue(measure, row), | |
183 style = stylesForValue(measure, row), | |
184 roundIndex = _lastMeasureWithData[row]; | |
185 | |
186 if (!isNullOrEmpty(style)) { | |
187 rect.classes.addAll(style); | |
188 } | |
189 rect.classes.add('stack-rdr-bar'); | |
190 | |
191 rect.attributes | |
192 ..['d'] = buildPath (d == null ? 0 : d, i, rect, animateBarGroups, | |
193 roundIndex) | |
194 ..['stroke-width'] = '${theme.defaultStrokeWidth}px' | |
195 ..['fill'] = color | |
196 ..['stroke'] = color; | |
197 | |
198 if (!isNullOrEmpty(filter)) { | |
199 rect.attributes['filter'] = filter; | |
200 } | |
201 if (!animateBarGroups) { | |
202 rect.attributes['data-column'] = '$measure'; | |
203 } | |
204 return rect; | |
205 }); | |
206 | |
207 enter | |
208 ..on('click', (d, i, e) => _event(mouseClickController, d, i, e)) | |
209 ..on('mouseover', (d, i, e) => _event(mouseOverController, d, i, e)) | |
210 ..on('mouseout', (d, i, e) => _event(mouseOutController, d, i, e)); | |
211 | |
212 if (animateBarGroups) { | |
213 bar.each((d, i, e) { | |
214 var measure = series.measures.elementAt(_reverseIdx(i)), | |
215 row = int.parse(e.parent.dataset['row']), | |
216 color = colorForValue(measure, row), | |
217 filter = filterForValue(measure, row), | |
218 styles = stylesForValue(measure, row); | |
219 e.attributes | |
220 ..['data-column'] = '$measure' | |
221 ..['fill'] = color | |
222 ..['stroke'] = color; | |
223 e.classes | |
224 ..removeAll(ChartState.VALUE_CLASS_NAMES) | |
225 ..addAll(styles); | |
226 if (isNullOrEmpty(filter)) { | |
227 e.attributes.remove('filter'); | |
228 } else { | |
229 e.attributes['filter'] = filter; | |
230 } | |
231 }); | |
232 | |
233 bar.transition() | |
234 ..attrWithCallback('d', (d, i, e) { | |
235 var row = int.parse(e.parent.dataset['row']), | |
236 roundIndex = _lastMeasureWithData[row]; | |
237 return buildPath(d == null ? 0 : d, i, e, false, roundIndex); | |
238 }); | |
239 } | |
240 | |
241 bar.exit.remove(); | |
242 } | |
243 | |
244 @override | |
245 void dispose() { | |
246 if (root == null) return; | |
247 root.selectAll('.stack-rdr-rowgroup').remove(); | |
248 } | |
249 | |
250 @override | |
251 double get bandInnerPadding => | |
252 area.theme.getDimensionAxisTheme().axisBandInnerPadding; | |
253 | |
254 @override | |
255 Extent get extent { | |
256 assert(area != null && series != null); | |
257 var rows = area.data.rows, | |
258 max = SMALL_INT_MIN, | |
259 min = SMALL_INT_MAX, | |
260 rowIndex = 0; | |
261 _lastMeasureWithData = new List.generate(rows.length, (i) => -1); | |
262 | |
263 rows.forEach((row) { | |
264 var bar = null; | |
265 series.measures.forEach((idx) { | |
266 var value = row.elementAt(idx); | |
267 if (value != null && value.isFinite) { | |
268 if (bar == null) bar = 0; | |
269 bar += value; | |
270 if (value.round() != 0 && _lastMeasureWithData[rowIndex] == -1) { | |
271 _lastMeasureWithData[rowIndex] = idx; | |
272 } | |
273 } | |
274 }); | |
275 if (bar > max) max = bar; | |
276 if (bar < min) min = bar; | |
277 rowIndex++; | |
278 }); | |
279 | |
280 return new Extent(min, max); | |
281 } | |
282 | |
283 @override | |
284 void handleStateChanges(List<ChangeRecord> changes) { | |
285 var groups = host.querySelectorAll('.stack-rdr-rowgroup'); | |
286 if (groups == null || groups.isEmpty) return; | |
287 | |
288 for(int i = 0, len = groups.length; i < len; ++i) { | |
289 var group = groups.elementAt(i), | |
290 bars = group.querySelectorAll('.stack-rdr-bar'), | |
291 row = int.parse(group.dataset['row']); | |
292 | |
293 for(int j = 0, barsCount = bars.length; j < barsCount; ++j) { | |
294 var bar = bars.elementAt(j), | |
295 column = int.parse(bar.dataset['column']), | |
296 color = colorForValue(column, row), | |
297 filter = filterForValue(column, row); | |
298 | |
299 bar.classes.removeAll(ChartState.VALUE_CLASS_NAMES); | |
300 bar.classes.addAll(stylesForValue(column, row)); | |
301 bar.attributes | |
302 ..['fill'] = color | |
303 ..['stroke'] = color; | |
304 if (isNullOrEmpty(filter)) { | |
305 bar.attributes.remove('filter'); | |
306 } else { | |
307 bar.attributes['filter'] = filter; | |
308 } | |
309 } | |
310 } | |
311 } | |
312 | |
313 void _event(StreamController controller, data, int index, Element e) { | |
314 if (controller == null) return; | |
315 var rowStr = e.parent.dataset['row']; | |
316 var row = rowStr != null ? int.parse(rowStr) : null; | |
317 controller.add(new DefaultChartEventImpl( | |
318 scope.event, area, series, row, | |
319 series.measures.elementAt(_reverseIdx(index)), data)); | |
320 } | |
321 | |
322 // Stacked bar chart renders items from bottom to top (first measure is at | |
323 // the bottom of the stack). We use [_reversedIdx] instead of index to | |
324 // match the color and order of what is displayed in the legend. | |
325 int _reverseIdx(int index) => series.measures.length - 1 - index; | |
326 } | |
OLD | NEW |