package flare.apps
{
import flare.animate.Transitioner;
import flare.data.DataSet;
import flare.data.DataSource;
import flare.display.TextSprite;
import flare.util.Shapes;
import flare.util.Strings;
import flare.util.palette.ColorPalette;
import flare.vis.Visualization;
import flare.vis.controls.ClickControl;
import flare.vis.controls.HoverControl;
import flare.vis.controls.TooltipControl;
import flare.vis.data.Data;
import flare.vis.data.DataSprite;
import flare.vis.data.NodeSprite;
import flare.vis.events.SelectionEvent;
import flare.vis.events.TooltipEvent;
import flare.vis.legend.Legend;
import flare.vis.legend.LegendItem;
import flare.vis.operator.encoder.ColorEncoder;
import flare.vis.operator.filter.VisibilityFilter;
import flare.vis.operator.label.StackedAreaLabeler;
import flare.vis.operator.layout.StackedAreaLayout;
import flare.widgets.ProgressBar;
import flare.widgets.SearchBox;
import flash.display.Shape;
import flash.events.Event;
import flash.filters.DropShadowFilter;
import flash.geom.Rectangle;
import flash.net.URLLoader;
import flash.text.TextFormat;
[SWF(backgroundColor="#ffffff", frameRate="30")]
public class JobVoyager extends App
{
private var _bar:ProgressBar;
private var _bounds:Rectangle;
private var _vis:Visualization;
private var _labelMask:Shape;
private var _title:TextSprite;
private var _search:SearchBox;
private var _gender:Legend;
private var _fmt:TextFormat = new TextFormat("Arial,Helvetica",12,0, false);
private var _dur:Number = 1.25; // animation duration
private var _reds:Array = [0xFFFEF0D9, 0xFFFDD49E, 0xFFFDBB84, 0xFFFC8D59, 0xFFE34A33, 0xFFB30000];
private var _t:Transitioner;
private var _query:Array;
private var _filter:String = "All";
private var _exact:Boolean = false;
private var _url:String = "http://projects.flowingdata.com/america/spending/expenditures.txt";
private var _cols:Array = [1984,1985,1986,1987,1988,1989,1990,1991,
1992,1993,1994,1995,1996,1997,1998,1999,
2000,2001,2002,2003,2004,2005,2006,2007,2008];
private var _titleText:String =
"Expenditures " +
"(source: http://bls.gov)";
protected override function init():void
{
addChild(_bar = new ProgressBar("Loading...", 150, 4, 0x666666));
_bar.bar.filters = [new DropShadowFilter(1)];
var ds:DataSource = new DataSource(_url, "tab");
var ldr:URLLoader = ds.load();
_bar.loadURL(ldr, function():void {
// get loaded data, reshape for stacked columns
var ds:DataSet = ldr.data as DataSet;
var dr:Array = reshape(ds.nodes.data, ["category"], "year", "expenditure", _cols);
visualize(Data.fromArray(dr));
_bar = null;
});
}
private function visualize(data:Data):void
{
// prepare data with default settings and sort
// data.nodes.sortBy("data.occupation","data.sex");
data.nodes.sortBy("data.category");
data.nodes.setProperties({
shape: Shapes.POLYGON,
lineColor: 0xFFFFFFFF
//fillColor: 0xFFEECE80
});
// expression sets male -> blue, female -> red
// data.nodes.setProperty("fillHue", iff(eq("data.sex",1), 0.7, 0));
// define the visualization
_vis = new Visualization(data);
// first, set the visibility according to the query
_vis.operators.add(new VisibilityFilter(filter));
_vis.operators[0].immediate = true; // filter immediately!
// second, layout the stacked chart
_vis.operators.add(new StackedAreaLayout(_cols, 0));
_vis.operators[1].scale.labelFormat = "0.##%"; // show as percent
// third, label the stacks
_vis.operators.add(new StackedAreaLabeler("data.category"));
// fourth, set the color saturation for the current view
// _vis.operators.add(new SaturationEncoder());
var colorPalette:ColorPalette = new ColorPalette(_reds);
_vis.operators.add(new ColorEncoder("data.max", "nodes", "fillColor", null, colorPalette));
// initialize y-axis labels: align and add mask
_labelMask = new Shape();
_vis.xyAxes.addChild(_labelMask); // hides extreme labels
_vis.xyAxes.yAxis.labels.mask = _labelMask;
_vis.xyAxes.yAxis.verticalAnchor = TextSprite.TOP;
_vis.xyAxes.yAxis.horizontalAnchor = TextSprite.RIGHT;
_vis.xyAxes.yAxis.labelOffsetX = 50; // offset labels to the right
_vis.xyAxes.yAxis.lineCapX1 = 15; // extra line length to the left
_vis.xyAxes.yAxis.lineCapX2 = 50; // extra line length to the right
_vis.xyAxes.showBorder = false;
// place and update
_vis.update();
addChild(_vis);
// add mouse-over highlight
_vis.controls.add(new HoverControl(NodeSprite,
// move highlighted node to be drawn on top
HoverControl.MOVE_AND_RETURN,
// highlight node to full saturation
function(e:SelectionEvent):void {
e.node.props.saturation = e.node.fillSaturation;
e.node.fillSaturation = 1;
},
// return node to previous saturation
function(e:SelectionEvent):void {
e.node.fillSaturation = e.node.props.saturation;
}
));
// add filter on click
_vis.controls.add(new ClickControl(NodeSprite, 1,
// set search query to the occupation name
function(e:SelectionEvent):void {
_exact = true; // force an exact search
_search.query = e.node.data.category;
}
));
// add tooltips
_vis.controls.add(new TooltipControl(NodeSprite, null,
// update on both roll-over and mouse-move
updateTooltip, updateTooltip));
// add title and search box
addControls();
layout();
}
private function updateTooltip(e:TooltipEvent):void
{
// get current year value from axes, and map to data
var yr:Number = Number(
_vis.xyAxes.xAxis.value(_vis.mouseX, _vis.mouseY));
var year:String = Math.round(yr).toString();
// var year:String = (10 * Math.round(yr/10)).toString();
var def:Boolean = (e.node.data[year] != undefined);
TextSprite(e.tooltip).htmlText = Strings.format(
"{0}
{1}, "+(def?"{2:0.##%}":"{2}"),
year, e.node.data.category, (def ? e.node.data[year] : "Missing Data"));
}
public override function resize(bounds:Rectangle):void
{
if (_bar) {
_bar.x = bounds.width/2 - _bar.width/2;
_bar.y = bounds.height/2 - _bar.height/2;
}
bounds.width -= (35 + 50);
bounds.height -= (45 + 35);
bounds.x += 20;
bounds.y += 45;
_bounds = bounds;
layout();
}
private function layout():void
{
if (_vis) {
// compute the visualization bounds
_vis.bounds = _bounds;
// mask the y-axis labels to hide extreme animation
_labelMask.graphics.clear();
_labelMask.graphics.beginFill(0);
_labelMask.graphics.drawRect(_vis.bounds.right,
_vis.bounds.top, 60, 1+_vis.bounds.height);
// update
_vis.update();
}
if (_title) {
_title.x = -1;
_title.y = _bounds.top - _title.height - 45;
}
if (_search) {
_search.x = 0;
_search.y = 0;
}
if (_gender) {
_gender.x = stage.stageWidth - _gender.width;
_gender.y = _search.y;
}
}
/** Filter function for determining visibility. */
private function filter(d:DataSprite):Boolean
{
if (!_query || _query.length==0) {
return true;
} else {
var s:String = String(d.data["category"]).toLowerCase();
for each (var q:String in _query) {
var len:int = q.length;
if (len == 0) continue;
if (!_exact && s.substr(0,len)==q) return true;
if (_exact && q==s) return true;
}
return false;
}
}
/** Callback for filter events. */
private function onFilter(evt:Event=null):void
{
_query = _search.query.toLowerCase().split(/\|/);
if (_query.length==1 && _query[0].length==0) _query.pop();
if (_t && _t.running) _t.stop();
_t = _vis.update(_dur);
_t.play();
_exact = false; // reset exact match after each search
}
// --------------------------------------------------------------------
private function addControls():void
{
// create title
// _title = new TextSprite("", _fmt, TextSprite.DEVICE);
// _title.htmlText = _titleText;
// _title.textField.selectable = false;
// addChild(_title);
// create search box
_search = new SearchBox(_fmt, "Search: ", 250);
_search.borderColor = 0xcccccc;
_search.input.tabIndex = 0;
_search.input.restrict = "a-zA-Z \\-";
_search.addEventListener(SearchBox.SEARCH, onFilter);
addChild(_search);
// create gender filter
// _gender = Legend.fromValues(null, [
// {label:"All", color:0xff888888},
// {label:"Male", color:0xff8888ff},
// {label:"Female", color:0xffff8888}
// ]);
// _gender.orientation = Orientation.LEFT_TO_RIGHT;
// _gender.labelTextFormat = _fmt;
// _gender.margin = 3;
// _gender.setItemProperties({buttonMode:true, alpha:0.3});
// _gender.items.getChildAt(0).alpha = 1;
// _gender.update();
// addChild(_gender);
// change alpha value on legend mouse-over
new HoverControl(LegendItem, 0,
function(e:SelectionEvent):void { e.object.alpha = 1; },
function(e:SelectionEvent):void {
var li:LegendItem = LegendItem(e.object);
if (li.text != _filter) li.alpha = 0.3;
}
).attach(_gender);
// filter by gender on legend click
new ClickControl(LegendItem, 1, function(e:SelectionEvent):void {
_gender.setItemProperties({alpha:0.3});
e.object.alpha = 1;
_filter = LegendItem(e.object).text;
onFilter();
}).attach(_gender);
}
// --------------------------------------------------------------------
/**
* Reshapes a data set, pivoting from rows to columns. For example, if
* yearly data is stored in individual rows, this method can be used to
* map each year into a column and the full time series into a single
* row. This is often needed to use the stacked area layout.
* @param tuples an array of data tuples
* @param cats the category values to maintain
* @param dim the dimension upon which to pivot. The values of this
* property should correspond to the names of newly created columns.
* @param measure the numerical value of interest. The values of this
* property will be used as the values of the new columns.
* @param cols an ordered array of the new column names. These should
* match the values of the dim property.
* @param normalize a flag indicating if the data should be normalized
*/
public static function reshape(tuples:Array, cats:Array, dim:String,
measure:String, cols:Array, normalize:Boolean=true):Array
{
var t:Object, d:Object, val:Object, name:String;
var data:Array = [], names:Array = []
var totals:Object = {};
for each (val in cols) totals[val] = 0;
// create data set
for each (t in tuples) {
// create lookup hash for tuple
var hash:String = "";
for each (name in cats) hash += t[name];
if (names[hash] == null) {
// create a new data tuple
data.push(d = {});
for each (name in cats) d[name] = t[name];
d[t[dim]] = t[measure];
names[hash] = d;
} else {
// update an existing data tuple
names[hash][t[dim]] = t[measure];
}
totals[t[dim]] += t[measure];
}
// zero out missing data
for each (t in data) {
var max:Number = 0;
for each (name in cols) {
if (!t[name]) t[name] = 0; // zero out null entries
if (normalize)
t[name] /= totals[name]; // normalize
if (t[name] > max) max = t[name];
}
t.max = max;
}
return data;
}
} // end of class Stacks
}
import flare.animate.Transitioner;
import flare.vis.data.DataSprite;
import flare.vis.operator.Operator;
class SaturationEncoder extends Operator
{
public override function operate(t:Transitioner=null):void
{
t = (t ? t : Transitioner.DEFAULT);
var m:Number=0, f:Number=0;
// first pass: determine maximum visible value
visualization.data.nodes.visit(function(d:DataSprite):void {
if (d.data.sex == 1) {
m = Math.max(m, d.data.max);
} else {
f = Math.max(f, d.data.max);
}
}, "visible");
// second pass: set saturation
visualization.data.nodes.visit(function(d:DataSprite):void {
var s:Number = .3 + .3*d.data.max/((d.data.sex==1)?m:f);
t.$(d).fillSaturation = s;
}, "visible");
}
} // end of class SaturationEncoder