| // |
| // Copyright 2014 Google Inc. All rights reserved. |
| // |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file or at |
| // https://developers.google.com/open-source/licenses/bsd |
| // |
| |
| part of charted.charts; |
| |
| class StackedBarChartRenderer extends CartesianRendererBase { |
| static const RADIUS = 2; |
| |
| final Iterable<int> dimensionsUsingBand = const [0]; |
| final bool alwaysAnimate; |
| |
| @override |
| final String name = "stack-rdr"; |
| |
| /// Used to capture the last measure with data in a data row. This is used |
| /// to decided whether to round the cornor of the bar or not. |
| List<int> _lastMeasureWithData = []; |
| |
| StackedBarChartRenderer({this.alwaysAnimate: false}); |
| |
| /// Returns false if the number of dimension axes on the area is 0. |
| /// Otherwise, the first dimension scale is used to render the chart. |
| @override |
| bool prepare(ChartArea area, ChartSeries series) { |
| if (area is! CartesianArea) { |
| throw new ArgumentError.value(area, 'area', |
| "ChartArea for StackedBarChartRenderer must be a CartesianArea"); |
| } |
| _ensureAreaAndSeries(area as CartesianArea, series); |
| return true; |
| } |
| |
| @override |
| void draw(Element element, {Future schedulePostRender}) { |
| _ensureReadyToDraw(element); |
| var verticalBars = !area.config.isLeftAxisPrimary; |
| |
| var measuresCount = series.measures.length, |
| measureScale = area.measureScales(series).first, |
| dimensionScale = area.dimensionScales.first; |
| |
| var rows = new List() |
| ..addAll(area.data.rows.map((e) => new List.generate(measuresCount, |
| (i) => e.elementAt(series.measures.elementAt(_reverseIdx(i)))))); |
| |
| var dimensionVals = area.data.rows |
| .map((row) => row.elementAt(area.config.dimensions.first)) |
| .toList(); |
| |
| var groups = root.selectAll('.stack-rdr-rowgroup').data(rows); |
| var animateBarGroups = alwaysAnimate || !groups.isEmpty; |
| groups.enter.append('g') |
| ..classed('stack-rdr-rowgroup') |
| ..attrWithCallback( |
| 'transform', |
| (d, i, c) => verticalBars |
| ? 'translate(${dimensionScale.scale(dimensionVals[i])}, 0)' |
| : 'translate(0, ${dimensionScale.scale(dimensionVals[i])})'); |
| groups.attrWithCallback('data-row', (d, i, e) => i); |
| groups.exit.remove(); |
| |
| if (animateBarGroups) { |
| groups.transition() |
| ..attrWithCallback( |
| 'transform', |
| (d, i, c) => verticalBars |
| ? 'translate(${dimensionScale.scale(dimensionVals[i])}, 0)' |
| : 'translate(0, ${dimensionScale.scale(dimensionVals[i])})') |
| ..duration(theme.transitionDurationMilliseconds); |
| } |
| |
| var bar = groups |
| .selectAll('.stack-rdr-bar') |
| .dataWithCallback((d, i, c) => d as Iterable); |
| |
| var prevOffsetVal = new List(); |
| |
| // Keep track of "y" values. |
| // These are used to insert values in the middle of stack when necessary |
| if (animateBarGroups) { |
| bar.each((d, i, e) { |
| var offset = e.dataset['offset'], |
| offsetVal = offset != null ? int.parse(offset) : 0; |
| if (i == 0) { |
| prevOffsetVal.add(offsetVal); |
| } else { |
| prevOffsetVal[prevOffsetVal.length - 1] = offsetVal; |
| } |
| }); |
| } |
| |
| var barWidth = (dimensionScale as OrdinalScale).rangeBand.toInt() - |
| theme.defaultStrokeWidth; |
| |
| // Calculate height of each segment in the bar. |
| // Uses prevAllZeroHeight and prevOffset to track previous segments |
| var prevAllZeroHeight = true; |
| num prevOffset = 0; |
| var getBarLength = (d, i) { |
| if (!verticalBars) return measureScale.scale(d).round(); |
| var retval = rect.height - (measureScale.scale(d) as num).round(); |
| if (i != 0) { |
| // If previous bars has 0 height, don't offset for spacing |
| // If any of the previous bar has non 0 height, do the offset. |
| retval -= prevAllZeroHeight |
| ? 1 |
| : (theme.defaultSeparatorWidth + theme.defaultStrokeWidth); |
| retval += prevOffset; |
| } else { |
| // When rendering next group of bars, reset prevZeroHeight. |
| prevOffset = 0; |
| prevAllZeroHeight = true; |
| retval -= 1; // -1 so bar does not overlap x axis. |
| } |
| |
| if (retval <= 0) { |
| prevOffset = prevAllZeroHeight |
| ? 0 |
| : theme.defaultSeparatorWidth + theme.defaultStrokeWidth + retval; |
| retval = 0; |
| } |
| prevAllZeroHeight = (retval == 0) && prevAllZeroHeight; |
| return retval; |
| }; |
| |
| // Initial "y" position of a bar that is being created. |
| // Only used when animateBarGroups is set to true. |
| var ic = 10000000, order = 0; |
| var getInitialBarPos = (int i) { |
| var tempY; |
| if (i <= ic && i > 0) { |
| tempY = prevOffsetVal[order]; |
| order++; |
| } else { |
| tempY = verticalBars ? rect.height : 0; |
| } |
| ic = i; |
| return tempY; |
| }; |
| |
| // Position of a bar in the stack. yPos is used to keep track of the |
| // offset based on previous calls to getBarY |
| num yPos = 0; |
| var getBarPos = (d, i) { |
| if (verticalBars) { |
| if (i == 0) { |
| yPos = (measureScale.scale(0) as num).round(); |
| } |
| return yPos -= (rect.height - (measureScale.scale(d) as num).round()); |
| } else { |
| if (i == 0) { |
| // 1 to not overlap the axis line. |
| yPos = 1; |
| } |
| var pos = yPos; |
| yPos += (measureScale.scale(d) as num).round(); |
| // Check if after adding the height of the bar, if y has changed, if |
| // changed, we offset for space between the bars. |
| if (yPos != pos) { |
| yPos += (theme.defaultSeparatorWidth + theme.defaultStrokeWidth); |
| } |
| return pos; |
| } |
| }; |
| |
| var buildPath = (d, int i, Element e, bool animate, int roundIdx) { |
| int position = animate ? getInitialBarPos(i) : getBarPos(d, i), |
| length = animate ? 0 : getBarLength(d, i), |
| radius = series.measures.elementAt(_reverseIdx(i)) == roundIdx |
| ? RADIUS |
| : 0; |
| var path = (length != 0) |
| ? verticalBars |
| ? topRoundedRect(0, position, barWidth, length, radius) |
| : rightRoundedRect(position, 0, length, barWidth, radius) |
| : ''; |
| e.attributes['data-offset'] = |
| verticalBars ? position.toString() : (position + length).toString(); |
| return path; |
| }; |
| |
| var enter = bar.enter.appendWithCallback((d, i, e) { |
| var rect = Namespace.createChildElement('path', e), |
| measure = series.measures.elementAt(_reverseIdx(i)), |
| row = int.parse(e.dataset['row']), |
| color = colorForValue(measure, row), |
| filter = filterForValue(measure, row), |
| style = stylesForValue(measure, row), |
| roundIndex = _lastMeasureWithData[row]; |
| |
| if (!isNullOrEmpty(style)) { |
| rect.classes.addAll(style); |
| } |
| rect.classes.add('stack-rdr-bar'); |
| |
| rect.attributes |
| ..['d'] = |
| buildPath(d == null ? 0 : d, i, rect, animateBarGroups, roundIndex) |
| ..['stroke-width'] = '${theme.defaultStrokeWidth}px' |
| ..['fill'] = color |
| ..['stroke'] = color; |
| |
| if (!isNullOrEmpty(filter)) { |
| rect.attributes['filter'] = filter; |
| } |
| if (!animateBarGroups) { |
| rect.attributes['data-column'] = '$measure'; |
| } |
| return rect; |
| }); |
| |
| enter |
| ..on('click', (d, i, e) => _event(mouseClickController, d, i, e)) |
| ..on('mouseover', (d, i, e) => _event(mouseOverController, d, i, e)) |
| ..on('mouseout', (d, i, e) => _event(mouseOutController, d, i, e)); |
| |
| if (animateBarGroups) { |
| bar.each((d, i, e) { |
| var measure = series.measures.elementAt(_reverseIdx(i)), |
| row = int.parse(e.parent.dataset['row']), |
| color = colorForValue(measure, row), |
| filter = filterForValue(measure, row), |
| styles = stylesForValue(measure, row); |
| e.attributes |
| ..['data-column'] = '$measure' |
| ..['fill'] = color |
| ..['stroke'] = color; |
| e.classes |
| ..removeAll(ChartState.VALUE_CLASS_NAMES) |
| ..addAll(styles); |
| if (isNullOrEmpty(filter)) { |
| e.attributes.remove('filter'); |
| } else { |
| e.attributes['filter'] = filter; |
| } |
| }); |
| |
| bar.transition() |
| ..attrWithCallback('d', (d, i, e) { |
| var row = int.parse(e.parent.dataset['row']), |
| roundIndex = _lastMeasureWithData[row]; |
| return buildPath(d == null ? 0 : d, i, e, false, roundIndex); |
| }); |
| } |
| |
| bar.exit.remove(); |
| } |
| |
| @override |
| void dispose() { |
| if (root == null) return; |
| root.selectAll('.stack-rdr-rowgroup').remove(); |
| } |
| |
| @override |
| double get bandInnerPadding => |
| area.theme.getDimensionAxisTheme().axisBandInnerPadding; |
| |
| @override |
| Extent get extent { |
| assert(area != null && series != null); |
| var rows = area.data.rows, rowIndex = 0; |
| num max = SMALL_INT_MIN; |
| num min = SMALL_INT_MAX; |
| _lastMeasureWithData = new List.generate(rows.length, (i) => -1); |
| |
| rows.forEach((row) { |
| num bar = null; |
| series.measures.forEach((idx) { |
| num value = row.elementAt(idx); |
| if (value != null && value.isFinite) { |
| if (bar == null) bar = 0; |
| bar += value; |
| if (value.round() != 0 && _lastMeasureWithData[rowIndex] == -1) { |
| _lastMeasureWithData[rowIndex] = idx; |
| } |
| } |
| }); |
| if (bar > max) max = bar; |
| if (bar < min) min = bar; |
| rowIndex++; |
| }); |
| |
| return new Extent(min, max); |
| } |
| |
| @override |
| void handleStateChanges(List<ChangeRecord> changes) { |
| var groups = host.querySelectorAll('.stack-rdr-rowgroup'); |
| if (groups == null || groups.isEmpty) return; |
| |
| for (int i = 0, len = groups.length; i < len; ++i) { |
| var group = groups.elementAt(i), |
| bars = group.querySelectorAll('.stack-rdr-bar'), |
| row = int.parse(group.dataset['row']); |
| |
| for (int j = 0, barsCount = bars.length; j < barsCount; ++j) { |
| var bar = bars.elementAt(j), |
| column = int.parse(bar.dataset['column']), |
| color = colorForValue(column, row), |
| filter = filterForValue(column, row); |
| |
| bar.classes.removeAll(ChartState.VALUE_CLASS_NAMES); |
| bar.classes.addAll(stylesForValue(column, row)); |
| bar.attributes |
| ..['fill'] = color |
| ..['stroke'] = color; |
| if (isNullOrEmpty(filter)) { |
| bar.attributes.remove('filter'); |
| } else { |
| bar.attributes['filter'] = filter; |
| } |
| } |
| } |
| } |
| |
| void _event(StreamController controller, data, int index, Element e) { |
| if (controller == null) return; |
| var rowStr = e.parent.dataset['row']; |
| var row = rowStr != null ? int.parse(rowStr) : null; |
| controller.add(new DefaultChartEventImpl(scope.event, area, series, row, |
| series.measures.elementAt(_reverseIdx(index)), data as num)); |
| } |
| |
| // Stacked bar chart renders items from bottom to top (first measure is at |
| // the bottom of the stack). We use [_reversedIdx] instead of index to |
| // match the color and order of what is displayed in the legend. |
| int _reverseIdx(int index) => series.measures.length - 1 - index; |
| } |