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

948 lines
24 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.axes
{
import com.yahoo.astra.fl.charts.series.ISeries;
import com.yahoo.astra.fl.utils.UIComponentUtil;
import com.yahoo.astra.utils.NumberUtil;
import com.yahoo.astra.fl.charts.CartesianChart;
import flash.utils.Dictionary;
import fl.core.UIComponent;
/**
* An axis type representing a numeric range from minimum to maximum
* with major and minor divisions.
*
* @author Josh Tynjala
*/
public class NumericAxis extends BaseAxis implements IAxis, IOriginAxis, IStackingAxis
{
//--------------------------------------
// Constructor
//--------------------------------------
/**
* Constructor.
*/
public function NumericAxis()
{
}
//--------------------------------------
// Properties
//--------------------------------------
/**
* @private
* The multiplier used to calculate the position on the renderer from an
* axis value.
*/
protected var positionMultiplier:Number = 0;
/**
* @private
* Storage for the minimum value.
*/
private var _minimum:Number = 0;
/**
* @private
* Indicates whether the minimum bound is user-defined or generated by the axis.
*/
private var _minimumSetByUser:Boolean = false;
/**
* The minimum value displayed on the axis. By default, this value is generated
* by the axis itself. If the user defines this value, the axis will skip this
* automatic generation. To enable this behavior again, set this property to NaN.
*/
public function get minimum():Number
{
return this._minimum;
}
/**
* @private
*/
public function set minimum(value:Number):void
{
this._minimum = value;
this._minimumSetByUser = !isNaN(value);
}
/**
* @private
* Storage for the maximum value.
*/
private var _maximum:Number = 100;
/**
* @private
* Indicates whether the maximum bound is user-defined or generated by the axis.
*/
private var _maximumSetByUser:Boolean = false;
/**
* The maximum value displayed on the axis. By default, this value is generated
* by the axis itself. If the user defines this value, the axis will skip this
* automatic generation. To enable this behavior again, set this property to NaN.
*/
public function get maximum():Number
{
return this._maximum;
}
/**
* @private
*/
public function set maximum(value:Number):void
{
this._maximum = value;
this._maximumSetByUser = !isNaN(value);
}
//-- Units
/**
* @private
* Storage for the major unit.
*/
private var _majorUnit:Number = 10;
/**
* @private
* Indicates whether the major unit is user-defined or generated by the axis.
*/
private var _majorUnitSetByUser:Boolean = false;
/**
* The major unit at which new ticks and labels are drawn. By default, this value
* is generated by the axis itself. If the user defines this value, the axis will
* skip the automatic generation. To enable this behavior again, set this property
* to NaN.
*/
public function get majorUnit():Number
{
return this._majorUnit;
}
/**
* @private
*/
public function set majorUnit(value:Number):void
{
this._majorUnit = value;
this._majorUnitSetByUser = !isNaN(value);
}
/**
* @private
* Storage for the minor unit.
*/
private var _minorUnit:Number = 0;
/**
* @private
* Indicates whether the minor unit is user-defined or generated by the axis.
*/
private var _minorUnitSetByUser:Boolean = false;
/**
* The minor unit at which new ticks are drawn. By default, this value
* is generated by the axis itself. If the user defines this value, the axis will
* skip the automatic generation. To enable this behavior again, set this property
* to NaN.
*/
public function get minorUnit():Number
{
return this._minorUnit;
}
/**
* @private
*/
public function set minorUnit(value:Number):void
{
this._minorUnit = value;
this._minorUnitSetByUser = !isNaN(value);
}
/**
* @inheritDoc
*/
public function get origin():Object
{
var origin:Number = 0;
if(this.scale == ScaleType.LOGARITHMIC)
{
origin = 1;
}
origin = Math.max(origin, this.minimum);
origin = Math.min(origin, this.maximum);
return origin;
}
/**
* @private
* Storage for the stackingEnabled property.
*/
private var _stackingEnabled:Boolean = false;
/**
* @inheritDoc
*/
public function get stackingEnabled():Boolean
{
return this._stackingEnabled;
}
/**
* @private
*/
public function set stackingEnabled(value:Boolean):void
{
this._stackingEnabled = value;
}
/**
* @private
* Storage for the alwaysShowZero property.
*/
private var _alwaysShowZero:Boolean = true;
/**
* If true, the axis will attempt to keep zero visible at all times.
* If both the minimum and maximum values displayed on the axis are
* above zero, the minimum will be reset to zero. If both minimum and
* maximum appear below zero, the maximum will be reset to zero. If
* the minimum and maximum appear at positive and negative values
* respectively, zero is already visible and the axis scale does not
* change.
*
* <p>This property has no affect if you manually set the minimum and
* maximum values of the axis.</p>
*/
public function get alwaysShowZero():Boolean
{
return this._alwaysShowZero;
}
/**
* @private
*/
public function set alwaysShowZero(value:Boolean):void
{
this._alwaysShowZero = value;
}
/**
* @private
* Storage for the snapToUnits property.
*/
private var _snapToUnits:Boolean = true;
/**
* If true, the labels, ticks, gridlines, and other objects will snap to
* the nearest major or minor unit. If false, their position will be based
* on the minimum value.
*/
public function get snapToUnits():Boolean
{
return this._snapToUnits;
}
/**
* @private
*/
public function set snapToUnits(value:Boolean):void
{
this._snapToUnits = value;
}
/**
* @private
* Storage for the scale property.
*/
private var _scale:String = ScaleType.LINEAR;
/**
* The type of scaling used to display items on the axis.
*
* @see com.yahoo.astra.fl.charts.ScaleType
*/
public function get scale():String
{
return this._scale;
}
/**
* @private
*/
public function set scale(value:String):void
{
this._scale = value;
}
/**
* @private
*/
private var _dataMinimum:Number = NaN;
/**
* @private
*/
private var _dataMaximum:Number = NaN;
/**
* @private
*/
private var _numLabels:Number;
/**
* @private
*/
private var _numLabelsSetByUser:Boolean = false;
/**
* @inheritDoc
*/
public function get numLabels():Number
{
return _numLabels;
}
/**
* @private (setter)
*/
public function set numLabels(value:Number):void
{
if(_numLabelsSetByUser) return;
_numLabels = value;
_numLabelsSetByUser = true;
_majorUnitSetByUser = false;
_minorUnitSetByUser = false;
}
/**
* @private
*/
private var _roundMajorUnit:Boolean = true;
/**
* Indicates whether to round the major unit
*/
public function get roundMajorUnit():Boolean
{
return _roundMajorUnit;
}
/**
* @private (setter)
*/
public function set roundMajorUnit(value:Boolean):void
{
_roundMajorUnit = value;
}
/**
* @private
* Holds value for idealPixels
*/
private var _idealPixels:Number = 70;
/**
* Desired distance between majorUnits. Used to calculate the major unit
* when unspecified and <code>calculateByLabelSize</code> is set to false.
*/
public function get idealPixels():Number
{
return _idealPixels;
}
/**
* @private (setter)
*/
public function set idealPixels(value:Number):void
{
_idealPixels = value;
}
/**
* @private
* Holds value for calculateByLabelSize
*/
private var _calculateByLabelSize:Boolean = false;
/**
* Indicates whether to use the maximum size of an axis label
* when calculating the majorUnit.
*/
public function get calculateByLabelSize():Boolean
{
return _calculateByLabelSize;
}
/**
* @private (setter)
*/
public function set calculateByLabelSize(value:Boolean):void
{
_calculateByLabelSize = value;
}
//--------------------------------------
// Public Methods
//--------------------------------------
/**
* @inheritDoc
*/
public function valueToLocal(data:Object):Number
{
if(data == null)
{
//bad data. a properly-designed renderer will not draw this.
return NaN;
}
var position:Number = 0;
if(this.scale == ScaleType.LINEAR)
{
position = (Number(data) - this.minimum) * this.positionMultiplier;
}
else
{
var logOfData:Number = Math.log(Number(data));
var logOfMinimum:Number = Math.log(this.minimum);
position = (logOfData - logOfMinimum) * this.positionMultiplier;
}
if(this.reverse)
{
position = this.renderer.length - position;
}
//the vertical axis has its origin on the bottom
if(this.renderer is ICartesianAxisRenderer && ICartesianAxisRenderer(this.renderer).orientation == AxisOrientation.VERTICAL)
{
position = this.renderer.length - position;
}
return Math.round(position);
}
/**
* @inheritDoc
*/
public function stack(top:Object, ...rest:Array):Object
{
var numericValue:Number = Number(top);
var negative:Boolean = false;
if(numericValue < 0)
{
negative = true;
}
var restCount:int = rest.length;
for(var i:int = 0; i < restCount; i++)
{
var currentValue:Number = Number(rest[i]);
if(negative && currentValue < 0)
{
numericValue += currentValue;
}
else if(!negative && currentValue > 0)
{
numericValue += currentValue;
}
}
return numericValue;
}
/**
* @inheritDoc
*/
public function updateScale():void
{
this.resetScale();
this.calculatePositionMultiplier();
(this.renderer as ICartesianAxisRenderer).majorUnitSetByUser = this._majorUnitSetByUser;
this.renderer.ticks = this.createAxisData(this.majorUnit);
this.renderer.minorTicks = this.createAxisData(this.minorUnit);
}
/**
* @inheritDoc
*/
public function getMaxLabel():String
{
var difference:Number = Math.round(this.maximum - this.minimum);
var maxString:String = this.valueToLabel(this.maximum);
var minString:String = this.valueToLabel(this.minimum);
var halfString:String = this.valueToLabel(Math.round(difference/2));
var thirdString:String = this.valueToLabel(Math.round(difference/3));
if(maxString.length < minString.length) maxString = minString;
if(halfString.length > maxString.length) maxString = halfString;
if(thirdString.length > maxString.length) maxString = thirdString;
return maxString as String;
}
//--------------------------------------
// Protected Methods
//--------------------------------------
/**
* @private
* If the minimum, maximum, major unit or minor unit have not been set by the user,
* these values must be generated by the axis. May be overridden to use custom
* scaling algorithms.
*/
protected function resetScale():void
{
//use the discovered min and max from the data
//if the developer didn't specify anything
if(!this._minimumSetByUser)
{
this._minimum = this._dataMinimum;
}
if(!this._maximumSetByUser)
{
this._maximum = this._dataMaximum;
}
this.checkMinLessThanMax();
this.pinToOrigin();
this.calculateMajorUnit();
this.adjustMinAndMaxFromMajorUnit();
this.correctLogScaleMinimum();
//ensure that min != max
if(!this._maximumSetByUser && this._minimum == this._maximum)
{
this._maximum = this._minimum + 1;
if(!this._majorUnitSetByUser)
{
//rarely happens, so I'll hardcode a nice major unit
//for our difference of one
this._majorUnit = 0.5;
}
}
this.calculateMinorUnit();
//even if they are manually set by the user, check all values for possible floating point errors.
//we don't want extra labels or anything like that!
this._minimum = NumberUtil.roundToPrecision(this._minimum, 10);
this._maximum = NumberUtil.roundToPrecision(this._maximum, 10);
this._majorUnit = NumberUtil.roundToPrecision(this._majorUnit, 10);
this._minorUnit = NumberUtil.roundToPrecision(this._minorUnit, 10);
}
/**
* @private
* Determines the best major unit.
*/
protected function calculateMajorUnit():void
{
if(this._majorUnitSetByUser)
{
return;
}
var chart:CartesianChart = this.chart as CartesianChart;
var labelSpacing:Number = 0;
var approxLabelDistance:Number = this.idealPixels;
var overflow:Number = 0;
if(this.calculateByLabelSize)
{
var rotation:Number;
//Check to see if this axis is horizontal. Since the width of labels will be variable, we will need to apply a different alogrithm to determine the majorUnit.
if(chart.horizontalAxis == this)
{
//extract the approximate width of the labels by getting the textWidth of the maximum date when rendered by the label function with the textFormat of the renderer.
approxLabelDistance = this.maxLabelWidth;
rotation = chart.getHorizontalAxisStyle("rotation") as Number;
if(rotation >= 0)
{
if(!isNaN(chart.horizontalAxisLabelData.rightLabelOffset)) overflow += chart.horizontalAxisLabelData.rightLabelOffset as Number;
}
if(rotation <= 0)
{
if(!isNaN(chart.horizontalAxisLabelData.leftLabelOffset)) overflow += chart.horizontalAxisLabelData.leftLabelOffset as Number;
}
}
else
{
approxLabelDistance = this.maxLabelHeight;
rotation = chart.getVerticalAxisStyle("rotation") as Number;
if(!isNaN(chart.verticalAxisLabelData.topLabelOffset)) overflow = chart.verticalAxisLabelData.topLabelOffset as Number;
}
labelSpacing = this.labelSpacing;
approxLabelDistance += (labelSpacing*2);
}
var difference:Number = this.maximum - this.minimum;
var tempMajorUnit:Number = 0;
var maxLabels:Number = ((this.renderer.length + overflow) - labelSpacing)/approxLabelDistance;
if(this.calculateByLabelSize)
{
maxLabels = Math.floor(maxLabels);
//Adjust the max labels to account for potential maximum and minimum adjustments that may occur.
if(!this._maximumSetByUser && !this._minimumSetByUser && !(this.alwaysShowZero && this._minimum == 0)) maxLabels -= 1;
}
//If set by user, use specified number of labels unless its too many
if(this._numLabelsSetByUser)
{
maxLabels = Math.min(maxLabels, this.numLabels);
}
tempMajorUnit = difference/maxLabels;
if(!this.calculateByLabelSize)
{
tempMajorUnit = this.niceNumber(tempMajorUnit);
}
else if(this.roundMajorUnit)
{
var order:Number = Math.ceil(Math.log(tempMajorUnit) * Math.LOG10E);
var roundedMajorUnit:Number = Math.pow(10, order);
if (roundedMajorUnit / 2 >= tempMajorUnit)
{
var roundedDiff:Number = Math.floor((roundedMajorUnit / 2 - tempMajorUnit) / (Math.pow(10,order-1)/2));
tempMajorUnit = roundedMajorUnit/2 - roundedDiff*Math.pow(10,order-1)/2;
}
else
{
tempMajorUnit = roundedMajorUnit;
}
}
if(!isNaN(tempMajorUnit)) this._majorUnit = tempMajorUnit;
}
/**
* @private
* Determines the best minor unit.
*/
protected function calculateMinorUnit():void
{
if(this._minorUnitSetByUser)
{
return;
}
var range:Number = this.maximum - this.minimum;
var majorUnitSpacing:Number = this.renderer.length * (this.majorUnit / range);
if(this._majorUnit != 1)
{
if(this._majorUnit % 2 == 0)
{
this._minorUnit = this._majorUnit / 2;
}
else if(this._majorUnit % 3 == 0)
{
this._minorUnit = this._majorUnit / 3;
}
else this._minorUnit = 0;
}
}
/**
* @private
* Creates the AxisData objects for the axis renderer.
*/
protected function createAxisData(unit:Number):Array
{
if(unit <= 0)
{
return [];
}
var data:Array = [];
var displayedMaximum:Boolean = false;
var value:Number = this.minimum;
while(value < this.maximum || NumberUtil.fuzzyEquals(value, this.maximum))
{
if(value % 1 != 0) value = NumberUtil.roundToPrecision(value, 10);
//because Flash UIComponents round the position to the nearest pixel, we need to do the same.
var position:Number = Math.round(this.valueToLocal(value));
var label:String = this.valueToLabel(value);
var axisData:AxisData = new AxisData(position, value, label);
data.push(axisData);
//if the maximum has been displayed, we're done!
if(displayedMaximum) break;
//a bad unit will get us stuck in an infinite loop
if(unit <= 0)
{
value = this.maximum;
}
else
{
value += unit;
if(this.snapToUnits && !this._minimumSetByUser && this.alwaysShowZero)
{
value = NumberUtil.roundDownToNearest(value, unit);
}
if(this._majorUnitSetByUser) value = Math.min(value, this.maximum);
}
displayedMaximum = NumberUtil.fuzzyEquals(value, this.maximum);
}
return data;
}
//--------------------------------------
// Private Methods
//--------------------------------------
/**
* @private
* If we want to always show zero, corrects the min or max as needed.
*/
private function pinToOrigin():void
{
//if we're pinned to zero, and min or max is supposed to be generated,
//make sure zero is somewhere in the range
if(this.alwaysShowZero)
{
if(!this._minimumSetByUser && this._minimum > 0 && this._maximum > 0)
{
this._minimum = 0;
}
else if(!this._maximumSetByUser && this._minimum < 0 && this._maximum < 0)
{
this._maximum = 0;
}
}
}
/**
* @private
* Increases the maximum and decreases the minimum based on the major unit.
*/
private function adjustMinAndMaxFromMajorUnit():void
{
//adjust the maximum so that it appears on a major unit
//but don't change the maximum if the user set it or it is pinned to zero
if(!this._maximumSetByUser && !(this.alwaysShowZero && this._maximum == 0))
{
var oldMaximum:Number = this._maximum;
if(this._minimumSetByUser)
{
//if the user sets the minimum, we need to ensure that the maximum is an increment of the major unit starting from
//the minimum instead of zero
this._maximum = NumberUtil.roundUpToNearest(this._maximum - this._minimum, this._majorUnit);
this._maximum += this._minimum;
}
else
{
this._maximum = NumberUtil.roundUpToNearest(this._maximum, this._majorUnit);
}
//uncomment to include an additional major unit in this adjustment
if(this._maximum == oldMaximum /*|| this._maximum - oldMaximum < this._majorUnit */)
{
this._maximum += this._majorUnit;
}
}
//adjust the minimum so that it appears on a major unit
//but don't change the minimum if the user set it or it is pinned to zero
if(!this._minimumSetByUser && !(this.alwaysShowZero && this._minimum == 0))
{
var oldMinimum:Number = this._minimum;
this._minimum = NumberUtil.roundDownToNearest(this._minimum, this._majorUnit);
//uncomment to include an additional major unit in this adjustment
if(this._minimum == oldMinimum /*|| oldMinimum - this._minimum < this._majorUnit*/)
{
this._minimum -= this._majorUnit;
}
}
}
/**
* @private
* If we're using logarithmic scale, corrects the minimum if it gets set
* to a value <= 0.
*/
private function correctLogScaleMinimum():void
{
//logarithmic scale can't have a minimum value <= 0. If that's the case, push it up to 1.0
//TODO: Determine if there's a better way to handle this...
if(!this._minimumSetByUser && this.scale == ScaleType.LOGARITHMIC && this._minimum <= 0)
{
//use the dataMinimum if it's between 0 and 1
//otherwise, just use 1
if(this._dataMinimum > 0 && this._dataMinimum < 1)
{
this._minimum = this._dataMinimum;
}
else
{
this._minimum = 1;
}
}
}
/**
* @private
* Calculates a "nice" number for use with major or minor units
* on the axis. Only returns numbers similar to 10, 20, 25, and 50.
*/
private function niceNumber(value:Number):Number
{
if(value == 0)
{
return 0;
}
var count:int = 0;
while(value > 10.0e-8)
{
value /= 10;
count++;
}
//all that division in the while loop up there
//could cause rounding errors. Don't you hate that?
value = NumberUtil.roundToPrecision(value, 10);
if(value > 4.0e-8)
{
value = 5.0e-8;
}
else if(value > 2.0e-8)
{
value = 2.5e-8;
}
else if(value > 1.0e-8)
{
value = 2.0e-8;
}
else
{
value = 1.0e-8;
}
for(var i:int = count; i > 0; i--)
{
value *= 10;
}
return value;
}
/**
* @private
* Swaps the minimum and maximum values, if needed.
*/
private function checkMinLessThanMax():void
{
if(this._minimum > this._maximum)
{
var temp:Number = this._minimum;
this._minimum = this._maximum;
this._maximum = temp;
//be sure to swap these flags too!
var temp2:Boolean = this._minimumSetByUser;
this._minimumSetByUser = this._maximumSetByUser;
this._maximumSetByUser = temp2;
}
}
/**
* @private
* Calculates the multiplier used to convert a data point to an actual position
* on the axis.
*/
private function calculatePositionMultiplier():void
{
var range:Number = this.maximum - this.minimum;
if(this.scale == ScaleType.LOGARITHMIC)
{
range = Math.log(this.maximum) - Math.log(this.minimum);
}
if(range == 0)
{
this.positionMultiplier = 0;
return;
}
this.positionMultiplier = this.renderer.length / range;
}
/**
* @private
*/
override protected function parseDataProvider():void
{
var seriesCount:int = this.dataProvider.length;
var dataMinimum:Number = NaN;
var dataMaximum:Number = NaN;
for(var i:int = 0; i < seriesCount; i++)
{
var series:ISeries = this.dataProvider[i] as ISeries;
var seriesLength:int = series.length;
for(var j:int = 0; j < seriesLength; j++)
{
var item:Object = series.dataProvider[j];
if(item === null)
{
continue;
}
//automatically calculates stacked values
var value:Number = Number(this.chart.itemToAxisValue(series, j, this));
if(isNaN(value))
{
continue; //skip bad data
}
//don't let bad data propogate
//Math.min()/Math.max() with a NaN argument will choose NaN. Ya Rly.
dataMinimum = isNaN(dataMinimum) ? value : Math.min(dataMinimum, value);
dataMaximum = isNaN(dataMaximum) ? value : Math.max(dataMaximum, value);
}
}
if(!isNaN(dataMinimum) && !isNaN(dataMaximum))
{
this._dataMinimum = dataMinimum;
this._dataMaximum = dataMaximum;
}
else
{
//some sensible defaults
this._dataMinimum = 0;
this._dataMaximum = 1;
}
if(!this._minimumSetByUser)
{
this._minimum = this._dataMinimum;
}
if(!this._maximumSetByUser)
{
this._maximum = this._dataMaximum;
}
}
}
}