| <!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> |