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._constructGraphIfPossible(chartData);
22 }.observes('chartData').on('init'),
23 didInsertElement: function ()
25 var chartData = this.get('chartData');
27 this._constructGraphIfPossible(chartData);
29 willClearRender: function ()
31 this._eventHandlers.forEach(function (item) {
32 $(item[0]).off(item[1], item[2]);
35 _attachEventListener: function(target, eventName, listener)
37 this._eventHandlers.push([target, eventName, listener]);
38 $(target).on(eventName, listener);
40 _constructGraphIfPossible: function (chartData)
42 if (!this._needsConstruction || !this.get('element'))
45 var element = this.get('element');
47 this._x = d3.time.scale();
48 this._y = d3.scale.linear();
50 // FIXME: Tear down the old SVG element.
51 this._svgElement = d3.select(element).append("svg")
52 .attr("width", "100%")
53 .attr("height", "100%");
55 var svg = this._svg = this._svgElement.append("g");
57 var clipId = element.id + "-clip";
58 this._clipPath = svg.append("defs").append("clipPath")
62 if (this.get('showXAxis')) {
63 this._xAxis = d3.svg.axis().scale(this._x).orient("bottom").ticks(6);
64 this._xAxisLabels = svg.append("g")
65 .attr("class", "x axis");
68 if (this.get('showYAxis')) {
69 this._yAxis = d3.svg.axis().scale(this._y).orient("left").ticks(6).tickFormat(d3.format("s"));
70 this._yAxisLabels = svg.append("g")
71 .attr("class", "y axis");
74 this._clippedContainer = svg.append("g")
75 .attr("clip-path", "url(#" + clipId + ")");
79 this._timeLine = d3.svg.line()
80 .x(function(point) { return xScale(point.time); })
81 .y(function(point) { return yScale(point.value); });
83 this._confidenceArea = d3.svg.area()
84 // .interpolate("cardinal")
85 .x(function(point) { return xScale(point.time); })
86 .y0(function(point) { return point.interval ? yScale(point.interval[0]) : null; })
87 .y1(function(point) { return point.interval ? yScale(point.interval[1]) : null; });
90 this._paths.forEach(function (path) { path.remove(); });
93 this._areas.forEach(function (area) { area.remove(); });
96 this._dots.forEach(function (dot) { dots.remove(); });
99 this._highlights.forEach(function (highlight) { highlight.remove(); });
100 this._highlights = [];
102 this._currentTimeSeries = chartData.current.timeSeriesByCommitTime();
103 this._currentTimeSeriesData = this._currentTimeSeries.series();
104 this._baselineTimeSeries = chartData.baseline ? chartData.baseline.timeSeriesByCommitTime() : null;
105 this._targetTimeSeries = chartData.target ? chartData.target.timeSeriesByCommitTime() : null;
107 this._yAxisUnit = chartData.unit;
109 var minMax = this._minMaxForAllTimeSeries();
110 var smallEnoughValue = minMax[0] - (minMax[1] - minMax[0]) * 10;
111 var largeEnoughValue = minMax[1] + (minMax[1] - minMax[0]) * 10;
113 // FIXME: Flip the sides based on smallerIsBetter-ness.
114 if (this._baselineTimeSeries) {
115 var data = this._baselineTimeSeries.series();
116 this._areas.push(this._clippedContainer
118 .datum(data.map(function (point) { return {time: point.time, value: point.value, interval: point.interval ? point.interval : [point.value, largeEnoughValue]}; }))
119 .attr("class", "area baseline"));
121 if (this._targetTimeSeries) {
122 var data = this._targetTimeSeries.series();
123 this._areas.push(this._clippedContainer
125 .datum(data.map(function (point) { return {time: point.time, value: point.value, interval: point.interval ? point.interval : [smallEnoughValue, point.value]}; }))
126 .attr("class", "area target"));
129 this._areas.push(this._clippedContainer
131 .datum(this._currentTimeSeriesData)
132 .attr("class", "area"));
134 this._paths.push(this._clippedContainer
136 .datum(this._currentTimeSeriesData)
137 .attr("class", "commit-time-line"));
139 this._dots.push(this._clippedContainer
141 .data(this._currentTimeSeriesData)
142 .enter().append("circle")
143 .attr("class", "dot")
144 .attr("r", this.get('chartPointRadius') || 1));
146 if (this.get('interactive')) {
147 this._attachEventListener(element, "mousemove", this._mouseMoved.bind(this));
148 this._attachEventListener(element, "mouseleave", this._mouseLeft.bind(this));
149 this._attachEventListener(element, "mousedown", this._mouseDown.bind(this));
150 this._attachEventListener($(element).parents("[tabindex]"), "keydown", this._keyPressed.bind(this));
152 this._currentItemLine = this._clippedContainer
154 .attr("class", "current-item");
156 this._currentItemCircle = this._clippedContainer
158 .attr("class", "dot current-item")
163 if (this.get('enableSelection')) {
164 this._brush = d3.svg.brush()
166 .on("brush", this._brushChanged.bind(this));
168 this._brushRect = this._clippedContainer
170 .attr("class", "x brush");
173 this._updateDomain();
174 this._updateDimensionsIfNeeded();
176 // Work around the fact the brush isn't set if we updated it synchronously here.
177 setTimeout(this._selectionChanged.bind(this), 0);
179 setTimeout(this._selectedItemChanged.bind(this), 0);
181 this._needsConstruction = false;
183 this._rangesChanged();
185 _updateDomain: function ()
187 var xDomain = this.get('domain');
188 var intrinsicXDomain = this._computeXAxisDomain(this._currentTimeSeriesData);
190 xDomain = intrinsicXDomain;
191 var currentDomain = this._x.domain();
192 if (currentDomain && App.domainsAreEqual(currentDomain, xDomain))
193 return currentDomain;
195 var yDomain = this._computeYAxisDomain(xDomain[0], xDomain[1]);
196 this._x.domain(xDomain);
197 this._y.domain(yDomain);
200 _updateDimensionsIfNeeded: function (newSelection)
202 var element = $(this.get('element'));
204 var newTotalWidth = element.width();
205 var newTotalHeight = element.height();
206 if (this._totalWidth == newTotalWidth && this._totalHeight == newTotalHeight)
209 this._totalWidth = newTotalWidth;
210 this._totalHeight = newTotalHeight;
213 this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
216 var padding = 0.5 * rem;
217 var margin = {top: padding, right: padding, bottom: padding, left: padding};
219 margin.bottom += rem;
221 margin.left += 3 * rem;
223 this._margin = margin;
224 this._contentWidth = this._totalWidth - margin.left - margin.right;
225 this._contentHeight = this._totalHeight - margin.top - margin.bottom;
227 this._svg.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
230 .attr("width", this._contentWidth)
231 .attr("height", this._contentHeight);
233 this._x.range([0, this._contentWidth]);
234 this._y.range([this._contentHeight, 0]);
237 this._xAxis.tickSize(-this._contentHeight);
238 this._xAxisLabels.attr("transform", "translate(0," + this._contentHeight + ")");
242 this._yAxis.tickSize(-this._contentWidth);
244 if (this._currentItemLine) {
245 this._currentItemLine
247 .attr("y2", margin.top + this._contentHeight);
250 this._relayoutDataAndAxes(this._currentSelection());
252 _updateBrush: function ()
258 .attr("height", this._contentHeight - 2);
259 this._updateSelectionToolbar();
261 _relayoutDataAndAxes: function (selection)
263 var timeline = this._timeLine;
264 this._paths.forEach(function (path) { path.attr("d", timeline); });
266 var confidenceArea = this._confidenceArea;
267 this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
269 var xScale = this._x;
270 var yScale = this._y;
271 this._dots.forEach(function (dot) {
273 .attr("cx", function(measurement) { return xScale(measurement.time); })
274 .attr("cy", function(measurement) { return yScale(measurement.value); });
276 this._updateMarkedDots();
277 this._updateHighlightPositions();
278 this._updateRangeBarRects();
282 this._brush.extent(selection);
288 this._updateCurrentItemIndicators();
291 this._xAxisLabels.call(this._xAxis);
295 this._yAxisLabels.call(this._yAxis);
296 if (this._yAxisUnitContainer)
297 this._yAxisUnitContainer.remove();
298 this._yAxisUnitContainer = this._yAxisLabels.append("text")
299 .attr("x", 0.5 * this._rem)
300 .attr("y", 0.2 * this._rem)
301 .attr("dy", 0.8 * this._rem)
302 .style("text-anchor", "start")
303 .style("z-index", "100")
304 .text(this._yAxisUnit);
306 _updateMarkedDots: function () {
307 var markedPoints = this.get('markedPoints') || {};
308 var defaultDotRadius = this.get('chartPointRadius') || 1;
309 this._dots.forEach(function (dot) {
310 dot.classed('marked', function (point) { return markedPoints[point.measurement.id()]; });
311 dot.attr('r', function (point) {
312 return markedPoints[point.measurement.id()] ? defaultDotRadius * 1.5 : defaultDotRadius; });
314 }.observes('markedPoints'),
315 _updateHighlightPositions: function () {
316 var xScale = this._x;
317 var yScale = this._y;
318 var y2 = this._margin.top + this._contentHeight;
319 this._highlights.forEach(function (highlight) {
323 .attr("y", function(measurement) { return yScale(measurement.value); })
324 .attr("x1", function(measurement) { return xScale(measurement.time); })
325 .attr("x2", function(measurement) { return xScale(measurement.time); });
328 _computeXAxisDomain: function (timeSeries)
330 var extent = d3.extent(timeSeries, function(point) { return point.time; });
331 var margin = 3600 * 1000; // Use x.inverse to compute the right amount from a margin.
332 return [+extent[0] - margin, +extent[1] + margin];
334 _computeYAxisDomain: function (startTime, endTime)
336 var range = this._minMaxForAllTimeSeries(startTime, endTime);
341 var diff = max - min;
342 var margin = diff * 0.05;
344 yExtent = [min - margin, max + margin];
345 // if (yMin !== undefined)
346 // yExtent[0] = parseInt(yMin);
349 _minMaxForAllTimeSeries: function (startTime, endTime)
351 var currentRange = this._currentTimeSeries.minMaxForTimeRange(startTime, endTime);
352 var baselineRange = this._baselineTimeSeries ? this._baselineTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
353 var targetRange = this._targetTimeSeries ? this._targetTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
355 Math.min(currentRange[0], baselineRange[0], targetRange[0]),
356 Math.max(currentRange[1], baselineRange[1], targetRange[1]),
359 _currentSelection: function ()
361 return this._brush && !this._brush.empty() ? this._brush.extent() : null;
363 _domainChanged: function ()
365 var selection = this._currentSelection() || this.get('sharedSelection');
366 var newXDomain = this._updateDomain();
368 if (selection && newXDomain && selection[0] <= newXDomain[0] && newXDomain[1] <= selection[1])
369 selection = null; // Otherwise the user has no way of clearing the selection.
371 this._relayoutDataAndAxes(selection);
372 }.observes('domain'),
373 _selectionChanged: function ()
375 this._updateSelection(this.get('selection'));
376 }.observes('selection'),
377 _updateSelection: function (newSelection)
382 var currentSelection = this._currentSelection();
383 if (newSelection && currentSelection && App.domainsAreEqual(newSelection, currentSelection))
386 var domain = this._x.domain();
387 if (!newSelection || App.domainsAreEqual(domain, newSelection))
390 this._brush.extent(newSelection);
393 this._setCurrentSelection(newSelection);
395 _brushChanged: function ()
397 if (this._brush.empty()) {
398 if (!this._brushExtent)
401 this.set('selectionIsLocked', false);
402 this._setCurrentSelection(undefined);
404 // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
405 this._brushJustChanged = true;
407 setTimeout(function () {
408 self._brushJustChanged = false;
414 this.set('selectionIsLocked', true);
415 this._setCurrentSelection(this._brush.extent());
417 _keyPressed: function (event)
419 if (!this._currentItemIndex || this._currentSelection())
423 switch (event.keyCode) {
425 newIndex = this._currentItemIndex - 1;
428 newIndex = this._currentItemIndex + 1;
436 // Unlike mousemove, keydown shouldn't move off the edge.
437 if (this._currentTimeSeriesData[newIndex])
438 this._setCurrentItem(newIndex);
440 _mouseMoved: function (event)
442 if (!this._margin || this._currentSelection() || this._currentItemLocked)
445 var point = this._mousePointInGraph(event);
447 this._selectClosestPointToMouseAsCurrentItem(point);
449 _mouseLeft: function (event)
451 if (!this._margin || this._currentItemLocked)
454 this._selectClosestPointToMouseAsCurrentItem(null);
456 _mouseDown: function (event)
458 if (!this._margin || this._currentSelection() || this._brushJustChanged)
461 var point = this._mousePointInGraph(event);
465 if (this._currentItemLocked) {
466 this._currentItemLocked = false;
467 this.set('selectedItem', null);
471 this._currentItemLocked = true;
472 this._selectClosestPointToMouseAsCurrentItem(point);
474 _mousePointInGraph: function (event)
476 var offset = $(this.get('element')).offset();
477 if (!offset || !$(event.target).closest('svg').length)
481 x: event.pageX - offset.left - this._margin.left,
482 y: event.pageY - offset.top - this._margin.top
485 var xScale = this._x;
486 var yScale = this._y;
487 var xDomain = xScale.domain();
488 var yDomain = yScale.domain();
489 if (point.x >= xScale(xDomain[0]) && point.x <= xScale(xDomain[1])
490 && point.y <= yScale(yDomain[0]) && point.y >= yScale(yDomain[1]))
495 _selectClosestPointToMouseAsCurrentItem: function (point)
497 var xScale = this._x;
498 var yScale = this._y;
499 var distanceHeuristics = function (m) {
500 var mX = xScale(m.time);
501 var mY = yScale(m.value);
502 var xDiff = mX - point.x;
503 var yDiff = mY - point.y;
504 return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
506 distanceHeuristics = function (m) {
507 return Math.abs(xScale(m.time) - point.x);
511 if (point && !this._currentSelection()) {
512 var distances = this._currentTimeSeriesData.map(distanceHeuristics);
513 var minDistance = Number.MAX_VALUE;
514 for (var i = 0; i < distances.length; i++) {
515 if (distances[i] < minDistance) {
517 minDistance = distances[i];
522 this._setCurrentItem(newItemIndex);
523 this._updateSelectionToolbar();
525 _currentTimeChanged: function ()
527 if (!this._margin || this._currentSelection() || this._currentItemLocked)
530 var currentTime = this.get('currentTime');
532 for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
533 var point = this._currentTimeSeriesData[i];
534 if (point.time >= currentTime) {
535 this._setCurrentItem(i, /* doNotNotify */ true);
540 this._setCurrentItem(undefined, /* doNotNotify */ true);
541 }.observes('currentTime'),
542 _setCurrentItem: function (newItemIndex, doNotNotify)
544 if (newItemIndex === this._currentItemIndex) {
545 if (this._currentItemLocked)
546 this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
550 var newItem = this._currentTimeSeriesData[newItemIndex];
551 this._brushExtent = undefined;
552 this._currentItemIndex = newItemIndex;
555 this._currentItemLocked = false;
556 this.set('selectedItem', null);
559 this._updateCurrentItemIndicators();
562 this.set('currentTime', newItem ? newItem.time : undefined);
564 this.set('currentItem', newItem);
565 if (this._currentItemLocked)
566 this.set('selectedItem', newItem ? newItem.measurement.id() : null);
568 _selectedItemChanged: function ()
573 var selectedId = this.get('selectedItem');
574 var currentItem = this.get('currentItem');
575 if (currentItem && currentItem.measurement.id() == selectedId)
578 var series = this._currentTimeSeriesData;
579 var selectedItemIndex = undefined;
580 for (var i = 0; i < series.length; i++) {
581 if (series[i].measurement.id() == selectedId) {
582 this._updateSelection(null);
583 this._currentItemLocked = true;
584 this._setCurrentItem(i);
585 this._updateSelectionToolbar();
589 }.observes('selectedItem').on('init'),
590 _highlightedItemsChanged: function () {
594 var highlightedItems = this.get('highlightedItems');
596 var data = this._currentTimeSeriesData.filter(function (item) { return highlightedItems[item.measurement.id()]; });
598 if (this._highlights.length)
599 this._highlights.forEach(function (highlight) { highlight.remove(); });
601 this._highlights.push(this._clippedContainer
602 .selectAll(".highlight")
604 .enter().append("line")
605 .attr("class", "highlight"));
607 this._updateHighlightPositions();
609 }.observes('highlightedItems'),
610 _rangesChanged: function ()
612 if (!this._currentTimeSeries)
615 function midPoint(firstPoint, secondPoint) {
616 if (firstPoint && secondPoint)
617 return (+firstPoint.time + +secondPoint.time) / 2;
619 return firstPoint.time;
620 return secondPoint.time;
622 var currentTimeSeries = this._currentTimeSeries;
623 var linkRoute = this.get('rangeRoute');
624 this.set('rangeBars', (this.get('ranges') || []).map(function (range) {
625 var start = currentTimeSeries.findPointByMeasurementId(range.get('startRun'));
626 var end = currentTimeSeries.findPointByMeasurementId(range.get('endRun'));
627 return Ember.Object.create({
628 startTime: midPoint(currentTimeSeries.previousPoint(start), start),
629 endTime: midPoint(end, currentTimeSeries.nextPoint(end)),
636 linkRoute: linkRoute,
637 linkId: range.get('id'),
638 label: range.get('label'),
642 this._updateRangeBarRects();
643 }.observes('ranges'),
644 _updateRangeBarRects: function () {
645 var rangeBars = this.get('rangeBars');
646 if (!rangeBars || !rangeBars.length)
649 var xScale = this._x;
650 var yScale = this._y;
652 // Expand the width of each range as needed and sort ranges by the left-edge of ranges.
654 var sortedBars = rangeBars.map(function (bar) {
655 var left = xScale(bar.get('startTime'));
656 var right = xScale(bar.get('endTime'));
657 if (right - left < minWidth) {
658 left -= minWidth / 2;
659 right += minWidth / 2;
661 bar.set('left', left);
662 bar.set('right', right);
664 }).sort(function (first, second) { return first.get('left') - second.get('left'); });
666 // At this point, left edges of all ranges prior to a range R1 is on the left of R1.
667 // 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.
669 sortedBars.forEach(function (bar) {
671 for (; rowIndex < rows.length; rowIndex++) {
672 var currentRow = rows[rowIndex];
673 if (currentRow[currentRow.length - 1].get('right') < bar.get('left')) {
674 currentRow.push(bar);
678 if (rowIndex >= rows.length)
680 bar.set('rowIndex', rowIndex);
682 var rowHeight = 0.6 * this._rem;
683 var firstRowTop = this._contentHeight - rows.length * rowHeight;
684 var barHeight = 0.5 * this._rem;
686 $(this.get('element')).find('.rangeBarsContainerInlineStyle').css({
687 left: this._margin.left + 'px',
688 top: this._margin.top + firstRowTop + 'px',
689 width: this._contentWidth + 'px',
690 height: rows.length * barHeight + 'px',
692 position: 'absolute',
695 var margin = this._margin;
696 sortedBars.forEach(function (bar) {
697 var top = bar.get('rowIndex') * rowHeight;
698 var height = barHeight;
699 var left = bar.get('left');
700 var width = bar.get('right') - left;
701 bar.set('inlineStyle', 'left: ' + left + 'px; top: ' + top + 'px; width: ' + width + 'px; height: ' + height + 'px;');
704 _updateCurrentItemIndicators: function ()
706 if (!this._currentItemLine)
709 var item = this._currentTimeSeriesData[this._currentItemIndex];
711 this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
712 this._currentItemCircle.attr("cx", -1000);
716 var x = this._x(item.time);
717 var y = this._y(item.value);
719 this._currentItemLine
723 this._currentItemCircle
727 _setCurrentSelection: function (newSelection)
729 if (this._brushExtent === newSelection)
734 points = this._currentTimeSeriesData
735 .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
740 this._brushExtent = newSelection;
741 this._setCurrentItem(undefined);
742 this._updateSelectionToolbar();
744 if (!App.domainsAreEqual(this.get('selection'), newSelection))
745 this.set('selection', newSelection);
746 this.set('selectedPoints', points);
748 _updateSelectionToolbar: function ()
750 if (!this.get('interactive'))
753 var selection = this._currentSelection();
754 var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
756 var left = this._x(selection[0]);
757 var right = this._x(selection[1]);
759 .css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
762 selectionToolbar.hide();
767 this.sendAction('zoom', this._currentSelection());
768 this.set('selection', null);
770 openRange: function (range)
772 this.sendAction('openRange', range);