/* Copyright (c) 2009 Yahoo! Inc. All rights reserved. The copyrights embodied in the content of this file are licensed under the BSD (revised) open source license */ package com.yahoo.astra.fl.charts { import com.yahoo.astra.fl.charts.events.ChartEvent; import com.yahoo.astra.fl.charts.legend.ILegend; import com.yahoo.astra.fl.charts.legend.LegendItemData; import com.yahoo.astra.fl.charts.series.ICategorySeries; import com.yahoo.astra.fl.charts.series.ILegendItemSeries; import com.yahoo.astra.fl.charts.series.ISeries; import com.yahoo.astra.fl.charts.series.ISeriesItemRenderer; import com.yahoo.astra.fl.utils.UIComponentUtil; import fl.core.InvalidationType; import fl.core.UIComponent; import flash.accessibility.AccessibilityProperties; import flash.display.DisplayObject; import flash.display.Sprite; import flash.events.Event; import flash.events.MouseEvent; import flash.geom.Point; import flash.text.TextFormat; import flash.text.TextFormatAlign; import flash.utils.getDefinitionByName; //-------------------------------------- // Styles //-------------------------------------- /** * The padding that separates the border of the component from its contents, * in pixels. * * @default 10 */ [Style(name="contentPadding", type="Number")] /** * Name of the class to use as the skin for the background and border of the * component. * * @default ChartBackgroundSkin */ [Style(name="backgroundSkin", type="Class")] /** * The default colors for each series. These colors are used for markers, * in most cases, but they may apply to lines, fills, or other graphical * items. * *

An Array of values that correspond to series indices in the data * provider. If the number of values in the Array is less than the number * of series, then the next series will restart at index zero in the style * Array. If the value of this style is an empty Array, then each individual series * will use the default or modified value set on the series itself.

* *

Example: If the seriesColors style is equal to [0xffffff, 0x000000] and there * are three series in the chart's data provider, then the series at index 0 * will have a color of 0xffffff, index 1 will have a color of 0x000000, and * index 2 will have a color of 0xffffff (starting over from the beginning).

* * @default [0x00b8bf, 0x8dd5e7, 0xedff9f, 0xffa928, 0xc0fff6, 0xd00050, 0xc6c6c6, 0xc3eafb, 0xfcffad, 0xcfff83, 0x444444, 0x4d95dd, 0xb8ebff, 0x60558f, 0x737d7e, 0xa64d9a, 0x8e9a9b, 0x803e77] */ [Style(name="seriesColors", type="Array")] /** * The default size of the markers in pixels. The actual drawn size of the * markers could end up being different in some cases. For example, bar charts * and column charts display markers side-by-side, and a chart may need to make * the bars or columns smaller to fit within the required region. * *

An Array of values that correspond to series indices in the data * provider. If the number of values in the Array is less than the number * of series, then the next series will restart at index zero in the style * Array. If the value of this style is an empty Array, then each individual series * will use the default or modified value set on the series itself.

* *

Example: If the seriesMarkerSizes style is equal to [10, 15] and there * are three series in the chart's data provider, then the series at index 0 * will have a marker size of 10, index 1 will have a marker size of 15, and * index 2 will have a marker size of 10 (starting over from the beginning).

* * @default [] */ [Style(name="seriesMarkerSizes", type="Array")] /** * An Array containing the default skin classes for each series. These classes * are used to instantiate the marker skins. The values may be fully-qualified * package and class strings or a reference to the classes themselves. * *

An Array of values that correspond to series indices in the data * provider. If the number of values in the Array is less than the number * of series, then the next series will restart at index zero in the style * Array. If the value of this style is an empty Array, then each individual series * will use the default or modified value set on the series itself.

* *

Example: If the seriesMarkerSkins style is equal to [CircleSkin, DiamondSkin] and there * are three series in the chart's data provider, then the series at index 0 * will have a marker skin of CircleSkin, index 1 will have a marker skin of DiamondSkin, and * index 2 will have a marker skin of CircleSkin (starting over from the beginning).

* * @default [] */ [Style(name="seriesMarkerSkins", type="Array")] /** * The TextFormat object to use to render data tips. * * @default TextFormat("_sans", 11, 0x000000, false, false, false, '', '', TextFormatAlign.LEFT, 0, 0, 0, 0) */ [Style(name="dataTipTextFormat", type="TextFormat")] /** * Name of the class to use as the skin for the background and border of the * chart's data tip. * * @default ChartDataTipBackground */ [Style(name="dataTipBackgroundSkin", type="Class")] /** * If the datatip's content padding is customizable, it will use this value. * The padding that separates the border of the component from its contents, * in pixels. * * @default 6 */ [Style(name="dataTipContentPadding", type="Number")] /** * Determines if data changes should be displayed with animation. * * @default true */ [Style(name="animationEnabled", type="Boolean")] /** * Indicates whether embedded font outlines are used to render the text * field. If this value is true, Flash Player renders the text field by * using embedded font outlines. If this value is false, Flash Player * renders the text field by using device fonts. * * If you set the embedFonts property to true for a text field, you must * specify a font for that text by using the font property of a TextFormat * object that is applied to the text field. If the specified font is not * embedded in the SWF file, the text is not displayed. * * @default false */ [Style(name="embedFonts", type="Boolean")] /** * Functionality common to most charts. Generally, a Chart object * shouldn't be instantiated directly. Instead, a subclass with a concrete * implementation should be used. That subclass generally should implement the * IPlotArea interface. * * @author Josh Tynjala */ public class Chart extends UIComponent { //-------------------------------------- // Class Variables //-------------------------------------- /** * @private */ private static var defaultStyles:Object = { seriesMarkerSizes: null, seriesMarkerSkins: null, seriesColors: [ 0x00b8bf, 0x8dd5e7, 0xedff9f, 0xffa928, 0xc0fff6, 0xd00050, 0xc6c6c6, 0xc3eafb, 0xfcffad, 0xcfff83, 0x444444, 0x4d95dd, 0xb8ebff, 0x60558f, 0x737d7e, 0xa64d9a, 0x8e9a9b, 0x803e77 ], seriesBorderColors:[], seriesFillColors:[], seriesLineColors:[], seriesBorderAlphas:[1], seriesFillAlphas:[1], seriesLineAlphas:[1], contentPadding: 10, backgroundSkin: "ChartBackground", backgroundColor: 0xffffff, dataTipBackgroundSkin: "ChartDataTipBackground", dataTipContentPadding: 6, dataTipTextFormat: new TextFormat("_sans", 11, 0x000000, false, false, false, '', '', TextFormatAlign.LEFT, 0, 0, 0, 0), animationEnabled: true, embedFonts: false }; /** * @private */ private static const ALL_SERIES_STYLES:Object = { color: "seriesColors", markerSize: "seriesMarkerSizes", markerSkin: "seriesMarkerSkins", borderColor: "seriesBorderColors", fillColor: "seriesFillColors", lineColor: "seriesLineColors", borderAlpha: "seriesBorderAlphas", fillAlpha: "seriesFillAlphas", lineAlpha: "seriesLineAlphas" }; /** * @private */ private static const SHARED_SERIES_STYLES:Object = { animationEnabled: "animationEnabled" }; private static const DATA_TIP_STYLES:Object = { backgroundSkin: "dataTipBackgroundSkin", contentPadding: "dataTipContentPadding", textFormat: "dataTipTextFormat", embedFonts: "embedFonts" }; //-------------------------------------- // Class Methods //-------------------------------------- /** * @private * @copy fl.core.UIComponent#getStyleDefinition() */ public static function getStyleDefinition():Object { return mergeStyles(defaultStyles, UIComponent.getStyleDefinition()); } //-------------------------------------- // Constructor //-------------------------------------- /** * Constructor. */ public function Chart() { super(); this.accessibilityProperties = new AccessibilityProperties(); this.accessibilityProperties.forceSimple = true; this.accessibilityProperties.description = "Chart"; } //-------------------------------------- // Variables and Properties //-------------------------------------- /** * @private * The display object representing the chart background. */ protected var background:DisplayObject; /** * @private * The area where series are drawn. */ protected var content:Sprite; /** * @private * The mouse over data tip that displays information about an item on the chart. */ protected var dataTip:DisplayObject; /** * @private * Storage for the data property. Saves a copy of the unmodified data. */ private var _dataProvider:Object; /** * @private * Modified version of the stored data. */ protected var series:Array = []; [Inspectable(type=Array)] /** * @copy com.yahoo.astra.fl.charts.IChart#dataProvider */ public function get dataProvider():Object { return this.series; } /** * @private */ public function set dataProvider(value:Object):void { if(this._dataProvider != value) { this._dataProvider = value; this.invalidate(InvalidationType.DATA); } } /** * @private * Storage for the defaultSeriesType property. */ private var _defaultSeriesType:Class; /** * When raw data (like an Array of Numbers) is encountered where an * ISeries instance is expected, it will be converted to this default * type. Accepts either a Class instance or a String referencing a * fully-qualified class name. */ public function get defaultSeriesType():Object { return this._defaultSeriesType; } /** * @private */ public function set defaultSeriesType(value:Object):void { if(!value) return; var classDefinition:Class = null; if(value is Class) { classDefinition = value as Class; } else { // borrowed from fl.core.UIComponent#getDisplayObjectInstance() try { classDefinition = getDefinitionByName(value.toString()) as Class; } catch(e:Error) { try { classDefinition = this.loaderInfo.applicationDomain.getDefinition(value.toString()) as Class; } catch (e:Error) { // Nothing } } } this._defaultSeriesType = classDefinition; //no need to redraw. //if the series have already been created, the user probably wanted it that way. //we have no way to tell if the user chose a particular series' type or not anyway. } private var _lastDataTipRenderer:ISeriesItemRenderer; /** * @private * Storage for the dataTipFunction property. */ private var _dataTipFunction:Function = defaultDataTipFunction; /** * If defined, the chart will call the input function to determine the * text displayed in the chart's data tip. The function uses the following * signature: * *

function dataTipFunction(item:Object, index:int, series:ISeries):String

*/ public function get dataTipFunction():Function { return this._dataTipFunction; } /** * @private */ public function set dataTipFunction(value:Function):void { this._dataTipFunction = value; } /** * @private * Storage for the legend property. */ private var _legend:ILegend; /** * The component that will display a human-readable legend for the chart. */ public function get legend():ILegend { return this._legend; } /** * @private */ public function set legend(value:ILegend):void { this._legend = value; this.invalidate(); } //-------------------------------------- // Public Methods //-------------------------------------- /** * Returns the index within this plot area of the input ISeries object. * * @param series a series that is displayed in this plot area. * @return the index of the input series */ public function seriesToIndex(series:ISeries):int { return this.series.indexOf(series); } /** * Returns the ISeries object at the specified index. * * @param index the index of the series to return * @return the series that appears at the input index or null if out of bounds */ public function indexToSeries(index:int):ISeries { if(index < 0 || index >= this.series.length) return null; return this.series[index]; } //-------------------------------------- // Protected Methods //-------------------------------------- /** * @private */ override protected function configUI():void { super.width = 400; super.height = 300; super.configUI(); this.content = new Sprite(); this.addChild(this.content); this.dataTip = new DataTipRenderer(); this.dataTip.visible = false; this.addChild(this.dataTip); } /** * @private */ override protected function draw():void { var dataInvalid:Boolean = this.isInvalid(InvalidationType.DATA); var stylesInvalid:Boolean = this.isInvalid(InvalidationType.STYLES); var sizeInvalid:Boolean = this.isInvalid(InvalidationType.SIZE); if(stylesInvalid || dataInvalid) { this.refreshSeries(); } //update the background if needed if(stylesInvalid) { if(this.background) { this.removeChild(this.background); } var skinClass:Object = this.getStyleValue("backgroundSkin"); this.background = UIComponentUtil.getDisplayObjectInstance(this, skinClass); this.addChildAt(this.background, 0); } if(this.background && (stylesInvalid || sizeInvalid)) { this.background.width = this.width; this.background.height = this.height; //force the background to redraw if it is a UIComponent if(this.background is UIComponent) { (this.background as UIComponent).drawNow(); } } if(this.dataTip is UIComponent) { var dataTip:UIComponent = UIComponent(this.dataTip); this.copyStylesToChild(dataTip, DATA_TIP_STYLES); dataTip.drawNow(); } super.draw(); } /** * Analyzes the input data and smartly converts it to the correct ISeries type * required for drawing. Adds new ISeries objects to the display list and removes * unused series objects that no longer need to be drawn. */ protected function refreshSeries():void { var modifiedData:Object = this._dataProvider; //loop through each series and convert it to the correct data type if(modifiedData is Array) { var arrayData:Array = (modifiedData as Array).concat(); var seriesCount:int = arrayData.length; var foundIncompatibleData:Boolean = false; for(var i:int = 0; i < seriesCount; i++) { var currentItem:Object = arrayData[i]; if(currentItem is Array || currentItem is XMLList) { var itemSeries:ISeries = new this.defaultSeriesType(); if(currentItem is Array) { itemSeries.dataProvider = (currentItem as Array).concat(); } else if(currentItem is XMLList) { itemSeries.dataProvider = (currentItem as XMLList).copy(); } arrayData[i] = itemSeries; } else if(!(currentItem is ISeries)) { //we only support Array, XMLList, and ISeries //anything else means that we should restore the original data var originalData:Array = (modifiedData as Array).concat(); modifiedData = new this.defaultSeriesType(originalData); foundIncompatibleData = true; break; } } if(!foundIncompatibleData) { modifiedData = arrayData; } } //attempt to turn a string into XML if(modifiedData is String) { try { modifiedData = new XML(modifiedData); } catch(error:Error) { //this isn't a valid xml string, so ignore it return; } } //we need an XMLList, so get the elements if(modifiedData is XML) { modifiedData = (modifiedData as XML).elements(); } //convert the XMLList to a series if(modifiedData is XMLList) { modifiedData = new this.defaultSeriesType(modifiedData); } //we should have an ISeries object by now, so put it in an Array if(modifiedData is ISeries) { //if the main data is a series, put it in an array modifiedData = [modifiedData]; } //if it's not an array, we have bad data, so ignore it if(!(modifiedData is Array)) { return; } arrayData = modifiedData as Array; seriesCount = this.series.length; for(i = 0; i < seriesCount; i++) { var currentSeries:ISeries = this.series[i] as ISeries; if(arrayData.indexOf(currentSeries) < 0) { //if the series no longer exists, remove it from the display list and stop listening to it this.content.removeChild(DisplayObject(currentSeries)); currentSeries.removeEventListener("dataChange", seriesDataChangeHandler); currentSeries.removeEventListener(ChartEvent.ITEM_ROLL_OVER, chartItemRollOver); currentSeries.removeEventListener(ChartEvent.ITEM_ROLL_OUT, chartItemRollOut); currentSeries.chart = null; } } //rebuild the series Array this.series = []; seriesCount = arrayData.length; for(i = 0; i < seriesCount; i++) { currentSeries = arrayData[i] as ISeries; this.series.push(currentSeries); if(!this.contains(DisplayObject(currentSeries))) { //if this is a new series, add it to the display list and listen for events currentSeries.addEventListener("dataChange", seriesDataChangeHandler, false, 0, true); currentSeries.addEventListener(ChartEvent.ITEM_ROLL_OVER, chartItemRollOver, false, 0, true); currentSeries.addEventListener(ChartEvent.ITEM_ROLL_OUT, chartItemRollOut, false, 0, true); currentSeries.chart = this; this.content.addChild(DisplayObject(currentSeries)); } DisplayObject(currentSeries).x = 0; DisplayObject(currentSeries).y = 0; //make sure the series are displayed in the correct order this.content.setChildIndex(DisplayObject(currentSeries), this.content.numChildren - 1); //update the series styles this.copyStylesToSeries(currentSeries, ALL_SERIES_STYLES); if(currentSeries is UIComponent) { this.copyStylesToChild(UIComponent(currentSeries), SHARED_SERIES_STYLES); } } } /** * @private * Refreshes the legend's data provider. */ protected function updateLegend():void { if(!this.legend) return; var legendData:Array = []; var seriesCount:int = this.series.length; for(var i:int = 0; i < seriesCount; i++) { var series:ISeries = ISeries(this.series[i]); if(series is ILegendItemSeries) { var itemData:LegendItemData = ILegendItemSeries(series).createLegendItemData(); itemData.label = itemData.label ? itemData.label : i.toString(); legendData.push(itemData); } else if(series is ICategorySeries) { legendData = legendData.concat(ICategorySeries(series).createLegendItemData()); } } this.legend.dataProvider = legendData; if(UIComponent.inCallLaterPhase) { UIComponent(this.legend).drawNow(); } } /** * @private * Tranfers the chart's styles to the ISeries components it contains. These styles * must be of the type Array, and the series index determines the index of the value * to use from that Array. If the chart contains more ISeries components than there * are values in the Array, the indices are reused starting from zero. */ protected function copyStylesToSeries(child:ISeries, styleMap:Object):void { var index:int = this.series.indexOf(child); var childComponent:UIComponent = child as UIComponent; for(var n:String in styleMap) { var styleValues:Array = this.getStyleValue(styleMap[n]) as Array; //if it doesn't exist, ignore it and go with the defaults for this series if(styleValues == null || styleValues.length == 0) continue; childComponent.setStyle(n, styleValues[index % styleValues.length]) } } /** * @private */ protected function defaultDataTipFunction(item:Object, index:int, series:ISeries):String { if(series.displayName) { return series.displayName; } return ""; } /** * @private * Passes data to the data tip. */ protected function refreshDataTip():void { var item:Object = this._lastDataTipRenderer.data; var series:ISeries = this._lastDataTipRenderer.series; var index:int = series.itemRendererToIndex(this._lastDataTipRenderer); var dataTipText:String = ""; if(this.dataTipFunction != null) { dataTipText = this.dataTipFunction(item, index, series); } var dataTipRenderer:IDataTipRenderer = this.dataTip as IDataTipRenderer; dataTipRenderer.text = dataTipText; dataTipRenderer.data = item; this.setChildIndex(this.dataTip, this.numChildren - 1); if(this.dataTip is UIComponent) { UIComponent(this.dataTip).drawNow(); } } //-------------------------------------- // Protected Event Handlers //-------------------------------------- /** * @private * Display the data tip when the user moves the mouse over a chart marker. */ protected function chartItemRollOver(event:ChartEvent):void { this._lastDataTipRenderer = event.itemRenderer; this.refreshDataTip(); var position:Point = this.mousePositionToDataTipPosition(); this.dataTip.x = position.x; this.dataTip.y = position.y; this.dataTip.visible = true; this.stage.addEventListener(MouseEvent.MOUSE_MOVE, stageMouseMoveHandler, false, 0 ,true); } /** * @private * Hide the data tip when the user moves the mouse off a chart marker. */ protected function chartItemRollOut(event:ChartEvent):void { this.stage.removeEventListener(MouseEvent.MOUSE_MOVE, stageMouseMoveHandler); this.dataTip.visible = false; } //-------------------------------------- // Private Methods //-------------------------------------- /** * @private * Determines the position for the data tip based on the mouse position * and the bounds of the chart. Attempts to keep the data tip within the * chart bounds so that it isn't hidden by any other display objects. */ private function mousePositionToDataTipPosition():Point { var position:Point = new Point(); position.x = this.mouseX + 2; position.x = Math.min(this.width - this.dataTip.width, position.x); position.y = this.mouseY - this.dataTip.height - 2; position.y = Math.max(0, position.y); return position; } //-------------------------------------- // Private Event Handlers //-------------------------------------- /** * @private * The plot area needs to redraw the axes if series data changes. */ private function seriesDataChangeHandler(event:Event):void { this.invalidate(InvalidationType.DATA); if(this.dataTip.visible) { this.refreshDataTip(); } } /** * @private * Make the data tip follow the mouse. */ private function stageMouseMoveHandler(event:MouseEvent):void { var position:Point = this.mousePositionToDataTipPosition(); this.dataTip.x = position.x; this.dataTip.y = position.y; } } }