| // Copyright 2017 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 StackedLineChartRenderer extends CartesianRendererBase { |
| final Iterable<int> dimensionsUsingBand = const []; |
| |
| final bool alwaysAnimate; |
| final bool showHoverCardOnTrackedDataPoints; |
| final bool trackDataPoints; |
| final bool trackOnDimensionAxis; |
| final int quantitativeScaleProximity; |
| |
| bool _trackingPointsCreated = false; |
| List<num> _xPositions = []; |
| |
| // Currently hovered row/column |
| int _savedOverRow = 0; |
| int _savedOverColumn = 0; |
| |
| int currentDataIndex = -1; |
| |
| @override |
| final String name = "stacked-line-rdr"; |
| |
| StackedLineChartRenderer( |
| {this.alwaysAnimate: false, |
| this.showHoverCardOnTrackedDataPoints: false, |
| this.trackDataPoints: true, |
| this.trackOnDimensionAxis: false, |
| this.quantitativeScaleProximity: 5}); |
| |
| // 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) { |
| _ensureAreaAndSeries(area as CartesianArea, series); |
| if (trackDataPoints) { |
| _trackPointerInArea(); |
| } |
| return area is CartesianArea; |
| } |
| |
| @override |
| void draw(Element element, {Future schedulePostRender}) { |
| _ensureReadyToDraw(element); |
| |
| var measureScale = area.measureScales(series).first, |
| dimensionScale = area.dimensionScales.first; |
| |
| // We only support one dimension axes, so we always use the |
| // first dimension. |
| var x = area.data.rows |
| .map((row) => row.elementAt(area.config.dimensions.first)) |
| .toList(); |
| |
| var accumulated = new List.filled(x.length, 0.0); |
| |
| var reversedMeasures = series.measures.toList().reversed.toList(); |
| // Create lists of values used for drawing. |
| // First Half: previous values reversed (need for drawing) |
| // Second Half: current accumulated values (need for drawing) |
| var lines = reversedMeasures.map((column) { |
| var row = area.data.rows.map((values) => values[column]).toList(); |
| return accumulated.reversed.toList() |
| ..addAll(new List.generate( |
| x.length, (i) => accumulated[i] += row[i] as num)); |
| }).toList(); |
| |
| var rangeBandOffset = |
| dimensionScale is OrdinalScale ? dimensionScale.rangeBand / 2 : 0; |
| |
| // If tracking data points is enabled, cache location of points that |
| // represent data. |
| if (trackDataPoints) { |
| _xPositions = x |
| .map((val) => (dimensionScale.scale(val) + rangeBandOffset) as num) |
| .toList(); |
| } |
| |
| var fillLine = new SvgLine( |
| xValueAccessor: (d, i) { |
| // The first x.length values are the bottom part of the path that |
| // should be drawn backword. The second part is the accumulated values |
| // that should be drawn forward. |
| var xval = i < x.length ? x[x.length - i - 1] : x[i - x.length]; |
| return (dimensionScale.scale(xval) as num) + rangeBandOffset; |
| }, |
| yValueAccessor: (d, i) => measureScale.scale(d) as num); |
| var strokeLine = new SvgLine( |
| xValueAccessor: (d, i) => |
| (dimensionScale.scale(x[i]) as num) + rangeBandOffset, |
| yValueAccessor: (d, i) => measureScale.scale(d) as num); |
| |
| // Add lines and hook up hover and selection events. |
| var svgLines = |
| root.selectAll('.stacked-line-rdr-line').data(lines.reversed); |
| svgLines.enter.append('g'); |
| |
| svgLines.each((d, i, e) { |
| var column = series.measures.elementAt(i), |
| color = colorForColumn(column), |
| filter = filterForColumn(column), |
| styles = stylesForColumn(column), |
| fill = new SvgElement.tag('path'), |
| stroke = new SvgElement.tag('path'), |
| fillData = d, |
| // Second half contains the accumulated data for this measure |
| strokeData = d.sublist(x.length, d.length); |
| e.attributes |
| ..['stroke'] = color |
| ..['fill'] = color |
| ..['class'] = styles.isEmpty |
| ? 'stacked-line-rdr-line' |
| : 'stacked-line-rdr-line ${styles.join(' ')}' |
| ..['data-column'] = '$column'; |
| fill.attributes |
| ..['d'] = fillLine.path(fillData, i, e) |
| ..['stroke'] = 'none'; |
| stroke.attributes |
| ..['d'] = strokeLine.path(strokeData, i, e) |
| ..['fill'] = 'none'; |
| e.children = [fill, stroke]; |
| if (isNullOrEmpty(filter)) { |
| e.attributes.remove('filter'); |
| } else { |
| e.attributes['filter'] = filter; |
| } |
| }); |
| |
| if (area.state != null) { |
| svgLines |
| ..on('click', (d, i, e) => _mouseClickHandler(d, i, e)) |
| ..on('mouseover', (d, i, e) => _mouseOverHandler(d, i, e)) |
| ..on('mouseout', (d, i, e) => _mouseOutHandler(d, i, e)); |
| } |
| |
| svgLines.exit.remove(); |
| } |
| |
| @override |
| void dispose() { |
| _disposer.dispose(); |
| if (root == null) return; |
| root.selectAll('.stacked-line-rdr-line').remove(); |
| root.selectAll('.stacked-line-rdr-point').remove(); |
| } |
| |
| @override |
| Extent get extent { |
| assert(area != null && series != null); |
| var rows = area.data.rows; |
| num max = SMALL_INT_MIN, min = SMALL_INT_MAX; |
| |
| rows.forEach((row) { |
| num line = null; |
| series.measures.forEach((idx) { |
| num value = row.elementAt(idx); |
| if (value != null && value.isFinite) { |
| if (line == null) line = 0.0; |
| line += value; |
| } |
| }); |
| if (line > max) max = line; |
| if (line < min) min = line; |
| }); |
| |
| return new Extent(min, max); |
| } |
| |
| @override |
| void handleStateChanges(List<ChangeRecord> changes) { |
| var lines = host.querySelectorAll('.stacked-line-rdr-line'); |
| if (lines == null || lines.isEmpty) return; |
| |
| for (int i = 0, len = lines.length; i < len; ++i) { |
| var line = lines.elementAt(i), |
| column = int.parse(line.dataset['column']), |
| filter = filterForColumn(column); |
| line.classes.removeAll(ChartState.COLUMN_CLASS_NAMES); |
| line.classes.addAll(stylesForColumn(column)); |
| line.attributes['stroke'] = colorForColumn(column); |
| line.attributes['fill'] = colorForColumn(column); |
| |
| if (isNullOrEmpty(filter)) { |
| line.attributes.remove('filter'); |
| } else { |
| line.attributes['filter'] = filter; |
| } |
| } |
| } |
| |
| void _createTrackingCircles() { |
| var linePoints = root |
| .selectAll('.stacked-line-rdr-point') |
| .data(series.measures.toList().reversed); |
| linePoints.enter.append('circle').each((d, i, e) { |
| e.classes.add('stacked-line-rdr-point'); |
| e.attributes['r'] = '4'; |
| }); |
| |
| linePoints |
| ..each((_d, i, e) { |
| int d = _d; |
| var color = colorForColumn(d); |
| e.attributes |
| ..['r'] = '4' |
| ..['stroke'] = color |
| ..['fill'] = color |
| ..['data-column'] = '$d'; |
| }) |
| ..on('click', _mouseClickHandler) |
| ..on('mousemove', _mouseOverHandler) // Ensure that we update values |
| ..on('mouseover', _mouseOverHandler) |
| ..on('mouseout', _mouseOutHandler); |
| |
| linePoints.exit.remove(); |
| _trackingPointsCreated = true; |
| } |
| |
| void _showTrackingCircles(ChartEvent event, int row) { |
| if (_trackingPointsCreated == false) { |
| _createTrackingCircles(); |
| } |
| |
| double cumulated = 0.0; |
| var yScale = area.measureScales(series).first; |
| root.selectAll('.stacked-line-rdr-point').each((_d, i, e) { |
| int d = _d; |
| var x = _xPositions[row], |
| measureVal = |
| cumulated += area.data.rows.elementAt(row).elementAt(d) as num; |
| if (measureVal != null && measureVal.isFinite) { |
| var color = colorForColumn(d), filter = filterForColumn(d); |
| e.attributes |
| ..['cx'] = '$x' |
| ..['cy'] = '${yScale.scale(measureVal)}' |
| ..['fill'] = color |
| ..['stroke'] = color |
| ..['data-row'] = '$row'; |
| e.style |
| ..setProperty('opacity', '1') |
| ..setProperty('visibility', 'visible'); |
| if (isNullOrEmpty(filter)) { |
| e.attributes.remove('filter'); |
| } else { |
| e.attributes['filter'] = filter; |
| } |
| } else { |
| e.style |
| ..setProperty('opacity', '$EPSILON') |
| ..setProperty('visibility', 'hidden'); |
| } |
| }); |
| |
| if (showHoverCardOnTrackedDataPoints) { |
| var firstMeasureColumn = series.measures.first; |
| mouseOverController.add(new DefaultChartEventImpl( |
| event.source, area, series, row, firstMeasureColumn, 0)); |
| _savedOverRow = row; |
| _savedOverColumn = firstMeasureColumn; |
| } |
| } |
| |
| void _hideTrackingCircles(ChartEvent event) { |
| root.selectAll('.stacked-line-rdr-point') |
| ..style('opacity', '0.0') |
| ..style('visibility', 'hidden'); |
| if (showHoverCardOnTrackedDataPoints) { |
| mouseOutController.add(new DefaultChartEventImpl( |
| event.source, area, series, _savedOverRow, _savedOverColumn, 0)); |
| } |
| } |
| |
| int _getNearestRowIndex(num x) { |
| num lastSmallerValue = 0.0; |
| var chartX = x - area.layout.renderArea.x; |
| for (var i = 0; i < _xPositions.length; i++) { |
| num pos = _xPositions[i]; |
| if (pos < chartX) { |
| lastSmallerValue = pos; |
| } else { |
| return i == 0 |
| ? 0 |
| : (chartX - lastSmallerValue <= pos - chartX) ? i - 1 : i; |
| } |
| } |
| return _xPositions.length - 1; |
| } |
| |
| void _trackPointerInArea() { |
| _trackingPointsCreated = false; |
| _disposer.add(area.onMouseMove.listen((ChartEvent event) { |
| if (area.layout.renderArea.contains(event.chartX, event.chartY)) { |
| var row = _getNearestRowIndex(event.chartX); |
| window.animationFrame.then((_) { |
| _showTrackingCircles(event, row); |
| }); |
| } else { |
| _hideTrackingCircles(event); |
| } |
| })); |
| _disposer.add(area.onMouseOut.listen((ChartEvent event) { |
| _hideTrackingCircles(event); |
| })); |
| } |
| |
| void _mouseClickHandler(d, int i, Element e) { |
| if (area.state != null) { |
| var selectedColumn = int.parse(e.dataset['column']); |
| area.state.isSelected(selectedColumn) |
| ? area.state.unselect(selectedColumn) |
| : area.state.select(selectedColumn); |
| } |
| if (mouseClickController != null && e.tagName == 'circle') { |
| var row = int.parse(e.dataset['row']), |
| column = int.parse(e.dataset['column']); |
| mouseClickController.add(new DefaultChartEventImpl( |
| scope.event, area, series, row, column, d as int)); |
| } |
| } |
| |
| void _mouseOverHandler(d, int i, Element e) { |
| if (area.state != null) { |
| area.state.preview = int.parse(e.dataset['column']); |
| } |
| if (mouseOverController != null && e.tagName == 'circle') { |
| _savedOverRow = int.parse(e.dataset['row']); |
| _savedOverColumn = int.parse(e.dataset['column']); |
| mouseOverController.add(new DefaultChartEventImpl(scope.event, area, |
| series, _savedOverRow, _savedOverColumn, d as int)); |
| } |
| } |
| |
| void _mouseOutHandler(d, int i, Element e) { |
| if (area.state != null && |
| area.state.preview == int.parse(e.dataset['column'])) { |
| area.state.preview = null; |
| } |
| if (mouseOutController != null && e.tagName == 'circle') { |
| mouseOutController.add(new DefaultChartEventImpl(scope.event, area, |
| series, _savedOverRow, _savedOverColumn, d as int)); |
| } |
| } |
| } |