blob: 37dc579cea98bb907bd75671c57dd029ebfc708a [file] [log] [blame]
// 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));
}
}
}