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 && !chartData.hideMovingAverage;
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 if (!chartData.hideEnvelope) {
157 this._areas.push(this._clippedContainer
159 .datum(this._movingAverageTimeSeries.series())
160 .attr("class", "envelope"));
165 this._currentItemLine = this._clippedContainer
167 .attr("class", "current-item");
169 this._currentItemCircle = this._clippedContainer
171 .attr("class", "dot current-item")
176 if (this.get('enableSelection')) {
177 this._brush = d3.svg.brush()
179 .on("brush", this._brushChanged.bind(this));
181 this._brushRect = this._clippedContainer
183 .attr("class", "x brush");
186 this._updateDomain();
187 this._updateDimensionsIfNeeded();
189 // Work around the fact the brush isn't set if we updated it synchronously here.
190 setTimeout(this._selectionChanged.bind(this), 0);
192 setTimeout(this._selectedItemChanged.bind(this), 0);
194 this._needsConstruction = false;
196 this._highlightedItemsChanged();
197 this._rangesChanged();
199 _updateDomain: function ()
201 var xDomain = this.get('domain');
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 currentDomain;
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 = this._totalWidth - margin.left - margin.right;
242 this._contentHeight = 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.tickSize(-this._contentHeight);
255 this._xAxisLabels.attr("transform", "translate(0," + this._contentHeight + ")");
259 this._yAxis.tickSize(-this._contentWidth);
261 if (this._currentItemLine) {
262 this._currentItemLine
264 .attr("y2", margin.top + this._contentHeight);
267 this._relayoutDataAndAxes(this._currentSelection());
269 _updateBrush: function ()
275 .attr("height", this._contentHeight - 2);
276 this._updateSelectionToolbar();
278 _relayoutDataAndAxes: function (selection)
280 var timeline = this._timeLine;
281 this._paths.forEach(function (path) { path.attr("d", timeline); });
283 var confidenceArea = this._confidenceArea;
284 this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
286 var xScale = this._x;
287 var yScale = this._y;
288 this._dots.forEach(function (dot) {
290 .attr("cx", function(measurement) { return xScale(measurement.time); })
291 .attr("cy", function(measurement) { return yScale(measurement.value); });
293 this._updateHighlightPositions();
294 this._updateRangeBarRects();
298 this._brush.extent(selection);
304 this._updateCurrentItemIndicators();
307 this._xAxisLabels.call(this._xAxis);
311 this._yAxisLabels.call(this._yAxis);
312 if (this._yAxisUnitContainer)
313 this._yAxisUnitContainer.remove();
314 var x = - 3.2 * this._rem;
315 var y = this._contentHeight / 2;
316 this._yAxisUnitContainer = this._yAxisLabels.append("text")
317 .attr("transform", "rotate(90 0 0) translate(" + y + ", " + (-x) + ")")
318 .style("text-anchor", "middle")
319 .text(this._yAxisUnit);
321 _updateHighlightPositions: function () {
322 if (!this._highlights)
325 var xScale = this._x;
326 var yScale = this._y;
328 .attr("cy", function(point) { return yScale(point.value); })
329 .attr("cx", function(point) { return xScale(point.time); });
331 _computeXAxisDomain: function (timeSeries)
333 var extent = d3.extent(timeSeries, function(point) { return point.time; });
334 var margin = 3600 * 1000; // Use x.inverse to compute the right amount from a margin.
335 return [+extent[0] - margin, +extent[1] + margin];
337 _computeYAxisDomain: function (startTime, endTime)
339 var range = this._minMaxForAllTimeSeries(startTime, endTime);
344 var diff = max - min;
345 var margin = diff * 0.05;
347 yExtent = [min - margin, max + margin];
348 // if (yMin !== undefined)
349 // yExtent[0] = parseInt(yMin);
352 _minMaxForAllTimeSeries: function (startTime, endTime)
354 var shouldShowFullYAxis = this.get('showFullYAxis');
355 var mainTimeSeries = this._movingAverageTimeSeries && !shouldShowFullYAxis ? this._movingAverageTimeSeries : this._currentTimeSeries;
356 var currentRange = mainTimeSeries.minMaxForTimeRange(startTime, endTime);
357 if (shouldShowFullYAxis)
358 currentRange[0] = Math.min(0, currentRange[0]);
360 var baselineRange = this._baselineTimeSeries ? this._baselineTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
361 var targetRange = this._targetTimeSeries ? this._targetTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
363 Math.min(currentRange[0], baselineRange[0], targetRange[0]),
364 Math.max(currentRange[1], baselineRange[1], targetRange[1]),
367 _currentSelection: function ()
369 return this._brush && !this._brush.empty() ? this._brush.extent() : null;
371 _domainChanged: function ()
373 var selection = this._currentSelection() || this.get('sharedSelection');
374 var newXDomain = this._updateDomain();
376 if (selection && newXDomain && selection[0] <= newXDomain[0] && newXDomain[1] <= selection[1])
377 selection = null; // Otherwise the user has no way of clearing the selection.
379 this._relayoutDataAndAxes(selection);
380 }.observes('domain', 'showFullYAxis'),
381 _selectionChanged: function ()
383 this._updateSelection(this.get('selection'));
384 }.observes('selection'),
385 _updateSelection: function (newSelection)
390 var currentSelection = this._currentSelection();
391 if (newSelection && currentSelection && App.domainsAreEqual(newSelection, currentSelection))
394 var domain = this._x.domain();
395 if (!newSelection || App.domainsAreEqual(domain, newSelection))
398 this._brush.extent(newSelection);
401 this._setCurrentSelection(newSelection);
403 _brushChanged: function ()
405 if (this._brush.empty()) {
406 if (!this._brushExtent)
409 this._setCurrentSelection(undefined);
411 // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
412 this._brushJustChanged = true;
414 setTimeout(function () {
415 self._brushJustChanged = false;
421 this._setCurrentSelection(this._brush.extent());
423 _keyPressed: function (event)
425 if (!this._currentItemIndex || this._currentSelection())
429 switch (event.keyCode) {
431 newIndex = this._currentItemIndex - 1;
434 newIndex = this._currentItemIndex + 1;
442 // Unlike mousemove, keydown shouldn't move off the edge.
443 if (this._currentTimeSeriesData[newIndex])
444 this._setCurrentItem(newIndex);
446 _mouseMoved: function (event)
448 if (!this._margin || this._currentSelection() || this._currentItemLocked)
451 var point = this._mousePointInGraph(event);
453 this._selectClosestPointToMouseAsCurrentItem(point);
455 _mouseLeft: function (event)
457 if (!this._margin || this._currentItemLocked)
460 this._selectClosestPointToMouseAsCurrentItem(null);
462 _mouseDown: function (event)
464 if (!this._margin || this._currentSelection() || this._brushJustChanged)
467 var point = this._mousePointInGraph(event);
471 if (this._currentItemLocked) {
472 this._currentItemLocked = false;
473 this.set('selectedItem', null);
477 this._currentItemLocked = true;
478 this._selectClosestPointToMouseAsCurrentItem(point);
480 _mousePointInGraph: function (event)
482 var offset = $(this.get('element')).offset();
483 if (!offset || !$(event.target).closest('svg').length)
487 x: event.pageX - offset.left - this._margin.left,
488 y: event.pageY - offset.top - this._margin.top
491 var xScale = this._x;
492 var yScale = this._y;
493 var xDomain = xScale.domain();
494 var yDomain = yScale.domain();
495 if (point.x >= xScale(xDomain[0]) && point.x <= xScale(xDomain[1])
496 && point.y <= yScale(yDomain[0]) && point.y >= yScale(yDomain[1]))
501 _selectClosestPointToMouseAsCurrentItem: function (point)
503 var xScale = this._x;
504 var yScale = this._y;
505 var distanceHeuristics = function (m) {
506 var mX = xScale(m.time);
507 var mY = yScale(m.value);
508 var xDiff = mX - point.x;
509 var yDiff = mY - point.y;
510 return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
512 distanceHeuristics = function (m) {
513 return Math.abs(xScale(m.time) - point.x);
517 if (point && !this._currentSelection()) {
518 var distances = this._currentTimeSeriesData.map(distanceHeuristics);
519 var minDistance = Number.MAX_VALUE;
520 for (var i = 0; i < distances.length; i++) {
521 if (distances[i] < minDistance) {
523 minDistance = distances[i];
528 this._setCurrentItem(newItemIndex);
529 this._updateSelectionToolbar();
531 _currentTimeChanged: function ()
533 if (!this._margin || this._currentSelection() || this._currentItemLocked)
536 var currentTime = this.get('currentTime');
538 for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
539 var point = this._currentTimeSeriesData[i];
540 if (point.time >= currentTime) {
541 this._setCurrentItem(i, /* doNotNotify */ true);
546 this._setCurrentItem(undefined, /* doNotNotify */ true);
547 }.observes('currentTime'),
548 _setCurrentItem: function (newItemIndex, doNotNotify)
550 if (newItemIndex === this._currentItemIndex) {
551 if (this._currentItemLocked)
552 this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
556 var newItem = this._currentTimeSeriesData[newItemIndex];
557 this._brushExtent = undefined;
558 this._currentItemIndex = newItemIndex;
561 this._currentItemLocked = false;
562 this.set('selectedItem', null);
565 this._updateCurrentItemIndicators();
568 this.set('currentTime', newItem ? newItem.time : undefined);
570 this.set('currentItem', newItem);
571 if (this._currentItemLocked)
572 this.set('selectedItem', newItem ? newItem.measurement.id() : null);
574 _selectedItemChanged: function ()
579 var selectedId = this.get('selectedItem');
580 var currentItem = this.get('currentItem');
581 if (currentItem && currentItem.measurement.id() == selectedId)
584 var series = this._currentTimeSeriesData;
585 var selectedItemIndex = undefined;
586 for (var i = 0; i < series.length; i++) {
587 if (series[i].measurement.id() == selectedId) {
588 this._updateSelection(null);
589 this._currentItemLocked = true;
590 this._setCurrentItem(i);
591 this._updateSelectionToolbar();
595 }.observes('selectedItem').on('init'),
596 _highlightedItemsChanged: function () {
597 var highlightedItems = this.get('highlightedItems');
599 if (!this._clippedContainer || !highlightedItems)
602 var data = this._currentTimeSeriesData.filter(function (item) { return highlightedItems[item.measurement.id()]; });
604 if (this._highlights)
605 this._highlights.remove();
606 this._highlights = this._clippedContainer
607 .selectAll(".highlight")
609 .enter().append("circle")
610 .attr("class", "highlight")
611 .attr("r", (this.get('chartPointRadius') || 1) * 1.8);
613 this._updateHighlightPositions();
614 }.observes('highlightedItems'),
615 _rangesChanged: function ()
617 if (!this._currentTimeSeries)
620 function midPoint(firstPoint, secondPoint) {
621 if (firstPoint && secondPoint)
622 return (+firstPoint.time + +secondPoint.time) / 2;
624 return firstPoint.time;
625 return secondPoint.time;
627 var currentTimeSeries = this._currentTimeSeries;
628 var linkRoute = this.get('rangeRoute');
629 this.set('rangeBars', (this.get('ranges') || []).map(function (range) {
630 var start = currentTimeSeries.findPointByMeasurementId(range.get('startRun'));
631 var end = currentTimeSeries.findPointByMeasurementId(range.get('endRun'));
632 return Ember.Object.create({
633 startTime: midPoint(currentTimeSeries.previousPoint(start), start),
634 endTime: midPoint(end, currentTimeSeries.nextPoint(end)),
641 linkRoute: linkRoute,
642 linkId: range.get('id'),
643 label: range.get('label'),
647 this._updateRangeBarRects();
648 }.observes('ranges'),
649 _updateRangeBarRects: function () {
650 var rangeBars = this.get('rangeBars');
651 if (!rangeBars || !rangeBars.length)
654 var xScale = this._x;
655 var yScale = this._y;
657 // Expand the width of each range as needed and sort ranges by the left-edge of ranges.
659 var sortedBars = rangeBars.map(function (bar) {
660 var left = xScale(bar.get('startTime'));
661 var right = xScale(bar.get('endTime'));
662 if (right - left < minWidth) {
663 left -= minWidth / 2;
664 right += minWidth / 2;
666 bar.set('left', left);
667 bar.set('right', right);
669 }).sort(function (first, second) { return first.get('left') - second.get('left'); });
671 // At this point, left edges of all ranges prior to a range R1 is on the left of R1.
672 // 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.
674 sortedBars.forEach(function (bar) {
676 for (; rowIndex < rows.length; rowIndex++) {
677 var currentRow = rows[rowIndex];
678 if (currentRow[currentRow.length - 1].get('right') < bar.get('left')) {
679 currentRow.push(bar);
683 if (rowIndex >= rows.length)
685 bar.set('rowIndex', rowIndex);
687 var rowHeight = 0.6 * this._rem;
688 var firstRowTop = this._contentHeight - rows.length * rowHeight;
689 var barHeight = 0.5 * this._rem;
691 $(this.get('element')).find('.rangeBarsContainerInlineStyle').css({
692 left: this._margin.left + 'px',
693 top: this._margin.top + firstRowTop + 'px',
694 width: this._contentWidth + 'px',
695 height: rows.length * barHeight + 'px',
697 position: 'absolute',
700 var margin = this._margin;
701 sortedBars.forEach(function (bar) {
702 var top = bar.get('rowIndex') * rowHeight;
703 var height = barHeight;
704 var left = bar.get('left');
705 var width = bar.get('right') - left;
706 bar.set('inlineStyle', 'left: ' + left + 'px; top: ' + top + 'px; width: ' + width + 'px; height: ' + height + 'px;');
709 _updateCurrentItemIndicators: function ()
711 if (!this._currentItemLine)
714 var item = this._currentTimeSeriesData[this._currentItemIndex];
716 this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
717 this._currentItemCircle.attr("cx", -1000);
721 var x = this._x(item.time);
722 var y = this._y(item.value);
724 this._currentItemLine
728 this._currentItemCircle
732 _setCurrentSelection: function (newSelection)
734 if (this._brushExtent === newSelection)
739 points = this._currentTimeSeriesData
740 .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
745 this._brushExtent = newSelection;
746 this._setCurrentItem(undefined);
747 this._updateSelectionToolbar();
749 if (!App.domainsAreEqual(this.get('selection'), newSelection))
750 this.set('selection', newSelection);
751 this.set('selectedPoints', points);
753 _updateSelectionToolbar: function ()
755 if (!this.get('interactive'))
758 var selection = this._currentSelection();
759 var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
761 var left = this._x(selection[0]);
762 var right = this._x(selection[1]);
764 .css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
767 selectionToolbar.hide();
772 this.sendAction('zoom', this._currentSelection());
773 this.set('selection', null);
775 openRange: function (range)
777 this.sendAction('openRange', range);