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