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