Upgrade to ExtJS 4.0.2 - Released 06/09/2011
[extjs.git] / src / chart / series / Scatter.js
1 /*
2
3 This file is part of Ext JS 4
4
5 Copyright (c) 2011 Sencha Inc
6
7 Contact:  http://www.sencha.com/contact
8
9 GNU General Public License Usage
10 This file may be used under the terms of the GNU General Public License version 3.0 as published by the Free Software Foundation and appearing in the file LICENSE included in the packaging of this file.  Please review the following information to ensure the GNU General Public License version 3.0 requirements will be met: http://www.gnu.org/copyleft/gpl.html.
11
12 If you are unsure which license is appropriate for your use, please contact the sales department at http://www.sencha.com/contact.
13
14 */
15 /**
16  * @class Ext.chart.series.Scatter
17  * @extends Ext.chart.series.Cartesian
18  * 
19  * Creates a Scatter Chart. The scatter plot is useful when trying to display more than two variables in the same visualization. 
20  * These variables can be mapped into x, y coordinates and also to an element's radius/size, color, etc.
21  * As with all other series, the Scatter Series must be appended in the *series* Chart array configuration. See the Chart 
22  * documentation for more information on creating charts. A typical configuration object for the scatter could be:
23  *
24  * {@img Ext.chart.series.Scatter/Ext.chart.series.Scatter.png Ext.chart.series.Scatter chart series}  
25  *
26  *     var store = Ext.create('Ext.data.JsonStore', {
27  *         fields: ['name', 'data1', 'data2', 'data3', 'data4', 'data5'],
28  *         data: [
29  *             {'name':'metric one', 'data1':10, 'data2':12, 'data3':14, 'data4':8, 'data5':13},
30  *             {'name':'metric two', 'data1':7, 'data2':8, 'data3':16, 'data4':10, 'data5':3},
31  *             {'name':'metric three', 'data1':5, 'data2':2, 'data3':14, 'data4':12, 'data5':7},
32  *             {'name':'metric four', 'data1':2, 'data2':14, 'data3':6, 'data4':1, 'data5':23},
33  *             {'name':'metric five', 'data1':27, 'data2':38, 'data3':36, 'data4':13, 'data5':33}                                                
34  *         ]
35  *     });
36  *     
37  *     Ext.create('Ext.chart.Chart', {
38  *         renderTo: Ext.getBody(),
39  *         width: 500,
40  *         height: 300,
41  *         animate: true,
42  *         theme:'Category2',
43  *         store: store,
44  *         axes: [{
45  *             type: 'Numeric',
46  *             position: 'bottom',
47  *             fields: ['data1', 'data2', 'data3'],
48  *             title: 'Sample Values',
49  *             grid: true,
50  *             minimum: 0
51  *         }, {
52  *             type: 'Category',
53  *             position: 'left',
54  *             fields: ['name'],
55  *             title: 'Sample Metrics'
56  *         }],
57  *         series: [{
58  *             type: 'scatter',
59  *             markerConfig: {
60  *                 radius: 5,
61  *                 size: 5
62  *             },
63  *             axis: 'left',
64  *             xField: 'name',
65  *             yField: 'data2'
66  *         }, {
67  *             type: 'scatter',
68  *             markerConfig: {
69  *                 radius: 5,
70  *                 size: 5
71  *             },
72  *             axis: 'left',
73  *             xField: 'name',
74  *             yField: 'data3'
75  *         }]   
76  *     });
77  * 
78  * In this configuration we add three different categories of scatter series. Each of them is bound to a different field of the same data store, 
79  * `data1`, `data2` and `data3` respectively. All x-fields for the series must be the same field, in this case `name`. 
80  * Each scatter series has a different styling configuration for markers, specified by the `markerConfig` object. Finally we set the left axis as 
81  * axis to show the current values of the elements.
82  * 
83  * @xtype scatter
84  */
85 Ext.define('Ext.chart.series.Scatter', {
86
87     /* Begin Definitions */
88
89     extend: 'Ext.chart.series.Cartesian',
90
91     requires: ['Ext.chart.axis.Axis', 'Ext.chart.Shape', 'Ext.fx.Anim'],
92
93     /* End Definitions */
94
95     type: 'scatter',
96     alias: 'series.scatter',
97
98     /**
99      * @cfg {Object} markerConfig
100      * The display style for the scatter series markers.
101      */
102     
103     /**
104      * @cfg {Object} style 
105      * Append styling properties to this object for it to override theme properties.
106      */
107
108     constructor: function(config) {
109         this.callParent(arguments);
110         var me = this,
111             shadow = me.chart.shadow,
112             surface = me.chart.surface, i, l;
113         Ext.apply(me, config, {
114             style: {},
115             markerConfig: {},
116             shadowAttributes: [{
117                 "stroke-width": 6,
118                 "stroke-opacity": 0.05,
119                 stroke: 'rgb(0, 0, 0)'
120             }, {
121                 "stroke-width": 4,
122                 "stroke-opacity": 0.1,
123                 stroke: 'rgb(0, 0, 0)'
124             }, {
125                 "stroke-width": 2,
126                 "stroke-opacity": 0.15,
127                 stroke: 'rgb(0, 0, 0)'
128             }]
129         });
130         me.group = surface.getGroup(me.seriesId);
131         if (shadow) {
132             for (i = 0, l = me.shadowAttributes.length; i < l; i++) {
133                 me.shadowGroups.push(surface.getGroup(me.seriesId + '-shadows' + i));
134             }
135         }
136     },
137
138     // @private Get chart and data boundaries
139     getBounds: function() {
140         var me = this,
141             chart = me.chart,
142             store = chart.substore || chart.store,
143             axes = [].concat(me.axis),
144             bbox, xScale, yScale, ln, minX, minY, maxX, maxY, i, axis, ends;
145
146         me.setBBox();
147         bbox = me.bbox;
148
149         for (i = 0, ln = axes.length; i < ln; i++) { 
150             axis = chart.axes.get(axes[i]);
151             if (axis) {
152                 ends = axis.calcEnds();
153                 if (axis.position == 'top' || axis.position == 'bottom') {
154                     minX = ends.from;
155                     maxX = ends.to;
156                 }
157                 else {
158                     minY = ends.from;
159                     maxY = ends.to;
160                 }
161             }
162         }
163         // If a field was specified without a corresponding axis, create one to get bounds
164         if (me.xField && !Ext.isNumber(minX)) {
165             axis = Ext.create('Ext.chart.axis.Axis', {
166                 chart: chart,
167                 fields: [].concat(me.xField)
168             }).calcEnds();
169             minX = axis.from;
170             maxX = axis.to;
171         }
172         if (me.yField && !Ext.isNumber(minY)) {
173             axis = Ext.create('Ext.chart.axis.Axis', {
174                 chart: chart,
175                 fields: [].concat(me.yField)
176             }).calcEnds();
177             minY = axis.from;
178             maxY = axis.to;
179         }
180
181         if (isNaN(minX)) {
182             minX = 0;
183             maxX = store.getCount() - 1;
184             xScale = bbox.width / (store.getCount() - 1);
185         }
186         else {
187             xScale = bbox.width / (maxX - minX);
188         }
189
190         if (isNaN(minY)) {
191             minY = 0;
192             maxY = store.getCount() - 1;
193             yScale = bbox.height / (store.getCount() - 1);
194         } 
195         else {
196             yScale = bbox.height / (maxY - minY);
197         }
198
199         return {
200             bbox: bbox,
201             minX: minX,
202             minY: minY,
203             xScale: xScale,
204             yScale: yScale
205         };
206     },
207
208     // @private Build an array of paths for the chart
209     getPaths: function() {
210         var me = this,
211             chart = me.chart,
212             enableShadows = chart.shadow,
213             store = chart.substore || chart.store,
214             group = me.group,
215             bounds = me.bounds = me.getBounds(),
216             bbox = me.bbox,
217             xScale = bounds.xScale,
218             yScale = bounds.yScale,
219             minX = bounds.minX,
220             minY = bounds.minY,
221             boxX = bbox.x,
222             boxY = bbox.y,
223             boxHeight = bbox.height,
224             items = me.items = [],
225             attrs = [],
226             x, y, xValue, yValue, sprite;
227
228         store.each(function(record, i) {
229             xValue = record.get(me.xField);
230             yValue = record.get(me.yField);
231             //skip undefined values
232             if (typeof yValue == 'undefined' || (typeof yValue == 'string' && !yValue)) {
233                 //<debug warn>
234                 if (Ext.isDefined(Ext.global.console)) {
235                     Ext.global.console.warn("[Ext.chart.series.Scatter]  Skipping a store element with an undefined value at ", record, xValue, yValue);
236                 }
237                 //</debug>
238                 return;
239             }
240             // Ensure a value
241             if (typeof xValue == 'string' || typeof xValue == 'object') {
242                 xValue = i;
243             }
244             if (typeof yValue == 'string' || typeof yValue == 'object') {
245                 yValue = i;
246             }
247             x = boxX + (xValue - minX) * xScale;
248             y = boxY + boxHeight - (yValue - minY) * yScale;
249             attrs.push({
250                 x: x,
251                 y: y
252             });
253
254             me.items.push({
255                 series: me,
256                 value: [xValue, yValue],
257                 point: [x, y],
258                 storeItem: record
259             });
260
261             // When resizing, reset before animating
262             if (chart.animate && chart.resizing) {
263                 sprite = group.getAt(i);
264                 if (sprite) {
265                     me.resetPoint(sprite);
266                     if (enableShadows) {
267                         me.resetShadow(sprite);
268                     }
269                 }
270             }
271         });
272         return attrs;
273     },
274
275     // @private translate point to the center
276     resetPoint: function(sprite) {
277         var bbox = this.bbox;
278         sprite.setAttributes({
279             translate: {
280                 x: (bbox.x + bbox.width) / 2,
281                 y: (bbox.y + bbox.height) / 2
282             }
283         }, true);
284     },
285
286     // @private translate shadows of a sprite to the center
287     resetShadow: function(sprite) {
288         var me = this,
289             shadows = sprite.shadows,
290             shadowAttributes = me.shadowAttributes,
291             ln = me.shadowGroups.length,
292             bbox = me.bbox,
293             i, attr;
294         for (i = 0; i < ln; i++) {
295             attr = Ext.apply({}, shadowAttributes[i]);
296             if (attr.translate) {
297                 attr.translate.x += (bbox.x + bbox.width) / 2;
298                 attr.translate.y += (bbox.y + bbox.height) / 2;
299             }
300             else {
301                 attr.translate = {
302                     x: (bbox.x + bbox.width) / 2,
303                     y: (bbox.y + bbox.height) / 2
304                 };
305             }
306             shadows[i].setAttributes(attr, true);
307         }
308     },
309
310     // @private create a new point
311     createPoint: function(attr, type) {
312         var me = this,
313             chart = me.chart,
314             group = me.group,
315             bbox = me.bbox;
316
317         return Ext.chart.Shape[type](chart.surface, Ext.apply({}, {
318             x: 0,
319             y: 0,
320             group: group,
321             translate: {
322                 x: (bbox.x + bbox.width) / 2,
323                 y: (bbox.y + bbox.height) / 2
324             }
325         }, attr));
326     },
327
328     // @private create a new set of shadows for a sprite
329     createShadow: function(sprite, endMarkerStyle, type) {
330         var me = this,
331             chart = me.chart,
332             shadowGroups = me.shadowGroups,
333             shadowAttributes = me.shadowAttributes,
334             lnsh = shadowGroups.length,
335             bbox = me.bbox,
336             i, shadow, shadows, attr;
337
338         sprite.shadows = shadows = [];
339
340         for (i = 0; i < lnsh; i++) {
341             attr = Ext.apply({}, shadowAttributes[i]);
342             if (attr.translate) {
343                 attr.translate.x += (bbox.x + bbox.width) / 2;
344                 attr.translate.y += (bbox.y + bbox.height) / 2;
345             }
346             else {
347                 Ext.apply(attr, {
348                     translate: {
349                         x: (bbox.x + bbox.width) / 2,
350                         y: (bbox.y + bbox.height) / 2
351                     }
352                 });
353             }
354             Ext.apply(attr, endMarkerStyle);
355             shadow = Ext.chart.Shape[type](chart.surface, Ext.apply({}, {
356                 x: 0,
357                 y: 0,
358                 group: shadowGroups[i]
359             }, attr));
360             shadows.push(shadow);
361         }
362     },
363
364     /**
365      * Draws the series for the current chart.
366      */
367     drawSeries: function() {
368         var me = this,
369             chart = me.chart,
370             store = chart.substore || chart.store,
371             group = me.group,
372             enableShadows = chart.shadow,
373             shadowGroups = me.shadowGroups,
374             shadowAttributes = me.shadowAttributes,
375             lnsh = shadowGroups.length,
376             sprite, attrs, attr, ln, i, endMarkerStyle, shindex, type, shadows,
377             rendererAttributes, shadowAttribute;
378
379         endMarkerStyle = Ext.apply(me.markerStyle, me.markerConfig);
380         type = endMarkerStyle.type;
381         delete endMarkerStyle.type;
382
383         //if the store is empty then there's nothing to be rendered
384         if (!store || !store.getCount()) {
385             return;
386         }
387
388         me.unHighlightItem();
389         me.cleanHighlights();
390
391         attrs = me.getPaths();
392         ln = attrs.length;
393         for (i = 0; i < ln; i++) {
394             attr = attrs[i];
395             sprite = group.getAt(i);
396             Ext.apply(attr, endMarkerStyle);
397
398             // Create a new sprite if needed (no height)
399             if (!sprite) {
400                 sprite = me.createPoint(attr, type);
401                 if (enableShadows) {
402                     me.createShadow(sprite, endMarkerStyle, type);
403                 }
404             }
405
406             shadows = sprite.shadows;
407             if (chart.animate) {
408                 rendererAttributes = me.renderer(sprite, store.getAt(i), { translate: attr }, i, store);
409                 sprite._to = rendererAttributes;
410                 me.onAnimate(sprite, {
411                     to: rendererAttributes
412                 });
413                 //animate shadows
414                 for (shindex = 0; shindex < lnsh; shindex++) {
415                     shadowAttribute = Ext.apply({}, shadowAttributes[shindex]);
416                     rendererAttributes = me.renderer(shadows[shindex], store.getAt(i), Ext.apply({}, { 
417                         translate: {
418                             x: attr.x + (shadowAttribute.translate? shadowAttribute.translate.x : 0),
419                             y: attr.y + (shadowAttribute.translate? shadowAttribute.translate.y : 0)
420                         } 
421                     }, shadowAttribute), i, store);
422                     me.onAnimate(shadows[shindex], { to: rendererAttributes });
423                 }
424             }
425             else {
426                 rendererAttributes = me.renderer(sprite, store.getAt(i), Ext.apply({ translate: attr }, { hidden: false }), i, store);
427                 sprite.setAttributes(rendererAttributes, true);
428                 //update shadows
429                 for (shindex = 0; shindex < lnsh; shindex++) {
430                     shadowAttribute = shadowAttributes[shindex];
431                     rendererAttributes = me.renderer(shadows[shindex], store.getAt(i), Ext.apply({ 
432                         x: attr.x,
433                         y: attr.y
434                     }, shadowAttribute), i, store);
435                     shadows[shindex].setAttributes(rendererAttributes, true);
436                 }
437             }
438             me.items[i].sprite = sprite;
439         }
440
441         // Hide unused sprites
442         ln = group.getCount();
443         for (i = attrs.length; i < ln; i++) {
444             group.getAt(i).hide(true);
445         }
446         me.renderLabels();
447         me.renderCallouts();
448     },
449     
450     // @private callback for when creating a label sprite.
451     onCreateLabel: function(storeItem, item, i, display) {
452         var me = this,
453             group = me.labelsGroup,
454             config = me.label,
455             endLabelStyle = Ext.apply({}, config, me.seriesLabelStyle),
456             bbox = me.bbox;
457         
458         return me.chart.surface.add(Ext.apply({
459             type: 'text',
460             group: group,
461             x: item.point[0],
462             y: bbox.y + bbox.height / 2
463         }, endLabelStyle));
464     },
465     
466     // @private callback for when placing a label sprite.
467     onPlaceLabel: function(label, storeItem, item, i, display, animate) {
468         var me = this,
469             chart = me.chart,
470             resizing = chart.resizing,
471             config = me.label,
472             format = config.renderer,
473             field = config.field,
474             bbox = me.bbox,
475             x = item.point[0],
476             y = item.point[1],
477             radius = item.sprite.attr.radius,
478             bb, width, height, anim;
479         
480         label.setAttributes({
481             text: format(storeItem.get(field)),
482             hidden: true
483         }, true);
484         
485         if (display == 'rotate') {
486             label.setAttributes({
487                 'text-anchor': 'start',
488                 'rotation': {
489                     x: x,
490                     y: y,
491                     degrees: -45
492                 }
493             }, true);
494             //correct label position to fit into the box
495             bb = label.getBBox();
496             width = bb.width;
497             height = bb.height;
498             x = x < bbox.x? bbox.x : x;
499             x = (x + width > bbox.x + bbox.width)? (x - (x + width - bbox.x - bbox.width)) : x;
500             y = (y - height < bbox.y)? bbox.y + height : y;
501         
502         } else if (display == 'under' || display == 'over') {
503             //TODO(nicolas): find out why width/height values in circle bounding boxes are undefined.
504             bb = item.sprite.getBBox();
505             bb.width = bb.width || (radius * 2);
506             bb.height = bb.height || (radius * 2);
507             y = y + (display == 'over'? -bb.height : bb.height);
508             //correct label position to fit into the box
509             bb = label.getBBox();
510             width = bb.width/2;
511             height = bb.height/2;
512             x = x - width < bbox.x ? bbox.x + width : x;
513             x = (x + width > bbox.x + bbox.width) ? (x - (x + width - bbox.x - bbox.width)) : x;
514             y = y - height < bbox.y? bbox.y + height : y;
515             y = (y + height > bbox.y + bbox.height) ? (y - (y + height - bbox.y - bbox.height)) : y;
516         }
517
518         if (!chart.animate) {
519             label.setAttributes({
520                 x: x,
521                 y: y
522             }, true);
523             label.show(true);
524         }
525         else {
526             if (resizing) {
527                 anim = item.sprite.getActiveAnimation();
528                 if (anim) {
529                     anim.on('afteranimate', function() {
530                         label.setAttributes({
531                             x: x,
532                             y: y
533                         }, true);
534                         label.show(true);
535                     });   
536                 }
537                 else {
538                     label.show(true);
539                 }
540             }
541             else {
542                 me.onAnimate(label, {
543                     to: {
544                         x: x,
545                         y: y
546                     }
547                 });
548             }
549         }
550     },
551     
552     // @private callback for when placing a callout sprite.    
553     onPlaceCallout: function(callout, storeItem, item, i, display, animate, index) {
554         var me = this,
555             chart = me.chart,
556             surface = chart.surface,
557             resizing = chart.resizing,
558             config = me.callouts,
559             items = me.items,
560             cur = item.point,
561             normal,
562             bbox = callout.label.getBBox(),
563             offsetFromViz = 30,
564             offsetToSide = 10,
565             offsetBox = 3,
566             boxx, boxy, boxw, boxh,
567             p, clipRect = me.bbox,
568             x, y;
569     
570         //position
571         normal = [Math.cos(Math.PI /4), -Math.sin(Math.PI /4)];
572         x = cur[0] + normal[0] * offsetFromViz;
573         y = cur[1] + normal[1] * offsetFromViz;
574         
575         //box position and dimensions
576         boxx = x + (normal[0] > 0? 0 : -(bbox.width + 2 * offsetBox));
577         boxy = y - bbox.height /2 - offsetBox;
578         boxw = bbox.width + 2 * offsetBox;
579         boxh = bbox.height + 2 * offsetBox;
580         
581         //now check if we're out of bounds and invert the normal vector correspondingly
582         //this may add new overlaps between labels (but labels won't be out of bounds).
583         if (boxx < clipRect[0] || (boxx + boxw) > (clipRect[0] + clipRect[2])) {
584             normal[0] *= -1;
585         }
586         if (boxy < clipRect[1] || (boxy + boxh) > (clipRect[1] + clipRect[3])) {
587             normal[1] *= -1;
588         }
589     
590         //update positions
591         x = cur[0] + normal[0] * offsetFromViz;
592         y = cur[1] + normal[1] * offsetFromViz;
593         
594         //update box position and dimensions
595         boxx = x + (normal[0] > 0? 0 : -(bbox.width + 2 * offsetBox));
596         boxy = y - bbox.height /2 - offsetBox;
597         boxw = bbox.width + 2 * offsetBox;
598         boxh = bbox.height + 2 * offsetBox;
599         
600         if (chart.animate) {
601             //set the line from the middle of the pie to the box.
602             me.onAnimate(callout.lines, {
603                 to: {
604                     path: ["M", cur[0], cur[1], "L", x, y, "Z"]
605                 }
606             }, true);
607             //set box position
608             me.onAnimate(callout.box, {
609                 to: {
610                     x: boxx,
611                     y: boxy,
612                     width: boxw,
613                     height: boxh
614                 }
615             }, true);
616             //set text position
617             me.onAnimate(callout.label, {
618                 to: {
619                     x: x + (normal[0] > 0? offsetBox : -(bbox.width + offsetBox)),
620                     y: y
621                 }
622             }, true);
623         } else {
624             //set the line from the middle of the pie to the box.
625             callout.lines.setAttributes({
626                 path: ["M", cur[0], cur[1], "L", x, y, "Z"]
627             }, true);
628             //set box position
629             callout.box.setAttributes({
630                 x: boxx,
631                 y: boxy,
632                 width: boxw,
633                 height: boxh
634             }, true);
635             //set text position
636             callout.label.setAttributes({
637                 x: x + (normal[0] > 0? offsetBox : -(bbox.width + offsetBox)),
638                 y: y
639             }, true);
640         }
641         for (p in callout) {
642             callout[p].show(true);
643         }
644     },
645
646     // @private handles sprite animation for the series.
647     onAnimate: function(sprite, attr) {
648         sprite.show();
649         return this.callParent(arguments);
650     },
651
652     isItemInPoint: function(x, y, item) {
653         var point,
654             tolerance = 10,
655             abs = Math.abs;
656
657         function dist(point) {
658             var dx = abs(point[0] - x),
659                 dy = abs(point[1] - y);
660             return Math.sqrt(dx * dx + dy * dy);
661         }
662         point = item.point;
663         return (point[0] - tolerance <= x && point[0] + tolerance >= x &&
664             point[1] - tolerance <= y && point[1] + tolerance >= y);
665     }
666 });
667
668