New perf dashboard should have the ability to overlay moving average with an envelope
[WebKit-https.git] / Websites / perf.webkit.org / public / v2 / interactive-chart.js
1 App.InteractiveChartComponent = Ember.Component.extend({
2     chartData: null,
3     showXAxis: true,
4     showYAxis: true,
5     interactive: false,
6     enableSelection: true,
7     classNames: ['chart'],
8     init: function ()
9     {
10         this._super();
11         this._needsConstruction = true;
12         this._eventHandlers = [];
13         $(window).resize(this._updateDimensionsIfNeeded.bind(this));
14     },
15     chartDataDidChange: function ()
16     {
17         var chartData = this.get('chartData');
18         if (!chartData)
19             return;
20         this._needsConstruction = true;
21         this._totalWidth = undefined;
22         this._totalHeight = undefined;
23         this._constructGraphIfPossible(chartData);
24     }.observes('chartData').on('init'),
25     didInsertElement: function ()
26     {
27         var chartData = this.get('chartData');
28         if (chartData)
29             this._constructGraphIfPossible(chartData);
30
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));
37         }
38     },
39     willClearRender: function ()
40     {
41         this._eventHandlers.forEach(function (item) {
42             $(item[0]).off(item[1], item[2]);
43         })
44     },
45     _attachEventListener: function(target, eventName, listener)
46     {
47         this._eventHandlers.push([target, eventName, listener]);
48         $(target).on(eventName, listener);
49     },
50     _constructGraphIfPossible: function (chartData)
51     {
52         if (!this._needsConstruction || !this.get('element'))
53             return;
54
55         var element = this.get('element');
56
57         this._x = d3.time.scale();
58         this._y = d3.scale.linear();
59
60         if (this._svgElement)
61             this._svgElement.remove();
62         this._svgElement = d3.select(element).append("svg")
63                 .attr("width", "100%")
64                 .attr("height", "100%");
65
66         var svg = this._svg = this._svgElement.append("g");
67
68         var clipId = element.id + "-clip";
69         this._clipPath = svg.append("defs").append("clipPath")
70             .attr("id", clipId)
71             .append("rect");
72
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");
77         }
78
79         if (this.get('showYAxis')) {
80             this._yAxis = d3.svg.axis().scale(this._y).orient("left").ticks(6).tickFormat(chartData.formatter);
81             this._yAxisLabels = svg.append("g")
82                 .attr("class", "y axis");
83         }
84
85         this._clippedContainer = svg.append("g")
86             .attr("clip-path", "url(#" + clipId + ")");
87
88         var xScale = this._x;
89         var yScale = this._y;
90         this._timeLine = d3.svg.line()
91             .x(function(point) { return xScale(point.time); })
92             .y(function(point) { return yScale(point.value); });
93
94         this._confidenceArea = d3.svg.area()
95 //            .interpolate("cardinal")
96             .x(function(point) { return xScale(point.time); })
97             .y0(function(point) { return point.interval ? yScale(point.interval[0]) : null; })
98             .y1(function(point) { return point.interval ? yScale(point.interval[1]) : null; });
99
100         this._paths = [];
101         this._areas = [];
102         this._dots = [];
103         this._highlights = null;
104
105         this._currentTimeSeries = chartData.current;
106         this._currentTimeSeriesData = this._currentTimeSeries.series();
107         this._baselineTimeSeries = chartData.baseline;
108         this._targetTimeSeries = chartData.target;
109         this._movingAverageTimeSeries = chartData.movingAverage;
110
111         this._yAxisUnit = chartData.unit;
112
113         if (this._baselineTimeSeries) {
114             this._paths.push(this._clippedContainer
115                 .append("path")
116                 .datum(this._baselineTimeSeries.series())
117                 .attr("class", "baseline"));
118         }
119         if (this._targetTimeSeries) {
120             this._paths.push(this._clippedContainer
121                 .append("path")
122                 .datum(this._targetTimeSeries.series())
123                 .attr("class", "target"));
124         }
125
126         var foregroundClass = this._movingAverageTimeSeries ? '' : ' foreground';
127         this._areas.push(this._clippedContainer
128             .append("path")
129             .datum(this._currentTimeSeriesData)
130             .attr("class", "area" + foregroundClass));
131
132         this._paths.push(this._clippedContainer
133             .append("path")
134             .datum(this._currentTimeSeriesData)
135             .attr("class", "current" + foregroundClass));
136
137         this._dots.push(this._clippedContainer
138             .selectAll(".dot")
139                 .data(this._currentTimeSeriesData)
140             .enter().append("circle")
141                 .attr("class", "dot" + foregroundClass)
142                 .attr("r", this.get('chartPointRadius') || 1));
143
144         if (this._movingAverageTimeSeries) {
145             this._paths.push(this._clippedContainer
146                 .append("path")
147                 .datum(this._movingAverageTimeSeries.series())
148                 .attr("class", "movingAverage"));
149             this._areas.push(this._clippedContainer
150                 .append("path")
151                 .datum(this._movingAverageTimeSeries.series())
152                 .attr("class", "envelope"));
153         }
154
155         if (this.get('interactive')) {
156             this._currentItemLine = this._clippedContainer
157                 .append("line")
158                 .attr("class", "current-item");
159
160             this._currentItemCircle = this._clippedContainer
161                 .append("circle")
162                 .attr("class", "dot current-item")
163                 .attr("r", 3);
164         }
165
166         this._brush = null;
167         if (this.get('enableSelection')) {
168             this._brush = d3.svg.brush()
169                 .x(this._x)
170                 .on("brush", this._brushChanged.bind(this));
171
172             this._brushRect = this._clippedContainer
173                 .append("g")
174                 .attr("class", "x brush");
175         }
176
177         this._updateDomain();
178         this._updateDimensionsIfNeeded();
179
180         // Work around the fact the brush isn't set if we updated it synchronously here.
181         setTimeout(this._selectionChanged.bind(this), 0);
182
183         setTimeout(this._selectedItemChanged.bind(this), 0);
184
185         this._needsConstruction = false;
186
187         this._highlightedItemsChanged();
188         this._rangesChanged();
189     },
190     _updateDomain: function ()
191     {
192         var xDomain = this.get('domain');
193         var intrinsicXDomain = this._computeXAxisDomain(this._currentTimeSeriesData);
194         if (!xDomain)
195             xDomain = intrinsicXDomain;
196         var currentDomain = this._x.domain();
197         if (currentDomain && App.domainsAreEqual(currentDomain, xDomain))
198             return currentDomain;
199
200         var yDomain = this._computeYAxisDomain(xDomain[0], xDomain[1]);
201         this._x.domain(xDomain);
202         this._y.domain(yDomain);
203         return xDomain;
204     },
205     _updateDimensionsIfNeeded: function (newSelection)
206     {
207         var element = $(this.get('element'));
208
209         var newTotalWidth = element.width();
210         var newTotalHeight = element.height();
211         if (this._totalWidth == newTotalWidth && this._totalHeight == newTotalHeight)
212             return;
213
214         this._totalWidth = newTotalWidth;
215         this._totalHeight = newTotalHeight;
216
217         if (!this._rem)
218             this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
219         var rem = this._rem;
220
221         var padding = 0.5 * rem;
222         var margin = {top: padding, right: padding, bottom: padding, left: padding};
223         if (this._xAxis)
224             margin.bottom += rem;
225         if (this._yAxis)
226             margin.left += 3 * rem;
227
228         this._margin = margin;
229         this._contentWidth = this._totalWidth - margin.left - margin.right;
230         this._contentHeight = this._totalHeight - margin.top - margin.bottom;
231
232         this._svg.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
233
234         this._clipPath
235             .attr("width", this._contentWidth)
236             .attr("height", this._contentHeight);
237
238         this._x.range([0, this._contentWidth]);
239         this._y.range([this._contentHeight, 0]);
240
241         if (this._xAxis) {
242             this._xAxis.tickSize(-this._contentHeight);
243             this._xAxisLabels.attr("transform", "translate(0," + this._contentHeight + ")");
244         }
245
246         if (this._yAxis)
247             this._yAxis.tickSize(-this._contentWidth);
248
249         if (this._currentItemLine) {
250             this._currentItemLine
251                 .attr("y1", 0)
252                 .attr("y2", margin.top + this._contentHeight);
253         }
254
255         this._relayoutDataAndAxes(this._currentSelection());
256     },
257     _updateBrush: function ()
258     {
259         this._brushRect
260             .call(this._brush)
261         .selectAll("rect")
262             .attr("y", 1)
263             .attr("height", this._contentHeight - 2);
264         this._updateSelectionToolbar();
265     },
266     _relayoutDataAndAxes: function (selection)
267     {
268         var timeline = this._timeLine;
269         this._paths.forEach(function (path) { path.attr("d", timeline); });
270
271         var confidenceArea = this._confidenceArea;
272         this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
273
274         var xScale = this._x;
275         var yScale = this._y;
276         this._dots.forEach(function (dot) {
277             dot
278                 .attr("cx", function(measurement) { return xScale(measurement.time); })
279                 .attr("cy", function(measurement) { return yScale(measurement.value); });
280         });
281         this._updateHighlightPositions();
282         this._updateRangeBarRects();
283
284         if (this._brush) {
285             if (selection)
286                 this._brush.extent(selection);
287             else
288                 this._brush.clear();
289             this._updateBrush();
290         }
291
292         this._updateCurrentItemIndicators();
293
294         if (this._xAxis)
295             this._xAxisLabels.call(this._xAxis);
296         if (!this._yAxis)
297             return;
298
299         this._yAxisLabels.call(this._yAxis);
300         if (this._yAxisUnitContainer)
301             this._yAxisUnitContainer.remove();
302         var x = - 3.2 * this._rem;
303         var y = this._contentHeight / 2;
304         this._yAxisUnitContainer = this._yAxisLabels.append("text")
305             .attr("transform", "rotate(90 0 0) translate(" + y + ", " + (-x) + ")")
306             .style("text-anchor", "middle")
307             .text(this._yAxisUnit);
308     },
309     _updateHighlightPositions: function () {
310         if (!this._highlights)
311             return;
312
313         var xScale = this._x;
314         var yScale = this._y;
315         this._highlights
316             .attr("cy", function(point) { return yScale(point.value); })
317             .attr("cx", function(point) { return xScale(point.time); });
318     },
319     _computeXAxisDomain: function (timeSeries)
320     {
321         var extent = d3.extent(timeSeries, function(point) { return point.time; });
322         var margin = 3600 * 1000; // Use x.inverse to compute the right amount from a margin.
323         return [+extent[0] - margin, +extent[1] + margin];
324     },
325     _computeYAxisDomain: function (startTime, endTime)
326     {
327         var range = this._minMaxForAllTimeSeries(startTime, endTime);
328         var min = range[0];
329         var max = range[1];
330         if (max < min)
331             min = max = 0;
332         var diff = max - min;
333         var margin = diff * 0.05;
334
335         yExtent = [min - margin, max + margin];
336 //        if (yMin !== undefined)
337 //            yExtent[0] = parseInt(yMin);
338         return yExtent;
339     },
340     _minMaxForAllTimeSeries: function (startTime, endTime)
341     {
342         var currentRange = this._currentTimeSeries.minMaxForTimeRange(startTime, endTime);
343         var baselineRange = this._baselineTimeSeries ? this._baselineTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
344         var targetRange = this._targetTimeSeries ? this._targetTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
345         var movingAverageRange = this._movingAverageTimeSeries ? this._movingAverageTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
346         return [
347             Math.min(currentRange[0], baselineRange[0], targetRange[0], movingAverageRange[0]),
348             Math.max(currentRange[1], baselineRange[1], targetRange[1], movingAverageRange[1]),
349         ];
350     },
351     _currentSelection: function ()
352     {
353         return this._brush && !this._brush.empty() ? this._brush.extent() : null;
354     },
355     _domainChanged: function ()
356     {
357         var selection = this._currentSelection() || this.get('sharedSelection');
358         var newXDomain = this._updateDomain();
359
360         if (selection && newXDomain && selection[0] <= newXDomain[0] && newXDomain[1] <= selection[1])
361             selection = null; // Otherwise the user has no way of clearing the selection.
362
363         this._relayoutDataAndAxes(selection);
364     }.observes('domain'),
365     _selectionChanged: function ()
366     {
367         this._updateSelection(this.get('selection'));
368     }.observes('selection'),
369     _updateSelection: function (newSelection)
370     {
371         if (!this._brush)
372             return;
373
374         var currentSelection = this._currentSelection();
375         if (newSelection && currentSelection && App.domainsAreEqual(newSelection, currentSelection))
376             return;
377
378         var domain = this._x.domain();
379         if (!newSelection || App.domainsAreEqual(domain, newSelection))
380             this._brush.clear();
381         else
382             this._brush.extent(newSelection);
383         this._updateBrush();
384
385         this._setCurrentSelection(newSelection);
386     },
387     _brushChanged: function ()
388     {
389         if (this._brush.empty()) {
390             if (!this._brushExtent)
391                 return;
392
393             this._setCurrentSelection(undefined);
394
395             // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
396             this._brushJustChanged = true;
397             var self = this;
398             setTimeout(function () {
399                 self._brushJustChanged = false;
400             }, 0);
401
402             return;
403         }
404
405         this._setCurrentSelection(this._brush.extent());
406     },
407     _keyPressed: function (event)
408     {
409         if (!this._currentItemIndex || this._currentSelection())
410             return;
411
412         var newIndex;
413         switch (event.keyCode) {
414         case 37: // Left
415             newIndex = this._currentItemIndex - 1;
416             break;
417         case 39: // Right
418             newIndex = this._currentItemIndex + 1;
419             break;
420         case 38: // Up
421         case 40: // Down
422         default:
423             return;
424         }
425
426         // Unlike mousemove, keydown shouldn't move off the edge.
427         if (this._currentTimeSeriesData[newIndex])
428             this._setCurrentItem(newIndex);
429     },
430     _mouseMoved: function (event)
431     {
432         if (!this._margin || this._currentSelection() || this._currentItemLocked)
433             return;
434
435         var point = this._mousePointInGraph(event);
436
437         this._selectClosestPointToMouseAsCurrentItem(point);
438     },
439     _mouseLeft: function (event)
440     {
441         if (!this._margin || this._currentItemLocked)
442             return;
443
444         this._selectClosestPointToMouseAsCurrentItem(null);
445     },
446     _mouseDown: function (event)
447     {
448         if (!this._margin || this._currentSelection() || this._brushJustChanged)
449             return;
450
451         var point = this._mousePointInGraph(event);
452         if (!point)
453             return;
454
455         if (this._currentItemLocked) {
456             this._currentItemLocked = false;
457             this.set('selectedItem', null);
458             return;
459         }
460
461         this._currentItemLocked = true;
462         this._selectClosestPointToMouseAsCurrentItem(point);
463     },
464     _mousePointInGraph: function (event)
465     {
466         var offset = $(this.get('element')).offset();
467         if (!offset || !$(event.target).closest('svg').length)
468             return null;
469
470         var point = {
471             x: event.pageX - offset.left - this._margin.left,
472             y: event.pageY - offset.top - this._margin.top
473         };
474
475         var xScale = this._x;
476         var yScale = this._y;
477         var xDomain = xScale.domain();
478         var yDomain = yScale.domain();
479         if (point.x >= xScale(xDomain[0]) && point.x <= xScale(xDomain[1])
480             && point.y <= yScale(yDomain[0]) && point.y >= yScale(yDomain[1]))
481             return point;
482
483         return null;
484     },
485     _selectClosestPointToMouseAsCurrentItem: function (point)
486     {
487         var xScale = this._x;
488         var yScale = this._y;
489         var distanceHeuristics = function (m) {
490             var mX = xScale(m.time);
491             var mY = yScale(m.value);
492             var xDiff = mX - point.x;
493             var yDiff = mY - point.y;
494             return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
495         };
496         distanceHeuristics = function (m) {
497             return Math.abs(xScale(m.time) - point.x);
498         }
499
500         var newItemIndex;
501         if (point && !this._currentSelection()) {
502             var distances = this._currentTimeSeriesData.map(distanceHeuristics);
503             var minDistance = Number.MAX_VALUE;
504             for (var i = 0; i < distances.length; i++) {
505                 if (distances[i] < minDistance) {
506                     newItemIndex = i;
507                     minDistance = distances[i];
508                 }
509             }
510         }
511
512         this._setCurrentItem(newItemIndex);
513         this._updateSelectionToolbar();
514     },
515     _currentTimeChanged: function ()
516     {
517         if (!this._margin || this._currentSelection() || this._currentItemLocked)
518             return
519
520         var currentTime = this.get('currentTime');
521         if (currentTime) {
522             for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
523                 var point = this._currentTimeSeriesData[i];
524                 if (point.time >= currentTime) {
525                     this._setCurrentItem(i, /* doNotNotify */ true);
526                     return;
527                 }
528             }
529         }
530         this._setCurrentItem(undefined, /* doNotNotify */ true);
531     }.observes('currentTime'),
532     _setCurrentItem: function (newItemIndex, doNotNotify)
533     {
534         if (newItemIndex === this._currentItemIndex) {
535             if (this._currentItemLocked)
536                 this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
537             return;
538         }
539
540         var newItem = this._currentTimeSeriesData[newItemIndex];
541         this._brushExtent = undefined;
542         this._currentItemIndex = newItemIndex;
543
544         if (!newItem) {
545             this._currentItemLocked = false;
546             this.set('selectedItem', null);
547         }
548
549         this._updateCurrentItemIndicators();
550
551         if (!doNotNotify)
552             this.set('currentTime', newItem ? newItem.time : undefined);
553
554         this.set('currentItem', newItem);
555         if (this._currentItemLocked)
556             this.set('selectedItem', newItem ? newItem.measurement.id() : null);
557     },
558     _selectedItemChanged: function ()
559     {
560         if (!this._margin)
561             return;
562
563         var selectedId = this.get('selectedItem');
564         var currentItem = this.get('currentItem');
565         if (currentItem && currentItem.measurement.id() == selectedId)
566             return;
567
568         var series = this._currentTimeSeriesData;
569         var selectedItemIndex = undefined;
570         for (var i = 0; i < series.length; i++) {
571             if (series[i].measurement.id() == selectedId) {
572                 this._updateSelection(null);
573                 this._currentItemLocked = true;
574                 this._setCurrentItem(i);
575                 this._updateSelectionToolbar();
576                 return;
577             }
578         }
579     }.observes('selectedItem').on('init'),
580     _highlightedItemsChanged: function () {
581         var highlightedItems = this.get('highlightedItems');
582
583         if (!this._clippedContainer || !highlightedItems)
584             return;
585
586         var data = this._currentTimeSeriesData.filter(function (item) { return highlightedItems[item.measurement.id()]; });
587
588         if (this._highlights)
589             this._highlights.remove();
590         this._highlights = this._clippedContainer
591             .selectAll(".highlight")
592                 .data(data)
593             .enter().append("circle")
594                 .attr("class", "highlight")
595                 .attr("r", (this.get('chartPointRadius') || 1) * 1.8);
596
597         this._updateHighlightPositions();
598     }.observes('highlightedItems'),
599     _rangesChanged: function ()
600     {
601         if (!this._currentTimeSeries)
602             return;
603
604         function midPoint(firstPoint, secondPoint) {
605             if (firstPoint && secondPoint)
606                 return (+firstPoint.time + +secondPoint.time) / 2;
607             if (firstPoint)
608                 return firstPoint.time;
609             return secondPoint.time;
610         }
611         var currentTimeSeries = this._currentTimeSeries;
612         var linkRoute = this.get('rangeRoute');
613         this.set('rangeBars', (this.get('ranges') || []).map(function (range) {
614             var start = currentTimeSeries.findPointByMeasurementId(range.get('startRun'));
615             var end = currentTimeSeries.findPointByMeasurementId(range.get('endRun'));
616             return Ember.Object.create({
617                 startTime: midPoint(currentTimeSeries.previousPoint(start), start),
618                 endTime: midPoint(end, currentTimeSeries.nextPoint(end)),
619                 range: range,
620                 left: null,
621                 right: null,
622                 rowIndex: null,
623                 top: null,
624                 bottom: null,
625                 linkRoute: linkRoute,
626                 linkId: range.get('id'),
627                 label: range.get('label'),
628             });
629         }));
630
631         this._updateRangeBarRects();
632     }.observes('ranges'),
633     _updateRangeBarRects: function () {
634         var rangeBars = this.get('rangeBars');
635         if (!rangeBars || !rangeBars.length)
636             return;
637
638         var xScale = this._x;
639         var yScale = this._y;
640
641         // Expand the width of each range as needed and sort ranges by the left-edge of ranges.
642         var minWidth = 3;
643         var sortedBars = rangeBars.map(function (bar) {
644             var left = xScale(bar.get('startTime'));
645             var right = xScale(bar.get('endTime'));
646             if (right - left < minWidth) {
647                 left -= minWidth / 2;
648                 right += minWidth / 2;
649             }
650             bar.set('left', left);
651             bar.set('right', right);
652             return bar;
653         }).sort(function (first, second) { return first.get('left') - second.get('left'); });
654
655         // At this point, left edges of all ranges prior to a range R1 is on the left of R1.
656         // 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.
657         var rows = [];
658         sortedBars.forEach(function (bar) {
659             var rowIndex = 0;
660             for (; rowIndex < rows.length; rowIndex++) {
661                 var currentRow = rows[rowIndex];
662                 if (currentRow[currentRow.length - 1].get('right') < bar.get('left')) {
663                     currentRow.push(bar);
664                     break;
665                 }
666             }
667             if (rowIndex >= rows.length)
668                 rows.push([bar]);
669             bar.set('rowIndex', rowIndex);
670         });
671         var rowHeight = 0.6 * this._rem;
672         var firstRowTop = this._contentHeight - rows.length * rowHeight;
673         var barHeight = 0.5 * this._rem;
674
675         $(this.get('element')).find('.rangeBarsContainerInlineStyle').css({
676             left: this._margin.left + 'px',
677             top: this._margin.top + firstRowTop + 'px',
678             width: this._contentWidth + 'px',
679             height: rows.length * barHeight + 'px',
680             overflow: 'hidden',
681             position: 'absolute',
682         });
683
684         var margin = this._margin;
685         sortedBars.forEach(function (bar) {
686             var top = bar.get('rowIndex') * rowHeight;
687             var height = barHeight;
688             var left = bar.get('left');
689             var width = bar.get('right') - left;
690             bar.set('inlineStyle', 'left: ' + left + 'px; top: ' + top + 'px; width: ' + width + 'px; height: ' + height + 'px;');
691         });
692     },
693     _updateCurrentItemIndicators: function ()
694     {
695         if (!this._currentItemLine)
696             return;
697
698         var item = this._currentTimeSeriesData[this._currentItemIndex];
699         if (!item) {
700             this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
701             this._currentItemCircle.attr("cx", -1000);
702             return;
703         }
704
705         var x = this._x(item.time);
706         var y = this._y(item.value);
707
708         this._currentItemLine
709             .attr("x1", x)
710             .attr("x2", x);
711
712         this._currentItemCircle
713             .attr("cx", x)
714             .attr("cy", y);
715     },
716     _setCurrentSelection: function (newSelection)
717     {
718         if (this._brushExtent === newSelection)
719             return;
720
721         var points = null;
722         if (newSelection) {
723             points = this._currentTimeSeriesData
724                 .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
725             if (!points.length)
726                 points = null;
727         }
728
729         this._brushExtent = newSelection;
730         this._setCurrentItem(undefined);
731         this._updateSelectionToolbar();
732
733         if (!App.domainsAreEqual(this.get('selection'), newSelection))
734             this.set('selection', newSelection);
735         this.set('selectedPoints', points);
736     },
737     _updateSelectionToolbar: function ()
738     {
739         if (!this.get('interactive'))
740             return;
741
742         var selection = this._currentSelection();
743         var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
744         if (selection) {
745             var left = this._x(selection[0]);
746             var right = this._x(selection[1]);
747             selectionToolbar
748                 .css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
749                 .show();
750         } else
751             selectionToolbar.hide();
752     },
753     actions: {
754         zoom: function ()
755         {
756             this.sendAction('zoom', this._currentSelection());
757             this.set('selection', null);
758         },
759         openRange: function (range)
760         {
761             this.sendAction('openRange', range);
762         },
763     },
764 });