2020-10-20 00:58:15 +02:00

826 lines
25 KiB
ActionScript
Executable File

/*
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.
*
* <p>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.</p>
*
* <p>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).</p>
*
* @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.
*
* <p>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.</p>
*
* <p>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).</p>
*
* @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.
*
* <p>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.</p>
*
* <p>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).</p>
*
* @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 <code>Chart</code> object
* shouldn't be instantiated directly. Instead, a subclass with a concrete
* implementation should be used. That subclass generally should implement the
* <code>IPlotArea</code> 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:
*
* <p><code>function dataTipFunction(item:Object, index:int, series:ISeries):String</code></p>
*/
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;
}
}
}