1 App.InteractiveChartComponent = Ember.Component.extend({
11 this._needsConstruction = true;
12 this._eventHandlers = [];
13 $(window).resize(this._updateDimensionsIfNeeded.bind(this));
15 chartDataDidChange: function ()
17 var chartData = this.get('chartData');
20 this._needsConstruction = true;
21 this._totalWidth = undefined;
22 this._totalHeight = undefined;
23 this._constructGraphIfPossible(chartData);
24 }.observes('chartData').on('init'),
25 didInsertElement: function ()
27 var chartData = this.get('chartData');
29 this._constructGraphIfPossible(chartData);
31 if (this.get('interactive')) {
32 var element = this.get('element');
33 this._attachEventListener(element, "mousemove", this._mouseMoved.bind(this));
34 this._attachEventListener(element, "mouseleave", this._mouseLeft.bind(this));
35 this._attachEventListener(element, "mousedown", this._mouseDown.bind(this));
36 this._attachEventListener($(element).parents("[tabindex]"), "keydown", this._keyPressed.bind(this));
39 willClearRender: function ()
41 this._eventHandlers.forEach(function (item) {
42 $(item[0]).off(item[1], item[2]);
45 _attachEventListener: function(target, eventName, listener)
47 this._eventHandlers.push([target, eventName, listener]);
48 $(target).on(eventName, listener);
50 _constructGraphIfPossible: function (chartData)
52 if (!this._needsConstruction || !this.get('element'))
55 var element = this.get('element');
57 this._x = d3.time.scale();
58 this._y = d3.scale.linear();
61 this._svgElement.remove();
62 this._svgElement = d3.select(element).append("svg")
63 .attr("width", "100%")
64 .attr("height", "100%");
66 var svg = this._svg = this._svgElement.append("g");
68 var clipId = element.id + "-clip";
69 this._clipPath = svg.append("defs").append("clipPath")
73 if (this.get('showXAxis')) {
74 this._xAxis = d3.svg.axis().scale(this._x).orient("bottom").ticks(6);
75 this._xAxisLabels = svg.append("g")
76 .attr("class", "x axis");
79 var isInteractive = this.get('interactive');
80 if (this.get('showYAxis')) {
81 this._yAxis = d3.svg.axis().scale(this._y).orient("left").ticks(6).tickFormat(chartData.formatter);
83 this._yAxisLabels = svg.append('g').attr('class', 'y axis' + (isInteractive ? ' interactive' : ''));
86 this._yAxisLabels.on('click', function () { self.toggleProperty('showFullYAxis'); });
90 this._clippedContainer = svg.append("g")
91 .attr("clip-path", "url(#" + clipId + ")");
95 this._timeLine = d3.svg.line()
96 .x(function(point) { return xScale(point.time); })
97 .y(function(point) { return yScale(point.value); });
99 this._confidenceArea = d3.svg.area()
100 // .interpolate("cardinal")
101 .x(function(point) { return xScale(point.time); })
102 .y0(function(point) { return point.interval ? yScale(point.interval[0]) : null; })
103 .y1(function(point) { return point.interval ? yScale(point.interval[1]) : null; });
108 this._highlights = null;
110 this._currentTimeSeries = chartData.current;
111 this._currentTimeSeriesData = this._currentTimeSeries.series();
112 this._baselineTimeSeries = chartData.baseline;
113 this._targetTimeSeries = chartData.target;
114 this._movingAverageTimeSeries = chartData.movingAverage;
116 this._yAxisUnit = chartData.unit;
118 if (this._baselineTimeSeries) {
119 this._paths.push(this._clippedContainer
121 .datum(this._baselineTimeSeries.series())
122 .attr("class", "baseline"));
124 if (this._targetTimeSeries) {
125 this._paths.push(this._clippedContainer
127 .datum(this._targetTimeSeries.series())
128 .attr("class", "target"));
131 var movingAverageIsVisible = this._movingAverageTimeSeries;
132 var foregroundClass = movingAverageIsVisible ? '' : ' foreground';
133 this._areas.push(this._clippedContainer
135 .datum(this._currentTimeSeriesData)
136 .attr("class", "area" + foregroundClass));
138 this._paths.push(this._clippedContainer
140 .datum(this._currentTimeSeriesData)
141 .attr("class", "current" + foregroundClass));
143 this._dots.push(this._clippedContainer
145 .data(this._currentTimeSeriesData)
146 .enter().append("circle")
147 .attr("class", "dot" + foregroundClass)
148 .attr("r", this.get('chartPointRadius') || 1));
150 if (movingAverageIsVisible) {
151 this._paths.push(this._clippedContainer
153 .datum(this._movingAverageTimeSeries.series())
154 .attr("class", "movingAverage"));
156 this._areas.push(this._clippedContainer
158 .datum(this._movingAverageTimeSeries.series())
159 .attr("class", "envelope"));
163 this._currentItemLine = this._clippedContainer
165 .attr("class", "current-item");
167 this._currentItemCircle = this._clippedContainer
169 .attr("class", "dot current-item")
174 if (this.get('enableSelection')) {
175 this._brush = d3.svg.brush()
177 .on("brush", this._brushChanged.bind(this));
179 this._brushRect = this._clippedContainer
181 .attr("class", "x brush");
184 this._updateDomain();
185 this._updateDimensionsIfNeeded();
187 // Work around the fact the brush isn't set if we updated it synchronously here.
188 setTimeout(this._selectionChanged.bind(this), 0);
190 setTimeout(this._selectedItemChanged.bind(this), 0);
192 this._needsConstruction = false;
194 this._highlightedItemsChanged();
195 this._rangesChanged();
197 _updateDomain: function ()
199 var xDomain = this.get('domain');
200 if (!xDomain || !this._currentTimeSeriesData)
202 var intrinsicXDomain = this._computeXAxisDomain(this._currentTimeSeriesData);
204 xDomain = intrinsicXDomain;
205 var yDomain = this._computeYAxisDomain(xDomain[0], xDomain[1]);
207 var currentXDomain = this._x.domain();
208 var currentYDomain = this._y.domain();
209 if (currentXDomain && App.domainsAreEqual(currentXDomain, xDomain)
210 && currentYDomain && App.domainsAreEqual(currentYDomain, yDomain))
211 return currentXDomain;
213 this._x.domain(xDomain);
214 this._y.domain(yDomain);
217 _updateDimensionsIfNeeded: function (newSelection)
219 var element = $(this.get('element'));
221 var newTotalWidth = element.width();
222 var newTotalHeight = element.height();
223 if (this._totalWidth == newTotalWidth && this._totalHeight == newTotalHeight)
226 this._totalWidth = newTotalWidth;
227 this._totalHeight = newTotalHeight;
230 this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
233 var padding = 0.5 * rem;
234 var margin = {top: padding, right: padding, bottom: padding, left: padding};
236 margin.bottom += rem;
238 margin.left += 3 * rem;
240 this._margin = margin;
241 this._contentWidth = Math.max(0, this._totalWidth - margin.left - margin.right);
242 this._contentHeight = Math.max(0, this._totalHeight - margin.top - margin.bottom);
244 this._svg.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
247 .attr("width", this._contentWidth)
248 .attr("height", this._contentHeight);
250 this._x.range([0, this._contentWidth]);
251 this._y.range([this._contentHeight, 0]);
254 this._xAxis.ticks(Math.round(this._contentWidth / 4 / rem));
255 this._xAxis.tickSize(-this._contentHeight);
256 this._xAxisLabels.attr("transform", "translate(0," + this._contentHeight + ")");
260 this._yAxis.ticks(Math.round(this._contentHeight / 2 / rem));
261 this._yAxis.tickSize(-this._contentWidth);
264 if (this._currentItemLine) {
265 this._currentItemLine
267 .attr("y2", margin.top + this._contentHeight);
270 this._relayoutDataAndAxes(this._currentSelection());
272 _updateBrush: function ()
278 .attr("height", Math.max(0, this._contentHeight - 2));
279 this._updateSelectionToolbar();
281 _relayoutDataAndAxes: function (selection)
283 var timeline = this._timeLine;
284 this._paths.forEach(function (path) { path.attr("d", timeline); });
286 var confidenceArea = this._confidenceArea;
287 this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
289 var xScale = this._x;
290 var yScale = this._y;
291 this._dots.forEach(function (dot) {
293 .attr("cx", function(measurement) { return xScale(measurement.time); })
294 .attr("cy", function(measurement) { return yScale(measurement.value); });
296 this._updateHighlightPositions();
297 this._updateRangeBarRects();
301 this._brush.extent(selection);
307 this._updateCurrentItemIndicators();
310 this._xAxisLabels.call(this._xAxis);
314 this._yAxisLabels.call(this._yAxis);
315 if (this._yAxisUnitContainer)
316 this._yAxisUnitContainer.remove();
317 var x = - 3.2 * this._rem;
318 var y = this._contentHeight / 2;
319 this._yAxisUnitContainer = this._yAxisLabels.append("text")
320 .attr("transform", "rotate(90 0 0) translate(" + y + ", " + (-x) + ")")
321 .style("text-anchor", "middle")
322 .text(this._yAxisUnit);
324 _updateHighlightPositions: function () {
325 if (!this._highlights)
328 var xScale = this._x;
329 var yScale = this._y;
331 .attr("cy", function(point) { return yScale(point.value); })
332 .attr("cx", function(point) { return xScale(point.time); });
334 _computeXAxisDomain: function (timeSeries)
336 var extent = d3.extent(timeSeries, function(point) { return point.time; });
337 var margin = 3600 * 1000; // Use x.inverse to compute the right amount from a margin.
338 return [+extent[0] - margin, +extent[1] + margin];
340 _computeYAxisDomain: function (startTime, endTime)
342 var shouldShowFullYAxis = this.get('showFullYAxis');
343 var range = this._minMaxForAllTimeSeries(startTime, endTime, !shouldShowFullYAxis);
347 var highlightedItems = this.get('highlightedItems');
348 if (highlightedItems) {
349 var data = this._currentTimeSeriesData
350 .filter(function (point) { return startTime <= point.time && point.time <= endTime && highlightedItems[point.measurement.id()]; })
351 .map(function (point) { return point.value });
352 min = Math.min(min, Statistics.min(data));
353 max = Math.max(max, Statistics.max(data));
358 else if (shouldShowFullYAxis)
359 min = Math.min(min, 0);
360 var diff = max - min;
361 var margin = diff * 0.05;
363 yExtent = [min - margin, max + margin];
366 _minMaxForAllTimeSeries: function (startTime, endTime, ignoreOutliners)
368 var seriesList = [this._currentTimeSeries, this._movingAverageTimeSeries, this._baselineTimeSeries, this._targetTimeSeries];
371 for (var i = 0; i < seriesList.length; i++) {
373 var minMax = seriesList[i].minMaxForTimeRange(startTime, endTime, ignoreOutliners);
374 min = Math.min(min, minMax[0]);
375 max = Math.max(max, minMax[1]);
380 _currentSelection: function ()
382 return this._brush && !this._brush.empty() ? this._brush.extent() : null;
384 _domainChanged: function ()
386 var selection = this._currentSelection() || this.get('sharedSelection');
387 var newXDomain = this._updateDomain();
391 if (selection && newXDomain && selection[0] <= newXDomain[0] && newXDomain[1] <= selection[1])
392 selection = null; // Otherwise the user has no way of clearing the selection.
394 this._relayoutDataAndAxes(selection);
395 }.observes('domain', 'showFullYAxis'),
396 _selectionChanged: function ()
398 this._updateSelection(this.get('selection'));
399 }.observes('selection'),
400 _updateSelection: function (newSelection)
405 var currentSelection = this._currentSelection();
406 if (newSelection && currentSelection && App.domainsAreEqual(newSelection, currentSelection))
409 var domain = this._x.domain();
410 if (!newSelection || App.domainsAreEqual(domain, newSelection))
413 this._brush.extent(newSelection);
416 this._setCurrentSelection(newSelection);
418 _brushChanged: function ()
420 if (this._brush.empty()) {
421 if (!this._brushExtent)
424 this._setCurrentSelection(undefined);
426 // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
427 this._brushJustChanged = true;
429 setTimeout(function () {
430 self._brushJustChanged = false;
436 this._setCurrentSelection(this._brush.extent());
438 _keyPressed: function (event)
440 if (!this._currentItemIndex || this._currentSelection())
444 switch (event.keyCode) {
446 newIndex = this._currentItemIndex - 1;
449 newIndex = this._currentItemIndex + 1;
457 // Unlike mousemove, keydown shouldn't move off the edge.
458 if (this._currentTimeSeriesData[newIndex])
459 this._setCurrentItem(newIndex);
461 _mouseMoved: function (event)
463 if (!this._margin || this._currentSelection() || this._currentItemLocked)
466 var point = this._mousePointInGraph(event);
468 this._selectClosestPointToMouseAsCurrentItem(point);
470 _mouseLeft: function (event)
472 if (!this._margin || this._currentItemLocked)
475 this._selectClosestPointToMouseAsCurrentItem(null);
477 _mouseDown: function (event)
479 if (!this._margin || this._currentSelection() || this._brushJustChanged)
482 var point = this._mousePointInGraph(event);
486 if (this._currentItemLocked) {
487 this._currentItemLocked = false;
488 this.set('selectedItem', null);
492 this._currentItemLocked = true;
493 this._selectClosestPointToMouseAsCurrentItem(point);
495 _mousePointInGraph: function (event)
497 var offset = $(this.get('element')).offset();
498 if (!offset || !$(event.target).closest('svg').length)
502 x: event.pageX - offset.left - this._margin.left,
503 y: event.pageY - offset.top - this._margin.top
506 var xScale = this._x;
507 var yScale = this._y;
508 var xDomain = xScale.domain();
509 var yDomain = yScale.domain();
510 if (point.x >= xScale(xDomain[0]) && point.x <= xScale(xDomain[1])
511 && point.y <= yScale(yDomain[0]) && point.y >= yScale(yDomain[1]))
516 _selectClosestPointToMouseAsCurrentItem: function (point)
518 var xScale = this._x;
519 var yScale = this._y;
520 var distanceHeuristics = function (m) {
521 var mX = xScale(m.time);
522 var mY = yScale(m.value);
523 var xDiff = mX - point.x;
524 var yDiff = mY - point.y;
525 return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
527 distanceHeuristics = function (m) {
528 return Math.abs(xScale(m.time) - point.x);
532 if (point && !this._currentSelection()) {
533 var distances = this._currentTimeSeriesData.map(distanceHeuristics);
534 var minDistance = Number.MAX_VALUE;
535 for (var i = 0; i < distances.length; i++) {
536 if (distances[i] < minDistance) {
538 minDistance = distances[i];
543 this._setCurrentItem(newItemIndex);
544 this._updateSelectionToolbar();
546 _currentTimeChanged: function ()
548 if (!this._margin || this._currentSelection() || this._currentItemLocked)
551 var currentTime = this.get('currentTime');
553 for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
554 var point = this._currentTimeSeriesData[i];
555 if (point.time >= currentTime) {
556 this._setCurrentItem(i, /* doNotNotify */ true);
561 this._setCurrentItem(undefined, /* doNotNotify */ true);
562 }.observes('currentTime'),
563 _setCurrentItem: function (newItemIndex, doNotNotify)
565 if (newItemIndex === this._currentItemIndex) {
566 if (this._currentItemLocked)
567 this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
571 var newItem = this._currentTimeSeriesData[newItemIndex];
572 this._brushExtent = undefined;
573 this._currentItemIndex = newItemIndex;
576 this._currentItemLocked = false;
577 this.set('selectedItem', null);
580 this._updateCurrentItemIndicators();
583 this.set('currentTime', newItem ? newItem.time : undefined);
585 this.set('currentItem', newItem);
586 if (this._currentItemLocked)
587 this.set('selectedItem', newItem ? newItem.measurement.id() : null);
589 _selectedItemChanged: function ()
594 var selectedId = this.get('selectedItem');
595 var currentItem = this.get('currentItem');
596 if (currentItem && currentItem.measurement.id() == selectedId)
599 var series = this._currentTimeSeriesData;
600 var selectedItemIndex = undefined;
601 for (var i = 0; i < series.length; i++) {
602 if (series[i].measurement.id() == selectedId) {
603 this._updateSelection(null);
604 this._currentItemLocked = true;
605 this._setCurrentItem(i);
606 this._updateSelectionToolbar();
610 }.observes('selectedItem').on('init'),
611 _highlightedItemsChanged: function () {
612 var highlightedItems = this.get('highlightedItems');
614 if (!this._clippedContainer || !highlightedItems)
617 var data = this._currentTimeSeriesData.filter(function (item) { return highlightedItems[item.measurement.id()]; });
619 if (this._highlights)
620 this._highlights.remove();
621 this._highlights = this._clippedContainer
622 .selectAll(".highlight")
624 .enter().append("circle")
625 .attr("class", "highlight")
626 .attr("r", (this.get('chartPointRadius') || 1) * 1.8);
628 this._domainChanged();
629 }.observes('highlightedItems'),
630 _rangesChanged: function ()
632 if (!this._currentTimeSeries)
635 function midPoint(firstPoint, secondPoint) {
636 if (firstPoint && secondPoint)
637 return (+firstPoint.time + +secondPoint.time) / 2;
639 return firstPoint.time;
640 return secondPoint.time;
642 var currentTimeSeries = this._currentTimeSeries;
643 var linkRoute = this.get('rangeRoute');
644 this.set('rangeBars', (this.get('ranges') || []).map(function (range) {
645 var start = currentTimeSeries.findPointByMeasurementId(range.get('startRun'));
646 var end = currentTimeSeries.findPointByMeasurementId(range.get('endRun'));
647 return Ember.Object.create({
648 startTime: midPoint(currentTimeSeries.previousPoint(start), start),
649 endTime: midPoint(end, currentTimeSeries.nextPoint(end)),
656 linkRoute: linkRoute,
657 linkId: range.get('id'),
658 label: range.get('label'),
662 this._updateRangeBarRects();
663 }.observes('ranges'),
664 _updateRangeBarRects: function () {
665 var rangeBars = this.get('rangeBars');
666 if (!rangeBars || !rangeBars.length)
669 var xScale = this._x;
670 var yScale = this._y;
672 // Expand the width of each range as needed and sort ranges by the left-edge of ranges.
674 var sortedBars = rangeBars.map(function (bar) {
675 var left = xScale(bar.get('startTime'));
676 var right = xScale(bar.get('endTime'));
677 if (right - left < minWidth) {
678 left -= minWidth / 2;
679 right += minWidth / 2;
681 bar.set('left', left);
682 bar.set('right', right);
684 }).sort(function (first, second) { return first.get('left') - second.get('left'); });
686 // At this point, left edges of all ranges prior to a range R1 is on the left of R1.
687 // 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.
689 sortedBars.forEach(function (bar) {
691 for (; rowIndex < rows.length; rowIndex++) {
692 var currentRow = rows[rowIndex];
693 if (currentRow[currentRow.length - 1].get('right') < bar.get('left')) {
694 currentRow.push(bar);
698 if (rowIndex >= rows.length)
700 bar.set('rowIndex', rowIndex);
702 var rowHeight = 0.6 * this._rem;
703 var firstRowTop = this._contentHeight - rows.length * rowHeight;
704 var barHeight = 0.5 * this._rem;
706 $(this.get('element')).find('.rangeBarsContainerInlineStyle').css({
707 left: this._margin.left + 'px',
708 top: this._margin.top + firstRowTop + 'px',
709 width: this._contentWidth + 'px',
710 height: rows.length * barHeight + 'px',
712 position: 'absolute',
715 var margin = this._margin;
716 sortedBars.forEach(function (bar) {
717 var top = bar.get('rowIndex') * rowHeight;
718 var height = barHeight;
719 var left = bar.get('left');
720 var width = bar.get('right') - left;
721 bar.set('inlineStyle', 'left: ' + left + 'px; top: ' + top + 'px; width: ' + width + 'px; height: ' + height + 'px;');
724 _updateCurrentItemIndicators: function ()
726 if (!this._currentItemLine)
729 var item = this._currentTimeSeriesData[this._currentItemIndex];
731 this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
732 this._currentItemCircle.attr("cx", -1000);
736 var x = this._x(item.time);
737 var y = this._y(item.value);
739 this._currentItemLine
743 this._currentItemCircle
747 _setCurrentSelection: function (newSelection)
749 if (this._brushExtent === newSelection)
754 points = this._currentTimeSeriesData
755 .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
760 this._brushExtent = newSelection;
761 this._setCurrentItem(undefined);
762 this._updateSelectionToolbar();
764 if (!App.domainsAreEqual(this.get('selection'), newSelection))
765 this.set('selection', newSelection);
766 this.set('selectedPoints', points);
768 _updateSelectionToolbar: function ()
770 if (!this.get('zoomable'))
773 var selection = this._currentSelection();
774 var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
776 var left = this._x(selection[0]);
777 var right = this._x(selection[1]);
779 .css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
782 selectionToolbar.hide();
787 this.sendAction('zoom', this._currentSelection());
788 this.set('selection', null);
790 openRange: function (range)
792 this.sendAction('openRange', range);