blob: 143db3172682d8a4ee048929dca9146297867537 [file] [log] [blame]
App.InteractiveChartComponent = Ember.Component.extend({
chartData: null,
showXAxis: true,
showYAxis: true,
interactive: false,
enableSelection: true,
classNames: ['chart'],
init: function ()
{
this._super();
this._needsConstruction = true;
this._eventHandlers = [];
$(window).resize(this._updateDimensionsIfNeeded.bind(this));
},
chartDataDidChange: function ()
{
var chartData = this.get('chartData');
if (!chartData)
return;
this._needsConstruction = true;
this._totalWidth = undefined;
this._totalHeight = undefined;
this._constructGraphIfPossible(chartData);
}.observes('chartData').on('init'),
didInsertElement: function ()
{
var chartData = this.get('chartData');
if (chartData)
this._constructGraphIfPossible(chartData);
if (this.get('interactive')) {
var element = this.get('element');
this._attachEventListener(element, "mousemove", this._mouseMoved.bind(this));
this._attachEventListener(element, "mouseleave", this._mouseLeft.bind(this));
this._attachEventListener(element, "mousedown", this._mouseDown.bind(this));
this._attachEventListener($(element).parents("[tabindex]"), "keydown", this._keyPressed.bind(this));
}
},
willClearRender: function ()
{
this._eventHandlers.forEach(function (item) {
$(item[0]).off(item[1], item[2]);
})
},
_attachEventListener: function(target, eventName, listener)
{
this._eventHandlers.push([target, eventName, listener]);
$(target).on(eventName, listener);
},
_constructGraphIfPossible: function (chartData)
{
if (!this._needsConstruction || !this.get('element'))
return;
var element = this.get('element');
this._x = d3.time.scale();
this._y = d3.scale.linear();
if (this._svgElement)
this._svgElement.remove();
this._svgElement = d3.select(element).append("svg")
.attr("width", "100%")
.attr("height", "100%");
var svg = this._svg = this._svgElement.append("g");
var clipId = element.id + "-clip";
this._clipPath = svg.append("defs").append("clipPath")
.attr("id", clipId)
.append("rect");
if (this.get('showXAxis')) {
this._xAxis = d3.svg.axis().scale(this._x).orient("bottom").ticks(6);
this._xAxisLabels = svg.append("g")
.attr("class", "x axis");
}
var isInteractive = this.get('interactive');
if (this.get('showYAxis')) {
this._yAxis = d3.svg.axis().scale(this._y).orient("left").ticks(6).tickFormat(chartData.formatter);
this._yAxisLabels = svg.append('g').attr('class', 'y axis' + (isInteractive ? ' interactive' : ''));
if (isInteractive) {
var self = this;
this._yAxisLabels.on('click', function () { self.toggleProperty('showFullYAxis'); });
}
}
this._clippedContainer = svg.append("g")
.attr("clip-path", "url(#" + clipId + ")");
var xScale = this._x;
var yScale = this._y;
this._timeLine = d3.svg.line()
.x(function(point) { return xScale(point.time); })
.y(function(point) { return yScale(point.value); });
this._confidenceArea = d3.svg.area()
// .interpolate("cardinal")
.x(function(point) { return xScale(point.time); })
.y0(function(point) { return point.interval ? yScale(point.interval[0]) : null; })
.y1(function(point) { return point.interval ? yScale(point.interval[1]) : null; });
this._paths = [];
this._areas = [];
this._dots = [];
this._highlights = null;
this._currentTimeSeries = chartData.current;
this._currentTimeSeriesData = this._currentTimeSeries.series();
this._baselineTimeSeries = chartData.baseline;
this._targetTimeSeries = chartData.target;
this._movingAverageTimeSeries = chartData.movingAverage;
if (this._baselineTimeSeries) {
this._paths.push(this._clippedContainer
.append("path")
.datum(this._baselineTimeSeries.series())
.attr("class", "baseline"));
}
if (this._targetTimeSeries) {
this._paths.push(this._clippedContainer
.append("path")
.datum(this._targetTimeSeries.series())
.attr("class", "target"));
}
var movingAverageIsVisible = this._movingAverageTimeSeries;
var foregroundClass = movingAverageIsVisible ? '' : ' foreground';
this._areas.push(this._clippedContainer
.append("path")
.datum(this._currentTimeSeriesData)
.attr("class", "area" + foregroundClass));
this._paths.push(this._clippedContainer
.append("path")
.datum(this._currentTimeSeriesData)
.attr("class", "current" + foregroundClass));
this._dots.push(this._clippedContainer
.selectAll(".dot")
.data(this._currentTimeSeriesData)
.enter().append("circle")
.attr("class", "dot" + foregroundClass));
if (movingAverageIsVisible) {
this._paths.push(this._clippedContainer
.append("path")
.datum(this._movingAverageTimeSeries.series())
.attr("class", "movingAverage"));
this._areas.push(this._clippedContainer
.append("path")
.datum(this._movingAverageTimeSeries.series())
.attr("class", "envelope"));
}
if (isInteractive) {
this._currentItemLine = this._clippedContainer
.append("line")
.attr("class", "current-item");
this._currentItemCircle = this._clippedContainer
.append("circle")
.attr("class", "dot current-item")
.attr("r", 3);
}
this._brush = null;
if (this.get('enableSelection')) {
this._brush = d3.svg.brush()
.x(this._x)
.on("brush", this._brushChanged.bind(this));
this._brushRect = this._clippedContainer
.append("g")
.attr("class", "x brush");
}
this._updateDomain();
this._updateDimensionsIfNeeded();
// Work around the fact the brush isn't set if we updated it synchronously here.
setTimeout(this._selectionChanged.bind(this), 0);
setTimeout(this._selectedItemChanged.bind(this), 0);
this._needsConstruction = false;
this._highlightedItemsChanged();
this._rangesChanged();
},
_updateDomain: function ()
{
var xDomain = this.get('domain');
if (!xDomain || !this._currentTimeSeriesData)
return null;
var intrinsicXDomain = this._computeXAxisDomain(this._currentTimeSeriesData);
if (!xDomain)
xDomain = intrinsicXDomain;
var yDomain = this._computeYAxisDomain(xDomain[0], xDomain[1]);
var currentXDomain = this._x.domain();
var currentYDomain = this._y.domain();
if (currentXDomain && App.domainsAreEqual(currentXDomain, xDomain)
&& currentYDomain && App.domainsAreEqual(currentYDomain, yDomain))
return currentXDomain;
this._x.domain(xDomain);
this._y.domain(yDomain);
return xDomain;
},
_updateDimensionsIfNeeded: function (newSelection)
{
var element = $(this.get('element'));
var newTotalWidth = element.width();
var newTotalHeight = element.height();
if (this._totalWidth == newTotalWidth && this._totalHeight == newTotalHeight)
return;
this._totalWidth = newTotalWidth;
this._totalHeight = newTotalHeight;
if (!this._rem)
this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
var rem = this._rem;
var padding = 0.5 * rem;
var margin = {top: padding, right: padding, bottom: padding, left: padding};
if (this._xAxis)
margin.bottom += rem;
if (this._yAxis)
margin.left += 3 * rem;
this._margin = margin;
this._contentWidth = Math.max(0, this._totalWidth - margin.left - margin.right);
this._contentHeight = Math.max(0, this._totalHeight - margin.top - margin.bottom);
this._svg.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
this._clipPath
.attr("width", this._contentWidth)
.attr("height", this._contentHeight);
this._x.range([0, this._contentWidth]);
this._y.range([this._contentHeight, 0]);
if (this._xAxis) {
this._xAxis.ticks(Math.round(this._contentWidth / 4 / rem));
this._xAxis.tickSize(-this._contentHeight);
this._xAxisLabels.attr("transform", "translate(0," + this._contentHeight + ")");
}
if (this._yAxis) {
this._yAxis.ticks(Math.round(this._contentHeight / 2 / rem));
this._yAxis.tickSize(-this._contentWidth);
}
if (this._currentItemLine) {
this._currentItemLine
.attr("y1", 0)
.attr("y2", margin.top + this._contentHeight);
}
this._relayoutDataAndAxes(this._currentSelection());
},
_updateBrush: function ()
{
this._brushRect
.call(this._brush)
.selectAll("rect")
.attr("y", 1)
.attr("height", Math.max(0, this._contentHeight - 2));
this._updateSelectionToolbar();
},
_relayoutDataAndAxes: function (selection)
{
var timeline = this._timeLine;
this._paths.forEach(function (path) { path.attr("d", timeline); });
var confidenceArea = this._confidenceArea;
this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
var xScale = this._x;
var yScale = this._y;
this._dots.forEach(function (dot) {
dot
.attr("cx", function(measurement) { return xScale(measurement.time); })
.attr("cy", function(measurement) { return yScale(measurement.value); });
});
this._updateHighlightPositions();
this._updateRangeBarRects();
if (this._brush) {
if (selection)
this._brush.extent(selection);
else
this._brush.clear();
this._updateBrush();
}
this._updateCurrentItemIndicators();
if (this._xAxis)
this._xAxisLabels.call(this._xAxis);
if (!this._yAxis)
return;
this._yAxisLabels.call(this._yAxis);
var x = - 3.2 * this._rem;
var y = this._contentHeight / 2;
},
_updateHighlightPositions: function () {
if (!this._highlights)
return;
var xScale = this._x;
var yScale = this._y;
this._highlights
.attr("cy", function(point) { return yScale(point.value); })
.attr("cx", function(point) { return xScale(point.time); });
},
_computeXAxisDomain: function (timeSeries)
{
var extent = d3.extent(timeSeries, function(point) { return point.time; });
var margin = 3600 * 1000; // Use x.inverse to compute the right amount from a margin.
return [+extent[0] - margin, +extent[1] + margin];
},
_computeYAxisDomain: function (startTime, endTime)
{
var shouldShowFullYAxis = this.get('showFullYAxis');
var range = this._minMaxForAllTimeSeries(startTime, endTime, !shouldShowFullYAxis);
var min = range[0];
var max = range[1];
var highlightedItems = this.get('highlightedItems');
if (highlightedItems) {
var data = this._currentTimeSeriesData
.filter(function (point) { return startTime <= point.time && point.time <= endTime && highlightedItems[point.measurement.id()]; })
.map(function (point) { return point.value });
min = Math.min(min, Statistics.min(data));
max = Math.max(max, Statistics.max(data));
}
if (max < min)
min = max = 0;
else if (shouldShowFullYAxis)
min = Math.min(min, 0);
var diff = max - min;
var margin = diff * 0.05;
yExtent = [min - margin, max + margin];
return yExtent;
},
_minMaxForAllTimeSeries: function (startTime, endTime, ignoreOutliners)
{
var seriesList = [this._currentTimeSeries, this._movingAverageTimeSeries, this._baselineTimeSeries, this._targetTimeSeries];
var min = Infinity;
var max = -Infinity;
for (var i = 0; i < seriesList.length; i++) {
if (seriesList[i]) {
var minMax = seriesList[i].minMaxForTimeRange(startTime, endTime, ignoreOutliners);
min = Math.min(min, minMax[0]);
max = Math.max(max, minMax[1]);
}
}
return [min, max];
},
_currentSelection: function ()
{
return this._brush && !this._brush.empty() ? this._brush.extent() : null;
},
_domainChanged: function ()
{
var selection = this._currentSelection() || this.get('sharedSelection');
var newXDomain = this._updateDomain();
if (!newXDomain)
return;
if (selection && newXDomain && selection[0] <= newXDomain[0] && newXDomain[1] <= selection[1])
selection = null; // Otherwise the user has no way of clearing the selection.
this._relayoutDataAndAxes(selection);
}.observes('domain', 'showFullYAxis'),
_selectionChanged: function ()
{
this._updateSelection(this.get('selection'));
}.observes('selection'),
_updateSelection: function (newSelection)
{
if (!this._brush)
return;
var currentSelection = this._currentSelection();
if (newSelection && currentSelection && App.domainsAreEqual(newSelection, currentSelection))
return;
var domain = this._x.domain();
if (!newSelection || App.domainsAreEqual(domain, newSelection))
this._brush.clear();
else
this._brush.extent(newSelection);
this._updateBrush();
this._setCurrentSelection(newSelection);
},
_brushChanged: function ()
{
if (this._brush.empty()) {
if (!this._brushExtent)
return;
this._setCurrentSelection(undefined);
// Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
this._brushJustChanged = true;
var self = this;
setTimeout(function () {
self._brushJustChanged = false;
}, 0);
return;
}
this._setCurrentSelection(this._brush.extent());
},
_keyPressed: function (event)
{
if (!this._currentItemIndex || this._currentSelection())
return;
var newIndex;
switch (event.keyCode) {
case 37: // Left
newIndex = this._currentItemIndex - 1;
break;
case 39: // Right
newIndex = this._currentItemIndex + 1;
break;
case 38: // Up
case 40: // Down
default:
return;
}
// Unlike mousemove, keydown shouldn't move off the edge.
if (this._currentTimeSeriesData[newIndex])
this._setCurrentItem(newIndex);
},
_mouseMoved: function (event)
{
if (!this._margin || this._currentSelection() || this._currentItemLocked)
return;
var point = this._mousePointInGraph(event);
this._selectClosestPointToMouseAsCurrentItem(point);
},
_mouseLeft: function (event)
{
if (!this._margin || this._currentItemLocked)
return;
this._selectClosestPointToMouseAsCurrentItem(null);
},
_mouseDown: function (event)
{
if (!this._margin || this._currentSelection() || this._brushJustChanged)
return;
var point = this._mousePointInGraph(event);
if (!point)
return;
if (this._currentItemLocked) {
this._currentItemLocked = false;
this.set('selectedItem', null);
return;
}
this._currentItemLocked = true;
this._selectClosestPointToMouseAsCurrentItem(point);
},
_mousePointInGraph: function (event)
{
var offset = $(this.get('element')).offset();
if (!offset || !$(event.target).closest('svg').length)
return null;
var point = {
x: event.pageX - offset.left - this._margin.left,
y: event.pageY - offset.top - this._margin.top
};
var xScale = this._x;
var yScale = this._y;
var xDomain = xScale.domain();
var yDomain = yScale.domain();
if (point.x >= xScale(xDomain[0]) && point.x <= xScale(xDomain[1])
&& point.y <= yScale(yDomain[0]) && point.y >= yScale(yDomain[1]))
return point;
return null;
},
_selectClosestPointToMouseAsCurrentItem: function (point)
{
var xScale = this._x;
var yScale = this._y;
var distanceHeuristics = function (m) {
var mX = xScale(m.time);
var mY = yScale(m.value);
var xDiff = mX - point.x;
var yDiff = mY - point.y;
return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
};
var newItemIndex;
if (point && !this._currentSelection()) {
var distances = this._currentTimeSeriesData.map(distanceHeuristics);
var minDistance = Number.MAX_VALUE;
for (var i = 0; i < distances.length; i++) {
if (distances[i] < minDistance) {
newItemIndex = i;
minDistance = distances[i];
}
}
}
this._setCurrentItem(newItemIndex);
this._updateSelectionToolbar();
},
_currentTimeChanged: function ()
{
if (!this._margin || this._currentSelection() || this._currentItemLocked)
return
var currentTime = this.get('currentTime');
if (currentTime) {
for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
var point = this._currentTimeSeriesData[i];
if (point.time >= currentTime) {
this._setCurrentItem(i, /* doNotNotify */ true);
return;
}
}
}
this._setCurrentItem(undefined, /* doNotNotify */ true);
}.observes('currentTime'),
_setCurrentItem: function (newItemIndex, doNotNotify)
{
if (newItemIndex === this._currentItemIndex) {
if (this._currentItemLocked)
this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
return;
}
var newItem = this._currentTimeSeriesData[newItemIndex];
this._brushExtent = undefined;
this._currentItemIndex = newItemIndex;
if (!newItem) {
this._currentItemLocked = false;
this.set('selectedItem', null);
}
this._updateCurrentItemIndicators();
if (!doNotNotify)
this.set('currentTime', newItem ? newItem.time : undefined);
this.set('currentItem', newItem);
if (this._currentItemLocked)
this.set('selectedItem', newItem ? newItem.measurement.id() : null);
},
_selectedItemChanged: function ()
{
if (!this._margin)
return;
var selectedId = this.get('selectedItem');
var currentItem = this.get('currentItem');
if (currentItem && currentItem.measurement.id() == selectedId)
return;
var series = this._currentTimeSeriesData;
var selectedItemIndex = undefined;
for (var i = 0; i < series.length; i++) {
if (series[i].measurement.id() == selectedId) {
this._updateSelection(null);
this._currentItemLocked = true;
this._setCurrentItem(i);
this._updateSelectionToolbar();
return;
}
}
}.observes('selectedItem').on('init'),
_highlightedItemsChanged: function () {
var highlightedItems = this.get('highlightedItems');
if (!this._clippedContainer || !highlightedItems)
return;
var data = this._currentTimeSeriesData.filter(function (item) { return highlightedItems[item.measurement.id()]; });
if (this._highlights)
this._highlights.remove();
this._highlights = this._clippedContainer
.selectAll(".highlight")
.data(data)
.enter().append("circle")
.attr("class", "highlight");
this._domainChanged();
}.observes('highlightedItems'),
_rangesChanged: function ()
{
var linkRoute = this.get('rangeRoute');
this.set('rangeBars', (this.get('ranges') || []).map(function (range) {
return Ember.Object.create({
startTime: range.get('startTime'),
endTime: range.get('endTime'),
range: range,
left: null,
right: null,
rowIndex: null,
top: null,
bottom: null,
linkRoute: linkRoute,
linkId: range.get('id'),
label: range.get('label'),
status: range.get('status'),
});
}));
this._updateRangeBarRects();
}.observes('ranges'),
_updateRangeBarRects: function () {
var rangeBars = this.get('rangeBars');
if (!rangeBars || !rangeBars.length)
return;
var xScale = this._x;
var yScale = this._y;
if (!xScale || !yScale)
return;
// Expand the width of each range as needed and sort ranges by the left-edge of ranges.
var minWidth = 3;
var sortedBars = rangeBars.map(function (bar) {
var left = xScale(bar.get('startTime'));
var right = xScale(bar.get('endTime'));
if (right - left < minWidth) {
left -= minWidth / 2;
right += minWidth / 2;
}
bar.set('left', left);
bar.set('right', right);
return bar;
}).sort(function (first, second) { return first.get('left') - second.get('left'); });
// At this point, left edges of all ranges prior to a range R1 is on the left of R1.
// Place R1 into a row in which right edges of all ranges prior to R1 is on the left of R1 to avoid overlapping ranges.
var rows = [];
sortedBars.forEach(function (bar) {
var rowIndex = 0;
for (; rowIndex < rows.length; rowIndex++) {
var currentRow = rows[rowIndex];
if (currentRow[currentRow.length - 1].get('right') < bar.get('left')) {
currentRow.push(bar);
break;
}
}
if (rowIndex >= rows.length)
rows.push([bar]);
bar.set('rowIndex', rowIndex);
});
var rowHeight = 0.6 * this._rem;
var firstRowTop = this._contentHeight - rows.length * rowHeight;
var barHeight = 0.5 * this._rem;
$(this.get('element')).find('.rangeBarsContainerInlineStyle').css({
left: this._margin.left + 'px',
top: this._margin.top + firstRowTop + 'px',
width: this._contentWidth + 'px',
height: rows.length * barHeight + 'px',
overflow: 'hidden',
position: 'absolute',
});
var margin = this._margin;
sortedBars.forEach(function (bar) {
var top = bar.get('rowIndex') * rowHeight;
var height = barHeight;
var left = bar.get('left');
var width = bar.get('right') - left;
bar.set('inlineStyle', 'left: ' + left + 'px; top: ' + top + 'px; width: ' + width + 'px; height: ' + height + 'px;');
});
},
_updateCurrentItemIndicators: function ()
{
if (!this._currentItemLine)
return;
var item = this._currentTimeSeriesData[this._currentItemIndex];
if (!item) {
this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
this._currentItemCircle.attr("cx", -1000);
return;
}
var x = this._x(item.time);
var y = this._y(item.value);
this._currentItemLine
.attr("x1", x)
.attr("x2", x);
this._currentItemCircle
.attr("cx", x)
.attr("cy", y);
},
_setCurrentSelection: function (newSelection)
{
if (this._brushExtent === newSelection)
return;
var points = null;
if (newSelection) {
points = this._currentTimeSeriesData
.filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
if (!points.length)
points = null;
}
this._brushExtent = newSelection;
this._setCurrentItem(undefined);
this._updateSelectionToolbar();
if (!App.domainsAreEqual(this.get('selection'), newSelection))
this.set('selection', newSelection);
this.set('selectedPoints', points);
},
_updateSelectionToolbar: function ()
{
if (!this.get('zoomable'))
return;
var selection = this._currentSelection();
var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
if (selection) {
var left = this._x(selection[0]);
var right = this._x(selection[1]);
selectionToolbar
.css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
.show();
} else
selectionToolbar.hide();
},
actions: {
zoom: function ()
{
this.sendAction('zoom', this._currentSelection());
this.set('selection', null);
},
openRange: function (range)
{
this.sendAction('openRange', range);
},
},
});