blob: 9996dc2786e2b552b1f4e285265e948a5898ff7e [file] [log] [blame]
<!DOCTYPE html>
<!--
Copyright (c) 2014 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link rel="import" href="/tracing/base/iteration_helpers.html">
<link rel="import" href="/tracing/base/math/math.html">
<link rel="import" href="/tracing/base/math/range.html">
<link rel="import" href="/tracing/base/math/statistics.html">
<link rel="import" href="/tracing/base/raf.html">
<link rel="import" href="/tracing/ui/base/chart_base.html">
<link rel="import" href="/tracing/ui/base/mouse_tracker.html">
<style>
* /deep/ .chart-base-2d.updating-brushing-state #brushes > * {
fill: rgb(103, 199, 165)
}
* /deep/ .chart-base-2d #brushes {
fill: rgb(213, 236, 229)
}
</style>
<script>
'use strict';
tr.exportTo('tr.ui.b', function() {
// This does not include the tick labels.
var D3_Y_AXIS_WIDTH_PX = 9;
// This includes the tick labels.
var D3_X_AXIS_HEIGHT_PX = 23;
// For charts with log y-axes, the y-axis tick values may need to be sanitized
// if the data is zero or negative.
function sanitizePower(x, defaultValue) {
if (!isNaN(x) && isFinite(x) && (x !== 0)) return x;
return defaultValue;
}
var ChartBase2D = tr.ui.b.define('chart-base-2d', tr.ui.b.ChartBase);
ChartBase2D.prototype = {
__proto__: tr.ui.b.ChartBase.prototype,
decorate() {
super.decorate();
Polymer.dom(this).classList.add('chart-base-2d');
this.xScale_ = d3.scale.linear();
this.yScale_ = d3.scale.linear();
this.isYLogScale_ = false;
this.yLogScaleMin_ = undefined;
this.autoDataRange_ = new tr.b.math.Range();
this.overrideDataRange_ = undefined;
this.hideXAxis_ = false;
this.hideYAxis_ = false;
this.data_ = [];
this.xAxisLabel_ = '';
this.yAxisLabel_ = '';
this.textHeightPx_ = 0;
d3.select(this.chartAreaElement)
.append('g')
.attr('id', 'brushes');
d3.select(this.chartAreaElement)
.append('g')
.attr('id', 'series');
this.addEventListener('mousedown', this.onMouseDown_.bind(this));
},
get xAxisLabel() {
return this.xAxisLabel_;
},
set xAxisLabel(label) {
this.xAxisLabel_ = label;
},
get yAxisLabel() {
return this.yAxisLabel_;
},
set yAxisLabel(label) {
this.yAxisLabel_ = label;
},
get hideXAxis() {
return this.hideXAxis_;
},
set hideXAxis(h) {
this.hideXAxis_ = h;
this.updateContents_();
},
get hideYAxis() {
return this.hideYAxis_;
},
set hideYAxis(h) {
this.hideYAxis_ = h;
this.updateContents_();
},
get data() {
return this.data_;
},
/**
* Sets the data array for the object
*
* @param {Array} data The data. Each element must be an object, with at
* least an x property. All other properties become series names in the
* chart. The data can be sparse (i.e. every x value does not have to
* contain data for every series).
*/
set data(data) {
if (data === undefined) {
throw new Error('data must be an Array');
}
this.data_ = data;
this.updateSeriesKeys_();
this.updateDataRange_();
this.updateContents_();
},
set isYLogScale(logScale) {
if (logScale) {
this.yScale_ = d3.scale.log(10);
} else {
this.yScale_ = d3.scale.linear();
}
this.isYLogScale_ = logScale;
},
getYScaleMin_() {
return this.isYLogScale_ ? this.yLogScaleMin_ : 0;
},
getYScaleDomain_(minValue, maxValue) {
if (this.overrideDataRange_ !== undefined) {
return [this.dataRange.min, this.dataRange.max];
}
if (this.isYLogScale_) {
return [this.getYScaleMin_(), maxValue];
}
return [Math.min(minValue, this.getYScaleMin_()), maxValue];
},
getSampleWidth_(data, index, leftSide) {
var leftIndex;
var rightIndex;
if (leftSide) {
leftIndex = Math.max(index - 1, 0);
rightIndex = index;
} else {
leftIndex = index;
rightIndex = Math.min(index + 1, data.length - 1);
}
var leftWidth = this.getXForDatum_(data[index], index) -
this.getXForDatum_(data[leftIndex], leftIndex);
var rightWidth = this.getXForDatum_(data[rightIndex], rightIndex) -
this.getXForDatum_(data[index], index);
return tr.b.math.Statistics.mean([leftWidth, rightWidth]);
},
updateSeriesKeys_() {
// Don't clear seriesByKey_; the caller might have put state in it using
// getDataSeries() before setting data.
this.data_.forEach(function(datum) {
Object.keys(datum).forEach(function(key) {
if (this.isDatumFieldSeries_(key))
this.getDataSeries(key);
}, this);
}, this);
},
isDatumFieldSeries_(fieldName) {
return fieldName !== 'x';
},
getXForDatum_(datum, index) {
return datum.x;
},
updateMargins_() {
this.margin.left = this.hideYAxis ? 0 : this.yAxisWidth;
this.margin.bottom = this.hideXAxis ? 0 : this.xAxisHeight;
if (this.hideXAxis && !this.hideYAxis) {
this.margin.bottom = 10;
}
if (this.hideYAxis && !this.hideXAxis) {
this.margin.left = 10;
}
this.margin.top = this.hideYAxis ? 0 : 10;
if (this.yAxisLabel) {
this.margin.top += this.textHeightPx_;
}
if (this.xAxisLabel) {
this.margin.right = Math.max(this.margin.right,
16 + tr.ui.b.getSVGTextSize(this, this.xAxisLabel).width);
}
super.updateMargins_();
},
get xAxisHeight() {
return D3_X_AXIS_HEIGHT_PX;
},
computeScaleTickWidth_(scale) {
if (this.data.length === 0) return 0;
var tickValues = scale.ticks();
var format = scale.tickFormat();
if (this.isYLogScale_) {
var enclosingPowers = this.dataRange.enclosingPowers();
tickValues = [
sanitizePower(enclosingPowers.min, 1),
sanitizePower(enclosingPowers.max, 10),
];
format = v => v.toString();
}
return D3_Y_AXIS_WIDTH_PX + Math.max(
tr.ui.b.getSVGTextSize(this, format(tickValues[0])).width,
tr.ui.b.getSVGTextSize(
this, format(tickValues[tickValues.length - 1])).width);
},
get yAxisWidth() {
return this.computeScaleTickWidth_(this.yScale_);
},
updateScales_() {
if (this.data_.length === 0) return;
this.xScale_.range([0, this.graphWidth]);
this.xScale_.domain(d3.extent(this.data_, this.getXForDatum_.bind(this)));
this.yScale_.range([this.graphHeight, 0]);
this.yScale_.domain([this.dataRange.min, this.dataRange.max]);
},
updateBrushContents_(brushSel) {
brushSel.selectAll('*').remove();
},
updateXAxis_(xAxis) {
xAxis.selectAll('*').remove();
xAxis[0][0].style.opacity = 0;
if (this.hideXAxis) return;
this.drawXAxis_(xAxis);
var label = xAxis.append('text').attr('class', 'label');
this.drawXAxisTicks_(xAxis);
this.drawXAxisLabel_(label);
xAxis[0][0].style.opacity = 1;
},
drawXAxis_(xAxis) {
xAxis.attr('transform', 'translate(0,' + this.graphHeight + ')')
.call(d3.svg.axis()
.scale(this.xScale_)
.orient('bottom'));
},
drawXAxisLabel_(label) {
label
.attr('x', this.graphWidth + 16)
.attr('y', 8)
.text(this.xAxisLabel);
},
drawXAxisTicks_(xAxis) {
var previousRight = undefined;
xAxis.selectAll('.tick')[0].forEach(function(tick) {
var currentLeft = tick.transform.baseVal[0].matrix.e;
if ((previousRight === undefined) ||
(currentLeft > (previousRight + 3))) {
var currentWidth = tick.getBBox().width;
previousRight = currentLeft + currentWidth;
} else {
tick.style.opacity = 0;
}
});
},
set overrideDataRange(range) {
this.overrideDataRange_ = range;
},
get dataRange() {
if (this.overrideDataRange_ !== undefined) {
return this.overrideDataRange_;
}
return this.autoDataRange_;
},
updateDataRange_() {
if (this.overrideDataRange_ !== undefined) return;
var dataBySeriesKey = this.getDataBySeriesKey_();
this.autoDataRange_.reset();
for (var [series, values] of Object.entries(dataBySeriesKey)) {
for (var i = 0; i < values.length; i++) {
this.autoDataRange_.addValue(values[i][series]);
}
}
// Choose the closest power of 10, rounded down, as the smallest tick
// to display.
this.yLogScaleMin_ = undefined;
if (this.autoDataRange_.min !== undefined) {
var minValue = this.autoDataRange_.min;
if (minValue === 0)
minValue = 1;
var onePowerLess = tr.b.math.lesserPower(minValue / 10);
this.yLogScaleMin_ = onePowerLess;
}
},
updateYAxis_(yAxis) {
yAxis.selectAll('*').remove();
yAxis[0][0].style.opacity = 0;
if (this.hideYAxis) return;
this.drawYAxis_(yAxis);
this.drawYAxisTicks_(yAxis);
var label = yAxis.append('text').attr('class', 'label');
this.drawYAxisLabel_(label);
},
drawYAxis_(yAxis) {
var axisModifier = d3.svg.axis()
.scale(this.yScale_)
.orient('left');
if (this.isYLogScale_) {
if (this.yLogScaleMin_ === undefined) return;
var tickValues = [];
var enclosingPowers = this.dataRange.enclosingPowers();
var maxPower = sanitizePower(enclosingPowers.max, 10);
for (var power = sanitizePower(enclosingPowers.min, 1);
power <= maxPower;
power *= 10) {
tickValues.push(power);
}
// The default tickFormat() for log scales always uses scientific
// notation. Override it to use Number.toString(), which only uses
// scientific notation for extreme values, and uses decimal notation for
// a broader range of values. Decimal notation is generally slightly
// easier to skim than scientific notation in the context of chart axes.
axisModifier = axisModifier
.tickValues(tickValues)
.tickFormat(v => v.toString());
}
yAxis.call(axisModifier);
},
drawYAxisLabel_(label) {
var labelWidthPx = Math.ceil(tr.ui.b.getSVGTextSize(
this.chartAreaElement, this.yAxisLabel).width);
label
.attr('x', -labelWidthPx)
.attr('y', -8)
.text(this.yAxisLabel);
},
drawYAxisTicks_(yAxis) {
var previousTop = undefined;
yAxis.selectAll('.tick')[0].forEach(function(tick) {
var bbox = tick.getBBox();
var currentTop = tick.transform.baseVal[0].matrix.f;
var currentBottom = currentTop + bbox.height;
if ((previousTop === undefined) ||
(previousTop > (currentBottom + 3))) {
previousTop = currentTop;
} else {
tick.style.opacity = 0;
}
});
yAxis[0][0].style.opacity = 1;
},
updateContents_() {
if (this.textHeightPx_ === 0) {
// Measure the height of a string that is as tall as it can be,
// with both an ascender and a descender.
// https://en.wikipedia.org/wiki/Ascender_(typography)
this.textHeightPx_ = tr.ui.b.getSVGTextSize(this, 'Ay').height;
// If the chart is not yet rooted in a document, then the height will be
// 0. Callers should make sure that updateContents_ is called at least
// once after the chart is rooted in a document so that textHeightPx_
// can be computed.
}
this.updateScales_();
super.updateContents_();
var chartAreaSel = d3.select(this.chartAreaElement);
this.updateXAxis_(chartAreaSel.select('.x.axis'));
this.updateYAxis_(chartAreaSel.select('.y.axis'));
this.updateBrushContents_(chartAreaSel.select('#brushes'));
this.updateDataContents_(chartAreaSel.select('#series'));
},
updateDataContents_(seriesSel) {
throw new Error('Not implemented');
},
/**
* Returns a map of series key to the data for that series.
*
* Example:
* // returns {y: [{x: 1, y: 1}, {x: 3, y: 3}], z: [{x: 2, z: 2}]}
* this.data_ = [{x: 1, y: 1}, {x: 2, z: 2}, {x: 3, y: 3}];
* this.getDataBySeriesKey_();
* @return {Object} A map of series data by series key.
*/
getDataBySeriesKey_() {
var dataBySeriesKey = {};
for (var [key, series] of this.seriesByKey_) {
dataBySeriesKey[key] = [];
}
this.data_.forEach(function(multiSeriesDatum, index) {
var x = this.getXForDatum_(multiSeriesDatum, index);
d3.keys(multiSeriesDatum).forEach(function(seriesKey) {
// Skip 'x' - it's not a series
if (seriesKey === 'x')
return;
if (multiSeriesDatum[seriesKey] === undefined)
return;
if (!this.isDatumFieldSeries_(seriesKey))
return;
var singleSeriesDatum = {x: x};
singleSeriesDatum[seriesKey] = multiSeriesDatum[seriesKey];
dataBySeriesKey[seriesKey].push(singleSeriesDatum);
}, this);
}, this);
return dataBySeriesKey;
},
getChartPointAtClientPoint_(clientPoint) {
var rect = this.getBoundingClientRect();
return {
x: clientPoint.x - rect.left - this.margin.left,
y: clientPoint.y - rect.top - this.margin.top
};
},
getDataPointAtChartPoint_(chartPoint) {
return {
x: tr.b.math.clamp(this.xScale_.invert(chartPoint.x),
this.xScale_.domain()[0], this.xScale_.domain()[1]),
y: tr.b.math.clamp(this.yScale_.invert(chartPoint.y),
this.yScale_.domain()[0], this.yScale_.domain()[1])
};
},
getDataPointAtClientPoint_(clientX, clientY) {
var chartPoint = this.getChartPointAtClientPoint_(
{x: clientX, y: clientY});
return this.getDataPointAtChartPoint_(chartPoint);
},
prepareDataEvent_(mouseEvent, dataEvent) {
var dataPoint = this.getDataPointAtClientPoint_(
mouseEvent.clientX, mouseEvent.clientY);
dataEvent.x = dataPoint.x;
dataEvent.y = dataPoint.y;
},
onMouseDown_(mouseEvent) {
tr.ui.b.trackMouseMovesUntilMouseUp(
this.onMouseMove_.bind(this, mouseEvent.button),
this.onMouseUp_.bind(this, mouseEvent.button));
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
var dataEvent = new tr.b.Event('item-mousedown');
dataEvent.button = mouseEvent.button;
Polymer.dom(this).classList.add('updating-brushing-state');
this.prepareDataEvent_(mouseEvent, dataEvent);
this.dispatchEvent(dataEvent);
},
onMouseMove_(button, mouseEvent) {
if (mouseEvent.buttons !== undefined) {
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
}
var dataEvent = new tr.b.Event('item-mousemove');
dataEvent.button = button;
this.prepareDataEvent_(mouseEvent, dataEvent);
this.dispatchEvent(dataEvent);
},
onMouseUp_(button, mouseEvent) {
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
var dataEvent = new tr.b.Event('item-mouseup');
dataEvent.button = button;
this.prepareDataEvent_(mouseEvent, dataEvent);
this.dispatchEvent(dataEvent);
Polymer.dom(this).classList.remove('updating-brushing-state');
}
};
return {
ChartBase2D,
};
});
</script>