Upgrade to ExtJS 4.0.2 - Released 06/09/2011
[extjs.git] / src / chart / series / Area.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.Area
17  * @extends Ext.chart.series.Cartesian
18  * 
19  <p>
20     Creates a Stacked Area Chart. The stacked area chart is useful when displaying multiple aggregated layers of information.
21     As with all other series, the Area Series must be appended in the *series* Chart array configuration. See the Chart 
22     documentation for more information. A typical configuration object for the area series could be:
23  </p>
24 {@img Ext.chart.series.Area/Ext.chart.series.Area.png Ext.chart.series.Area chart series} 
25   <pre><code>
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         store: store,
42         axes: [{
43             type: 'Numeric',
44             grid: true,
45             position: 'left',
46             fields: ['data1', 'data2', 'data3', 'data4', 'data5'],
47             title: 'Sample Values',
48             grid: {
49                 odd: {
50                     opacity: 1,
51                     fill: '#ddd',
52                     stroke: '#bbb',
53                     'stroke-width': 1
54                 }
55             },
56             minimum: 0,
57             adjustMinimumByMajorUnit: 0
58         }, {
59             type: 'Category',
60             position: 'bottom',
61             fields: ['name'],
62             title: 'Sample Metrics',
63             grid: true,
64             label: {
65                 rotate: {
66                     degrees: 315
67                 }
68             }
69         }],
70         series: [{
71             type: 'area',
72             highlight: false,
73             axis: 'left',
74             xField: 'name',
75             yField: ['data1', 'data2', 'data3', 'data4', 'data5'],
76             style: {
77                 opacity: 0.93
78             }
79         }]
80     });
81    </code></pre>
82  
83   
84  <p>
85   In this configuration we set `area` as the type for the series, set highlighting options to true for highlighting elements on hover, 
86   take the left axis to measure the data in the area series, set as xField (x values) the name field of each element in the store, 
87   and as yFields (aggregated layers) seven data fields from the same store. Then we override some theming styles by adding some opacity 
88   to the style object.
89  </p>
90   
91  * @xtype area
92  * 
93  */
94 Ext.define('Ext.chart.series.Area', {
95
96     /* Begin Definitions */
97
98     extend: 'Ext.chart.series.Cartesian',
99     
100     alias: 'series.area',
101
102     requires: ['Ext.chart.axis.Axis', 'Ext.draw.Color', 'Ext.fx.Anim'],
103
104     /* End Definitions */
105
106     type: 'area',
107
108     // @private Area charts are alyways stacked
109     stacked: true,
110
111     /**
112      * @cfg {Object} style 
113      * Append styling properties to this object for it to override theme properties.
114      */
115     style: {},
116
117     constructor: function(config) {
118         this.callParent(arguments);
119         var me = this,
120             surface = me.chart.surface,
121             i, l;
122         Ext.apply(me, config, {
123             __excludes: [],
124             highlightCfg: {
125                 lineWidth: 3,
126                 stroke: '#55c',
127                 opacity: 0.8,
128                 color: '#f00'
129             }
130         });
131         if (me.highlight) {
132             me.highlightSprite = surface.add({
133                 type: 'path',
134                 path: ['M', 0, 0],
135                 zIndex: 1000,
136                 opacity: 0.3,
137                 lineWidth: 5,
138                 hidden: true,
139                 stroke: '#444'
140             });
141         }
142         me.group = surface.getGroup(me.seriesId);
143     },
144
145     // @private Shrinks dataSets down to a smaller size
146     shrink: function(xValues, yValues, size) {
147         var len = xValues.length,
148             ratio = Math.floor(len / size),
149             i, j,
150             xSum = 0,
151             yCompLen = this.areas.length,
152             ySum = [],
153             xRes = [],
154             yRes = [];
155         //initialize array
156         for (j = 0; j < yCompLen; ++j) {
157             ySum[j] = 0;
158         }
159         for (i = 0; i < len; ++i) {
160             xSum += xValues[i];
161             for (j = 0; j < yCompLen; ++j) {
162                 ySum[j] += yValues[i][j];
163             }
164             if (i % ratio == 0) {
165                 //push averages
166                 xRes.push(xSum/ratio);
167                 for (j = 0; j < yCompLen; ++j) {
168                     ySum[j] /= ratio;
169                 }
170                 yRes.push(ySum);
171                 //reset sum accumulators
172                 xSum = 0;
173                 for (j = 0, ySum = []; j < yCompLen; ++j) {
174                     ySum[j] = 0;
175                 }
176             }
177         }
178         return {
179             x: xRes,
180             y: yRes
181         };
182     },
183
184     // @private Get chart and data boundaries
185     getBounds: function() {
186         var me = this,
187             chart = me.chart,
188             store = chart.substore || chart.store,
189             areas = [].concat(me.yField),
190             areasLen = areas.length,
191             xValues = [],
192             yValues = [],
193             infinity = Infinity,
194             minX = infinity,
195             minY = infinity,
196             maxX = -infinity,
197             maxY = -infinity,
198             math = Math,
199             mmin = math.min,
200             mmax = math.max,
201             bbox, xScale, yScale, xValue, yValue, areaIndex, acumY, ln, sumValues, clipBox, areaElem;
202
203         me.setBBox();
204         bbox = me.bbox;
205
206         // Run through the axis
207         if (me.axis) {
208             axis = chart.axes.get(me.axis);
209             if (axis) {
210                 out = axis.calcEnds();
211                 minY = out.from || axis.prevMin;
212                 maxY = mmax(out.to || axis.prevMax, 0);
213             }
214         }
215
216         if (me.yField && !Ext.isNumber(minY)) {
217             axis = Ext.create('Ext.chart.axis.Axis', {
218                 chart: chart,
219                 fields: [].concat(me.yField)
220             });
221             out = axis.calcEnds();
222             minY = out.from || axis.prevMin;
223             maxY = mmax(out.to || axis.prevMax, 0);
224         }
225
226         if (!Ext.isNumber(minY)) {
227             minY = 0;
228         }
229         if (!Ext.isNumber(maxY)) {
230             maxY = 0;
231         }
232
233         store.each(function(record, i) {
234             xValue = record.get(me.xField);
235             yValue = [];
236             if (typeof xValue != 'number') {
237                 xValue = i;
238             }
239             xValues.push(xValue);
240             acumY = 0;
241             for (areaIndex = 0; areaIndex < areasLen; areaIndex++) {
242                 areaElem = record.get(areas[areaIndex]);
243                 if (typeof areaElem == 'number') {
244                     minY = mmin(minY, areaElem);
245                     yValue.push(areaElem);
246                     acumY += areaElem;
247                 }
248             }
249             minX = mmin(minX, xValue);
250             maxX = mmax(maxX, xValue);
251             maxY = mmax(maxY, acumY);
252             yValues.push(yValue);
253         }, me);
254
255         xScale = bbox.width / (maxX - minX);
256         yScale = bbox.height / (maxY - minY);
257
258         ln = xValues.length;
259         if ((ln > bbox.width) && me.areas) {
260             sumValues = me.shrink(xValues, yValues, bbox.width);
261             xValues = sumValues.x;
262             yValues = sumValues.y;
263         }
264
265         return {
266             bbox: bbox,
267             minX: minX,
268             minY: minY,
269             xValues: xValues,
270             yValues: yValues,
271             xScale: xScale,
272             yScale: yScale,
273             areasLen: areasLen
274         };
275     },
276
277     // @private Build an array of paths for the chart
278     getPaths: function() {
279         var me = this,
280             chart = me.chart,
281             store = chart.substore || chart.store,
282             first = true,
283             bounds = me.getBounds(),
284             bbox = bounds.bbox,
285             items = me.items = [],
286             componentPaths = [],
287             componentPath,
288             paths = [],
289             i, ln, x, y, xValue, yValue, acumY, areaIndex, prevAreaIndex, areaElem, path;
290
291         ln = bounds.xValues.length;
292         // Start the path
293         for (i = 0; i < ln; i++) {
294             xValue = bounds.xValues[i];
295             yValue = bounds.yValues[i];
296             x = bbox.x + (xValue - bounds.minX) * bounds.xScale;
297             acumY = 0;
298             for (areaIndex = 0; areaIndex < bounds.areasLen; areaIndex++) {
299                 // Excluded series
300                 if (me.__excludes[areaIndex]) {
301                     continue;
302                 }
303                 if (!componentPaths[areaIndex]) {
304                     componentPaths[areaIndex] = [];
305                 }
306                 areaElem = yValue[areaIndex];
307                 acumY += areaElem;
308                 y = bbox.y + bbox.height - (acumY - bounds.minY) * bounds.yScale;
309                 if (!paths[areaIndex]) {
310                     paths[areaIndex] = ['M', x, y];
311                     componentPaths[areaIndex].push(['L', x, y]);
312                 } else {
313                     paths[areaIndex].push('L', x, y);
314                     componentPaths[areaIndex].push(['L', x, y]);
315                 }
316                 if (!items[areaIndex]) {
317                     items[areaIndex] = {
318                         pointsUp: [],
319                         pointsDown: [],
320                         series: me
321                     };
322                 }
323                 items[areaIndex].pointsUp.push([x, y]);
324             }
325         }
326         
327         // Close the paths
328         for (areaIndex = 0; areaIndex < bounds.areasLen; areaIndex++) {
329             // Excluded series
330             if (me.__excludes[areaIndex]) {
331                 continue;
332             }
333             path = paths[areaIndex];
334             // Close bottom path to the axis
335             if (areaIndex == 0 || first) {
336                 first = false;
337                 path.push('L', x, bbox.y + bbox.height,
338                           'L', bbox.x, bbox.y + bbox.height,
339                           'Z');
340             }
341             // Close other paths to the one before them
342             else {
343                 componentPath = componentPaths[prevAreaIndex];
344                 componentPath.reverse();
345                 path.push('L', x, componentPath[0][2]);
346                 for (i = 0; i < ln; i++) {
347                     path.push(componentPath[i][0],
348                               componentPath[i][1],
349                               componentPath[i][2]);
350                     items[areaIndex].pointsDown[ln -i -1] = [componentPath[i][1], componentPath[i][2]];
351                 }
352                 path.push('L', bbox.x, path[2], 'Z');
353             }
354             prevAreaIndex = areaIndex;
355         }
356         return {
357             paths: paths,
358             areasLen: bounds.areasLen
359         };
360     },
361
362     /**
363      * Draws the series for the current chart.
364      */
365     drawSeries: function() {
366         var me = this,
367             chart = me.chart,
368             store = chart.substore || chart.store,
369             surface = chart.surface,
370             animate = chart.animate,
371             group = me.group,
372             endLineStyle = Ext.apply(me.seriesStyle, me.style),
373             colorArrayStyle = me.colorArrayStyle,
374             colorArrayLength = colorArrayStyle && colorArrayStyle.length || 0,
375             areaIndex, areaElem, paths, path, rendererAttributes;
376
377         me.unHighlightItem();
378         me.cleanHighlights();
379
380         if (!store || !store.getCount()) {
381             return;
382         }
383         
384         paths = me.getPaths();
385
386         if (!me.areas) {
387             me.areas = [];
388         }
389
390         for (areaIndex = 0; areaIndex < paths.areasLen; areaIndex++) {
391             // Excluded series
392             if (me.__excludes[areaIndex]) {
393                 continue;
394             }
395             if (!me.areas[areaIndex]) {
396                 me.items[areaIndex].sprite = me.areas[areaIndex] = surface.add(Ext.apply({}, {
397                     type: 'path',
398                     group: group,
399                     // 'clip-rect': me.clipBox,
400                     path: paths.paths[areaIndex],
401                     stroke: endLineStyle.stroke || colorArrayStyle[areaIndex % colorArrayLength],
402                     fill: colorArrayStyle[areaIndex % colorArrayLength]
403                 }, endLineStyle || {}));
404             }
405             areaElem = me.areas[areaIndex];
406             path = paths.paths[areaIndex];
407             if (animate) {
408                 //Add renderer to line. There is not a unique record associated with this.
409                 rendererAttributes = me.renderer(areaElem, false, { 
410                     path: path,
411                     // 'clip-rect': me.clipBox,
412                     fill: colorArrayStyle[areaIndex % colorArrayLength],
413                     stroke: endLineStyle.stroke || colorArrayStyle[areaIndex % colorArrayLength]
414                 }, areaIndex, store);
415                 //fill should not be used here but when drawing the special fill path object
416                 me.animation = me.onAnimate(areaElem, {
417                     to: rendererAttributes
418                 });
419             } else {
420                 rendererAttributes = me.renderer(areaElem, false, { 
421                     path: path,
422                     // 'clip-rect': me.clipBox,
423                     hidden: false,
424                     fill: colorArrayStyle[areaIndex % colorArrayLength],
425                     stroke: endLineStyle.stroke || colorArrayStyle[areaIndex % colorArrayLength]
426                 }, areaIndex, store);
427                 me.areas[areaIndex].setAttributes(rendererAttributes, true);
428             }
429         }
430         me.renderLabels();
431         me.renderCallouts();
432     },
433
434     // @private
435     onAnimate: function(sprite, attr) {
436         sprite.show();
437         return this.callParent(arguments);
438     },
439
440     // @private
441     onCreateLabel: function(storeItem, item, i, display) {
442         var me = this,
443             group = me.labelsGroup,
444             config = me.label,
445             bbox = me.bbox,
446             endLabelStyle = Ext.apply(config, me.seriesLabelStyle);
447
448         return me.chart.surface.add(Ext.apply({
449             'type': 'text',
450             'text-anchor': 'middle',
451             'group': group,
452             'x': item.point[0],
453             'y': bbox.y + bbox.height / 2
454         }, endLabelStyle || {}));
455     },
456
457     // @private
458     onPlaceLabel: function(label, storeItem, item, i, display, animate, index) {
459         var me = this,
460             chart = me.chart,
461             resizing = chart.resizing,
462             config = me.label,
463             format = config.renderer,
464             field = config.field,
465             bbox = me.bbox,
466             x = item.point[0],
467             y = item.point[1],
468             bb, width, height;
469         
470         label.setAttributes({
471             text: format(storeItem.get(field[index])),
472             hidden: true
473         }, true);
474         
475         bb = label.getBBox();
476         width = bb.width / 2;
477         height = bb.height / 2;
478         
479         x = x - width < bbox.x? bbox.x + width : x;
480         x = (x + width > bbox.x + bbox.width) ? (x - (x + width - bbox.x - bbox.width)) : x;
481         y = y - height < bbox.y? bbox.y + height : y;
482         y = (y + height > bbox.y + bbox.height) ? (y - (y + height - bbox.y - bbox.height)) : y;
483
484         if (me.chart.animate && !me.chart.resizing) {
485             label.show(true);
486             me.onAnimate(label, {
487                 to: {
488                     x: x,
489                     y: y
490                 }
491             });
492         } else {
493             label.setAttributes({
494                 x: x,
495                 y: y
496             }, true);
497             if (resizing) {
498                 me.animation.on('afteranimate', function() {
499                     label.show(true);
500                 });
501             } else {
502                 label.show(true);
503             }
504         }
505     },
506
507     // @private
508     onPlaceCallout : function(callout, storeItem, item, i, display, animate, index) {
509         var me = this,
510             chart = me.chart,
511             surface = chart.surface,
512             resizing = chart.resizing,
513             config = me.callouts,
514             items = me.items,
515             prev = (i == 0) ? false : items[i -1].point,
516             next = (i == items.length -1) ? false : items[i +1].point,
517             cur = item.point,
518             dir, norm, normal, a, aprev, anext,
519             bbox = callout.label.getBBox(),
520             offsetFromViz = 30,
521             offsetToSide = 10,
522             offsetBox = 3,
523             boxx, boxy, boxw, boxh,
524             p, clipRect = me.clipRect,
525             x, y;
526
527         //get the right two points
528         if (!prev) {
529             prev = cur;
530         }
531         if (!next) {
532             next = cur;
533         }
534         a = (next[1] - prev[1]) / (next[0] - prev[0]);
535         aprev = (cur[1] - prev[1]) / (cur[0] - prev[0]);
536         anext = (next[1] - cur[1]) / (next[0] - cur[0]);
537         
538         norm = Math.sqrt(1 + a * a);
539         dir = [1 / norm, a / norm];
540         normal = [-dir[1], dir[0]];
541         
542         //keep the label always on the outer part of the "elbow"
543         if (aprev > 0 && anext < 0 && normal[1] < 0 || aprev < 0 && anext > 0 && normal[1] > 0) {
544             normal[0] *= -1;
545             normal[1] *= -1;
546         } else if (Math.abs(aprev) < Math.abs(anext) && normal[0] < 0 || Math.abs(aprev) > Math.abs(anext) && normal[0] > 0) {
547             normal[0] *= -1;
548             normal[1] *= -1;
549         }
550
551         //position
552         x = cur[0] + normal[0] * offsetFromViz;
553         y = cur[1] + normal[1] * offsetFromViz;
554         
555         //box position and dimensions
556         boxx = x + (normal[0] > 0? 0 : -(bbox.width + 2 * offsetBox));
557         boxy = y - bbox.height /2 - offsetBox;
558         boxw = bbox.width + 2 * offsetBox;
559         boxh = bbox.height + 2 * offsetBox;
560         
561         //now check if we're out of bounds and invert the normal vector correspondingly
562         //this may add new overlaps between labels (but labels won't be out of bounds).
563         if (boxx < clipRect[0] || (boxx + boxw) > (clipRect[0] + clipRect[2])) {
564             normal[0] *= -1;
565         }
566         if (boxy < clipRect[1] || (boxy + boxh) > (clipRect[1] + clipRect[3])) {
567             normal[1] *= -1;
568         }
569
570         //update positions
571         x = cur[0] + normal[0] * offsetFromViz;
572         y = cur[1] + normal[1] * offsetFromViz;
573         
574         //update box position and dimensions
575         boxx = x + (normal[0] > 0? 0 : -(bbox.width + 2 * offsetBox));
576         boxy = y - bbox.height /2 - offsetBox;
577         boxw = bbox.width + 2 * offsetBox;
578         boxh = bbox.height + 2 * offsetBox;
579         
580         //set the line from the middle of the pie to the box.
581         callout.lines.setAttributes({
582             path: ["M", cur[0], cur[1], "L", x, y, "Z"]
583         }, true);
584         //set box position
585         callout.box.setAttributes({
586             x: boxx,
587             y: boxy,
588             width: boxw,
589             height: boxh
590         }, true);
591         //set text position
592         callout.label.setAttributes({
593             x: x + (normal[0] > 0? offsetBox : -(bbox.width + offsetBox)),
594             y: y
595         }, true);
596         for (p in callout) {
597             callout[p].show(true);
598         }
599     },
600     
601     isItemInPoint: function(x, y, item, i) {
602         var me = this,
603             pointsUp = item.pointsUp,
604             pointsDown = item.pointsDown,
605             abs = Math.abs,
606             dist = Infinity, p, pln, point;
607         
608         for (p = 0, pln = pointsUp.length; p < pln; p++) {
609             point = [pointsUp[p][0], pointsUp[p][1]];
610             if (dist > abs(x - point[0])) {
611                 dist = abs(x - point[0]);
612             } else {
613                 point = pointsUp[p -1];
614                 if (y >= point[1] && (!pointsDown.length || y <= (pointsDown[p -1][1]))) {
615                     item.storeIndex = p -1;
616                     item.storeField = me.yField[i];
617                     item.storeItem = me.chart.store.getAt(p -1);
618                     item._points = pointsDown.length? [point, pointsDown[p -1]] : [point];
619                     return true;
620                 } else {
621                     break;
622                 }
623             }
624         }
625         return false;
626     },
627
628     /**
629      * Highlight this entire series.
630      * @param {Object} item Info about the item; same format as returned by #getItemForPoint.
631      */
632     highlightSeries: function() {
633         var area, to, fillColor;
634         if (this._index !== undefined) {
635             area = this.areas[this._index];
636             if (area.__highlightAnim) {
637                 area.__highlightAnim.paused = true;
638             }
639             area.__highlighted = true;
640             area.__prevOpacity = area.__prevOpacity || area.attr.opacity || 1;
641             area.__prevFill = area.__prevFill || area.attr.fill;
642             area.__prevLineWidth = area.__prevLineWidth || area.attr.lineWidth;
643             fillColor = Ext.draw.Color.fromString(area.__prevFill);
644             to = {
645                 lineWidth: (area.__prevLineWidth || 0) + 2
646             };
647             if (fillColor) {
648                 to.fill = fillColor.getLighter(0.2).toString();
649             }
650             else {
651                 to.opacity = Math.max(area.__prevOpacity - 0.3, 0);
652             }
653             if (this.chart.animate) {
654                 area.__highlightAnim = Ext.create('Ext.fx.Anim', Ext.apply({
655                     target: area,
656                     to: to
657                 }, this.chart.animate));
658             }
659             else {
660                 area.setAttributes(to, true);
661             }
662         }
663     },
664
665     /**
666      * UnHighlight this entire series.
667      * @param {Object} item Info about the item; same format as returned by #getItemForPoint.
668      */
669     unHighlightSeries: function() {
670         var area;
671         if (this._index !== undefined) {
672             area = this.areas[this._index];
673             if (area.__highlightAnim) {
674                 area.__highlightAnim.paused = true;
675             }
676             if (area.__highlighted) {
677                 area.__highlighted = false;
678                 area.__highlightAnim = Ext.create('Ext.fx.Anim', {
679                     target: area,
680                     to: {
681                         fill: area.__prevFill,
682                         opacity: area.__prevOpacity,
683                         lineWidth: area.__prevLineWidth
684                     }
685                 });
686             }
687         }
688     },
689
690     /**
691      * Highlight the specified item. If no item is provided the whole series will be highlighted.
692      * @param item {Object} Info about the item; same format as returned by #getItemForPoint
693      */
694     highlightItem: function(item) {
695         var me = this,
696             points, path;
697         if (!item) {
698             this.highlightSeries();
699             return;
700         }
701         points = item._points;
702         path = points.length == 2? ['M', points[0][0], points[0][1], 'L', points[1][0], points[1][1]]
703                 : ['M', points[0][0], points[0][1], 'L', points[0][0], me.bbox.y + me.bbox.height];
704         me.highlightSprite.setAttributes({
705             path: path,
706             hidden: false
707         }, true);
708     },
709
710     /**
711      * un-highlights the specified item. If no item is provided it will un-highlight the entire series.
712      * @param item {Object} Info about the item; same format as returned by #getItemForPoint
713      */
714     unHighlightItem: function(item) {
715         if (!item) {
716             this.unHighlightSeries();
717         }
718
719         if (this.highlightSprite) {
720             this.highlightSprite.hide(true);
721         }
722     },
723
724     // @private
725     hideAll: function() {
726         if (!isNaN(this._index)) {
727             this.__excludes[this._index] = true;
728             this.areas[this._index].hide(true);
729             this.drawSeries();
730         }
731     },
732
733     // @private
734     showAll: function() {
735         if (!isNaN(this._index)) {
736             this.__excludes[this._index] = false;
737             this.areas[this._index].show(true);
738             this.drawSeries();
739         }
740     },
741
742     /**
743      * Returns the color of the series (to be displayed as color for the series legend item).
744      * @param item {Object} Info about the item; same format as returned by #getItemForPoint
745      */
746     getLegendColor: function(index) {
747         var me = this;
748         return me.colorArrayStyle[index % me.colorArrayStyle.length];
749     }
750 });
751