Relationship between A/B testing results are unclear
[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         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);
82             
83             this._yAxisLabels = svg.append('g').attr('class', 'y axis' + (isInteractive ? ' interactive' : ''));
84             if (isInteractive) {
85                 var self = this;
86                 this._yAxisLabels.on('click', function () { self.toggleProperty('showFullYAxis'); });
87             }
88         }
89
90         this._clippedContainer = svg.append("g")
91             .attr("clip-path", "url(#" + clipId + ")");
92
93         var xScale = this._x;
94         var yScale = this._y;
95         this._timeLine = d3.svg.line()
96             .x(function(point) { return xScale(point.time); })
97             .y(function(point) { return yScale(point.value); });
98
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; });
104
105         this._paths = [];
106         this._areas = [];
107         this._dots = [];
108         this._highlights = null;
109
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;
115
116         this._yAxisUnit = chartData.unit;
117
118         if (this._baselineTimeSeries) {
119             this._paths.push(this._clippedContainer
120                 .append("path")
121                 .datum(this._baselineTimeSeries.series())
122                 .attr("class", "baseline"));
123         }
124         if (this._targetTimeSeries) {
125             this._paths.push(this._clippedContainer
126                 .append("path")
127                 .datum(this._targetTimeSeries.series())
128                 .attr("class", "target"));
129         }
130
131         var movingAverageIsVisible = this._movingAverageTimeSeries;
132         var foregroundClass = movingAverageIsVisible ? '' : ' foreground';
133         this._areas.push(this._clippedContainer
134             .append("path")
135             .datum(this._currentTimeSeriesData)
136             .attr("class", "area" + foregroundClass));
137
138         this._paths.push(this._clippedContainer
139             .append("path")
140             .datum(this._currentTimeSeriesData)
141             .attr("class", "current" + foregroundClass));
142
143         this._dots.push(this._clippedContainer
144             .selectAll(".dot")
145                 .data(this._currentTimeSeriesData)
146             .enter().append("circle")
147                 .attr("class", "dot" + foregroundClass)
148                 .attr("r", this.get('chartPointRadius') || 1));
149
150         if (movingAverageIsVisible) {
151             this._paths.push(this._clippedContainer
152                 .append("path")
153                 .datum(this._movingAverageTimeSeries.series())
154                 .attr("class", "movingAverage"));
155
156             this._areas.push(this._clippedContainer
157                 .append("path")
158                 .datum(this._movingAverageTimeSeries.series())
159                 .attr("class", "envelope"));
160         }
161
162         if (isInteractive) {
163             this._currentItemLine = this._clippedContainer
164                 .append("line")
165                 .attr("class", "current-item");
166
167             this._currentItemCircle = this._clippedContainer
168                 .append("circle")
169                 .attr("class", "dot current-item")
170                 .attr("r", 3);
171         }
172
173         this._brush = null;
174         if (this.get('enableSelection')) {
175             this._brush = d3.svg.brush()
176                 .x(this._x)
177                 .on("brush", this._brushChanged.bind(this));
178
179             this._brushRect = this._clippedContainer
180                 .append("g")
181                 .attr("class", "x brush");
182         }
183
184         this._updateDomain();
185         this._updateDimensionsIfNeeded();
186
187         // Work around the fact the brush isn't set if we updated it synchronously here.
188         setTimeout(this._selectionChanged.bind(this), 0);
189
190         setTimeout(this._selectedItemChanged.bind(this), 0);
191
192         this._needsConstruction = false;
193
194         this._highlightedItemsChanged();
195         this._rangesChanged();
196     },
197     _updateDomain: function ()
198     {
199         var xDomain = this.get('domain');
200         if (!xDomain || !this._currentTimeSeriesData)
201             return null;
202         var intrinsicXDomain = this._computeXAxisDomain(this._currentTimeSeriesData);
203         if (!xDomain)
204             xDomain = intrinsicXDomain;
205         var yDomain = this._computeYAxisDomain(xDomain[0], xDomain[1]);
206
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;
212
213         this._x.domain(xDomain);
214         this._y.domain(yDomain);
215         return xDomain;
216     },
217     _updateDimensionsIfNeeded: function (newSelection)
218     {
219         var element = $(this.get('element'));
220
221         var newTotalWidth = element.width();
222         var newTotalHeight = element.height();
223         if (this._totalWidth == newTotalWidth && this._totalHeight == newTotalHeight)
224             return;
225
226         this._totalWidth = newTotalWidth;
227         this._totalHeight = newTotalHeight;
228
229         if (!this._rem)
230             this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
231         var rem = this._rem;
232
233         var padding = 0.5 * rem;
234         var margin = {top: padding, right: padding, bottom: padding, left: padding};
235         if (this._xAxis)
236             margin.bottom += rem;
237         if (this._yAxis)
238             margin.left += 3 * rem;
239
240         this._margin = margin;
241         this._contentWidth = this._totalWidth - margin.left - margin.right;
242         this._contentHeight = this._totalHeight - margin.top - margin.bottom;
243
244         this._svg.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
245
246         this._clipPath
247             .attr("width", this._contentWidth)
248             .attr("height", this._contentHeight);
249
250         this._x.range([0, this._contentWidth]);
251         this._y.range([this._contentHeight, 0]);
252
253         if (this._xAxis) {
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 + ")");
257         }
258
259         if (this._yAxis) {
260             this._yAxis.ticks(Math.round(this._contentHeight / 2 / rem));
261             this._yAxis.tickSize(-this._contentWidth);
262         }
263
264         if (this._currentItemLine) {
265             this._currentItemLine
266                 .attr("y1", 0)
267                 .attr("y2", margin.top + this._contentHeight);
268         }
269
270         this._relayoutDataAndAxes(this._currentSelection());
271     },
272     _updateBrush: function ()
273     {
274         this._brushRect
275             .call(this._brush)
276         .selectAll("rect")
277             .attr("y", 1)
278             .attr("height", this._contentHeight - 2);
279         this._updateSelectionToolbar();
280     },
281     _relayoutDataAndAxes: function (selection)
282     {
283         var timeline = this._timeLine;
284         this._paths.forEach(function (path) { path.attr("d", timeline); });
285
286         var confidenceArea = this._confidenceArea;
287         this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
288
289         var xScale = this._x;
290         var yScale = this._y;
291         this._dots.forEach(function (dot) {
292             dot
293                 .attr("cx", function(measurement) { return xScale(measurement.time); })
294                 .attr("cy", function(measurement) { return yScale(measurement.value); });
295         });
296         this._updateHighlightPositions();
297         this._updateRangeBarRects();
298
299         if (this._brush) {
300             if (selection)
301                 this._brush.extent(selection);
302             else
303                 this._brush.clear();
304             this._updateBrush();
305         }
306
307         this._updateCurrentItemIndicators();
308
309         if (this._xAxis)
310             this._xAxisLabels.call(this._xAxis);
311         if (!this._yAxis)
312             return;
313
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);
323     },
324     _updateHighlightPositions: function () {
325         if (!this._highlights)
326             return;
327
328         var xScale = this._x;
329         var yScale = this._y;
330         this._highlights
331             .attr("cy", function(point) { return yScale(point.value); })
332             .attr("cx", function(point) { return xScale(point.time); });
333     },
334     _computeXAxisDomain: function (timeSeries)
335     {
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];
339     },
340     _computeYAxisDomain: function (startTime, endTime)
341     {
342         var shouldShowFullYAxis = this.get('showFullYAxis');
343         var range = this._minMaxForAllTimeSeries(startTime, endTime, !shouldShowFullYAxis);
344         var min = range[0];
345         var max = range[1];
346
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));
354         }
355
356         if (max < min)
357             min = max = 0;
358         else if (shouldShowFullYAxis)
359             min = Math.min(min, 0);
360         var diff = max - min;
361         var margin = diff * 0.05;
362
363         yExtent = [min - margin, max + margin];
364         return yExtent;
365     },
366     _minMaxForAllTimeSeries: function (startTime, endTime, ignoreOutliners)
367     {
368         var seriesList = [this._currentTimeSeries, this._movingAverageTimeSeries, this._baselineTimeSeries, this._targetTimeSeries];
369         var min = Infinity;
370         var max = -Infinity;
371         for (var i = 0; i < seriesList.length; i++) {
372             if (seriesList[i]) {
373                 var minMax = seriesList[i].minMaxForTimeRange(startTime, endTime, ignoreOutliners);
374                 min = Math.min(min, minMax[0]);
375                 max = Math.max(max, minMax[1]);
376             }
377         }
378         return [min, max];
379     },
380     _currentSelection: function ()
381     {
382         return this._brush && !this._brush.empty() ? this._brush.extent() : null;
383     },
384     _domainChanged: function ()
385     {
386         var selection = this._currentSelection() || this.get('sharedSelection');
387         var newXDomain = this._updateDomain();
388         if (!newXDomain)
389             return;
390
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.
393
394         this._relayoutDataAndAxes(selection);
395     }.observes('domain', 'showFullYAxis'),
396     _selectionChanged: function ()
397     {
398         this._updateSelection(this.get('selection'));
399     }.observes('selection'),
400     _updateSelection: function (newSelection)
401     {
402         if (!this._brush)
403             return;
404
405         var currentSelection = this._currentSelection();
406         if (newSelection && currentSelection && App.domainsAreEqual(newSelection, currentSelection))
407             return;
408
409         var domain = this._x.domain();
410         if (!newSelection || App.domainsAreEqual(domain, newSelection))
411             this._brush.clear();
412         else
413             this._brush.extent(newSelection);
414         this._updateBrush();
415
416         this._setCurrentSelection(newSelection);
417     },
418     _brushChanged: function ()
419     {
420         if (this._brush.empty()) {
421             if (!this._brushExtent)
422                 return;
423
424             this._setCurrentSelection(undefined);
425
426             // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
427             this._brushJustChanged = true;
428             var self = this;
429             setTimeout(function () {
430                 self._brushJustChanged = false;
431             }, 0);
432
433             return;
434         }
435
436         this._setCurrentSelection(this._brush.extent());
437     },
438     _keyPressed: function (event)
439     {
440         if (!this._currentItemIndex || this._currentSelection())
441             return;
442
443         var newIndex;
444         switch (event.keyCode) {
445         case 37: // Left
446             newIndex = this._currentItemIndex - 1;
447             break;
448         case 39: // Right
449             newIndex = this._currentItemIndex + 1;
450             break;
451         case 38: // Up
452         case 40: // Down
453         default:
454             return;
455         }
456
457         // Unlike mousemove, keydown shouldn't move off the edge.
458         if (this._currentTimeSeriesData[newIndex])
459             this._setCurrentItem(newIndex);
460     },
461     _mouseMoved: function (event)
462     {
463         if (!this._margin || this._currentSelection() || this._currentItemLocked)
464             return;
465
466         var point = this._mousePointInGraph(event);
467
468         this._selectClosestPointToMouseAsCurrentItem(point);
469     },
470     _mouseLeft: function (event)
471     {
472         if (!this._margin || this._currentItemLocked)
473             return;
474
475         this._selectClosestPointToMouseAsCurrentItem(null);
476     },
477     _mouseDown: function (event)
478     {
479         if (!this._margin || this._currentSelection() || this._brushJustChanged)
480             return;
481
482         var point = this._mousePointInGraph(event);
483         if (!point)
484             return;
485
486         if (this._currentItemLocked) {
487             this._currentItemLocked = false;
488             this.set('selectedItem', null);
489             return;
490         }
491
492         this._currentItemLocked = true;
493         this._selectClosestPointToMouseAsCurrentItem(point);
494     },
495     _mousePointInGraph: function (event)
496     {
497         var offset = $(this.get('element')).offset();
498         if (!offset || !$(event.target).closest('svg').length)
499             return null;
500
501         var point = {
502             x: event.pageX - offset.left - this._margin.left,
503             y: event.pageY - offset.top - this._margin.top
504         };
505
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]))
512             return point;
513
514         return null;
515     },
516     _selectClosestPointToMouseAsCurrentItem: function (point)
517     {
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.
526         };
527         distanceHeuristics = function (m) {
528             return Math.abs(xScale(m.time) - point.x);
529         }
530
531         var newItemIndex;
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) {
537                     newItemIndex = i;
538                     minDistance = distances[i];
539                 }
540             }
541         }
542
543         this._setCurrentItem(newItemIndex);
544         this._updateSelectionToolbar();
545     },
546     _currentTimeChanged: function ()
547     {
548         if (!this._margin || this._currentSelection() || this._currentItemLocked)
549             return
550
551         var currentTime = this.get('currentTime');
552         if (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);
557                     return;
558                 }
559             }
560         }
561         this._setCurrentItem(undefined, /* doNotNotify */ true);
562     }.observes('currentTime'),
563     _setCurrentItem: function (newItemIndex, doNotNotify)
564     {
565         if (newItemIndex === this._currentItemIndex) {
566             if (this._currentItemLocked)
567                 this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
568             return;
569         }
570
571         var newItem = this._currentTimeSeriesData[newItemIndex];
572         this._brushExtent = undefined;
573         this._currentItemIndex = newItemIndex;
574
575         if (!newItem) {
576             this._currentItemLocked = false;
577             this.set('selectedItem', null);
578         }
579
580         this._updateCurrentItemIndicators();
581
582         if (!doNotNotify)
583             this.set('currentTime', newItem ? newItem.time : undefined);
584
585         this.set('currentItem', newItem);
586         if (this._currentItemLocked)
587             this.set('selectedItem', newItem ? newItem.measurement.id() : null);
588     },
589     _selectedItemChanged: function ()
590     {
591         if (!this._margin)
592             return;
593
594         var selectedId = this.get('selectedItem');
595         var currentItem = this.get('currentItem');
596         if (currentItem && currentItem.measurement.id() == selectedId)
597             return;
598
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();
607                 return;
608             }
609         }
610     }.observes('selectedItem').on('init'),
611     _highlightedItemsChanged: function () {
612         var highlightedItems = this.get('highlightedItems');
613
614         if (!this._clippedContainer || !highlightedItems)
615             return;
616
617         var data = this._currentTimeSeriesData.filter(function (item) { return highlightedItems[item.measurement.id()]; });
618
619         if (this._highlights)
620             this._highlights.remove();
621         this._highlights = this._clippedContainer
622             .selectAll(".highlight")
623                 .data(data)
624             .enter().append("circle")
625                 .attr("class", "highlight")
626                 .attr("r", (this.get('chartPointRadius') || 1) * 1.8);
627
628         this._domainChanged();
629     }.observes('highlightedItems'),
630     _rangesChanged: function ()
631     {
632         if (!this._currentTimeSeries)
633             return;
634
635         function midPoint(firstPoint, secondPoint) {
636             if (firstPoint && secondPoint)
637                 return (+firstPoint.time + +secondPoint.time) / 2;
638             if (firstPoint)
639                 return firstPoint.time;
640             return secondPoint.time;
641         }
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)),
650                 range: range,
651                 left: null,
652                 right: null,
653                 rowIndex: null,
654                 top: null,
655                 bottom: null,
656                 linkRoute: linkRoute,
657                 linkId: range.get('id'),
658                 label: range.get('label'),
659             });
660         }));
661
662         this._updateRangeBarRects();
663     }.observes('ranges'),
664     _updateRangeBarRects: function () {
665         var rangeBars = this.get('rangeBars');
666         if (!rangeBars || !rangeBars.length)
667             return;
668
669         var xScale = this._x;
670         var yScale = this._y;
671
672         // Expand the width of each range as needed and sort ranges by the left-edge of ranges.
673         var minWidth = 3;
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;
680             }
681             bar.set('left', left);
682             bar.set('right', right);
683             return bar;
684         }).sort(function (first, second) { return first.get('left') - second.get('left'); });
685
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.
688         var rows = [];
689         sortedBars.forEach(function (bar) {
690             var rowIndex = 0;
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);
695                     break;
696                 }
697             }
698             if (rowIndex >= rows.length)
699                 rows.push([bar]);
700             bar.set('rowIndex', rowIndex);
701         });
702         var rowHeight = 0.6 * this._rem;
703         var firstRowTop = this._contentHeight - rows.length * rowHeight;
704         var barHeight = 0.5 * this._rem;
705
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',
711             overflow: 'hidden',
712             position: 'absolute',
713         });
714
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;');
722         });
723     },
724     _updateCurrentItemIndicators: function ()
725     {
726         if (!this._currentItemLine)
727             return;
728
729         var item = this._currentTimeSeriesData[this._currentItemIndex];
730         if (!item) {
731             this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
732             this._currentItemCircle.attr("cx", -1000);
733             return;
734         }
735
736         var x = this._x(item.time);
737         var y = this._y(item.value);
738
739         this._currentItemLine
740             .attr("x1", x)
741             .attr("x2", x);
742
743         this._currentItemCircle
744             .attr("cx", x)
745             .attr("cy", y);
746     },
747     _setCurrentSelection: function (newSelection)
748     {
749         if (this._brushExtent === newSelection)
750             return;
751
752         var points = null;
753         if (newSelection) {
754             points = this._currentTimeSeriesData
755                 .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
756             if (!points.length)
757                 points = null;
758         }
759
760         this._brushExtent = newSelection;
761         this._setCurrentItem(undefined);
762         this._updateSelectionToolbar();
763
764         if (!App.domainsAreEqual(this.get('selection'), newSelection))
765             this.set('selection', newSelection);
766         this.set('selectedPoints', points);
767     },
768     _updateSelectionToolbar: function ()
769     {
770         if (!this.get('zoomable'))
771             return;
772
773         var selection = this._currentSelection();
774         var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
775         if (selection) {
776             var left = this._x(selection[0]);
777             var right = this._x(selection[1]);
778             selectionToolbar
779                 .css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
780                 .show();
781         } else
782             selectionToolbar.hide();
783     },
784     actions: {
785         zoom: function ()
786         {
787             this.sendAction('zoom', this._currentSelection());
788             this.set('selection', null);
789         },
790         openRange: function (range)
791         {
792             this.sendAction('openRange', range);
793         },
794     },
795 });