Stacked area charts

This commit is contained in:
2015-03-11 19:10:11 +07:00
parent 5f8ace43ef
commit e58046f227
10 changed files with 522 additions and 84 deletions
+248
View File
@@ -0,0 +1,248 @@
var Router = ReactRouter,
Link = Router.Link;
var Storage = {
set: function(category, key, value) {
window.localStorage.setItem(category +'-'+ key, JSON.stringify(value));
},
get: function(category, key) {
var val = window.localStorage.getItem(category +'-'+ key);
return val === null ? null : JSON.parse(val);
}
};
var App = React.createClass({
mixins: [Router.Navigation, Router.State],
orgsURL: "/api/orgs",
teamsURL: "/api/teams?org=",
getInitialState: function() {
return {
orgs: [],
org: null,
teams: [],
team: null
};
},
componentDidMount: function() {
this.loadOrgs();
this.loadTeams();
},
loadOrgs: function() {
$.get(this.orgsURL, function(res){
this.setState({orgs: res});
if (res !== null) {
for (var i = 0; i < res.length; i++) {
var org = res[i];
Storage.set('org', org.login, org);
}
}
}.bind(this));
},
loadTeams: function() {
$.get(this.teamsURL + this.getParams().org, function(res){
this.setState({teams: res})
if (res !== null) {
for (var i = 0; i < res.length; i++) {
var team = res[i];
Storage.set('team', team.name, team);
}
}
}.bind(this));
},
render: function(){
return (
<section className="app">
<Menu orgs={this.state.orgs} teams={this.state.teams} />
<Router.RouteHandler />
</section>
);
}
});
var Menu = React.createClass({
mixins: [Router.State],
render: function() {
var renderOrg = function(org) {
return (
<li key={'org-'+ org.login} className="nav org">
<Link to="org" params={{org: org.login}}>{org.login}</Link>
</li>
)
};
var renderTeam = function(team) {
return (
<li key={'team-'+ team.name} className="nav team">
<Link to="team" params={{org: team.owner, team: team.name}}>{team.name}</Link>
</li>
)
};
return (
<section className="menu">
<ul>
<li key="empact">
<Link to="org" params={this.getParams()} className="logo-button">
<div className="logo e">e</div>
<div className="logo m">m</div>
<div className="logo p">p</div>
<div className="logo a">a</div>
<div className="logo c">c</div>
<div className="logo t">t</div>
</Link>
</li>
<li key="orgs-header" className="nav header">Organizations:</li>
{this.props.orgs.map(renderOrg)}
<li key="teams-header" className="nav header">Teams:</li>
{this.props.teams.map(renderTeam)}
</ul>
</section>
);
}
});
var Org = React.createClass({
render: function(){
return (
<Router.RouteHandler />
);
}
});
var OrgStats = React.createClass({
mixins: [Router.State],
render: function(){
var org = Storage.get('org', this.getParams().org);
return (
<section className="content">
<InfoBlock key={'info-block-org-'+ this.getParams().org}
image={org.avatar_url}
title={org.login}
text={org.descr} />
<BarChart key={'bar-chart-'+ this.getParams().org}
api="/api/stat/orgs/top"
params={this.getParams()}
items={["repo", "team", "user"]} />
<StackedAreaChart key={'sa-chart-team-'+ this.getParams().team}
api="/api/stat/orgs/activity"
params={this.getParams()}
items={["repo", "team", "user"]} />
</section>
);
}
});
var TeamStats = React.createClass({
mixins: [Router.State],
render: function(){
return (
<section className="content">
<InfoBlock key={"info-block-team-"+ this.getParams().team}
image="https://media.licdn.com/mpr/mpr/p/8/005/058/14b/0088c48.jpg"
title={this.getParams().team}
text={"The most awesome team in "+ this.getParams().org} />
<BarChart key={'bar-chart-team-'+ this.getParams().team}
api="/api/stat/teams/top"
params={this.getParams()}
items={["repo", "user"]} />
<StackedAreaChart key={'sa-chart-team-'+ this.getParams().team}
api="/api/stat/teams/activity"
params={this.getParams()}
items={["repo", "user"]} />
</section>
);
}
});
var UserStats = React.createClass({
mixins: [Router.State],
render: function(){
return (
<section className="content">
<InfoBlock key={'info-block-user-'+ this.getParams().user}
title={this.getParams().user} />
<BarChart key={'bar-chart-user-'+ this.getParams().user}
api="/api/stat/users/top"
params={this.getParams()}
items={["repo"]} />
<StackedAreaChart key={'sa-chart-team-'+ this.getParams().team}
api="/api/stat/users/activity"
params={this.getParams()}
items={["repo"]} />
</section>
);
}
});
var RepoStats = React.createClass({
mixins: [Router.State],
render: function(){
return (
<section className="content">
<InfoBlock key={'info-block-repo'+ this.getParams().repo}
title={this.getParams().repo} />
<BarChart key={'bar-chart-repo-'+ this.getParams().team}
api="/api/stat/repos/top"
params={this.getParams()}
items={["user", "team"]} />
<StackedAreaChart key={'sa-chart-team-'+ this.getParams().team}
api="/api/stat/repos/activity"
params={this.getParams()}
items={["user", "team"]} />
</section>
);
}
});
var NotFound = React.createClass({
render: function(){
return (
<section className="content">NOT FOUND :(</section>
);
}
});
var SelectOrg = React.createClass({
render: function(){
return (
<section className="content">Please select organization from the menu!</section>
);
}
});
var InfoBlock = React.createClass({
render: function() {
var img = <div className="img" style={{backgroundImage: "url("+ this.props.image +")"}} />;
return (
<div className="info-block">
{ this.props.image ? img : null }
<h1>{this.props.title}</h1>
<p>{this.props.text}</p>
</div>
)
}
});
var routes = [
<Router.Route name="root" path="/app/" handler={App}>
<Router.DefaultRoute handler={SelectOrg} />
<Router.NotFoundRoute handler={NotFound} />
<Router.Route name="org" path=":org" handler={Org}>
<Router.DefaultRoute handler={OrgStats} />
<Router.Route name="team" path="teams/:team" handler={TeamStats} />
<Router.Route name="user" path="users/:user" handler={UserStats} />
<Router.Route name="repo" path="repos/:repo" handler={RepoStats} />
</Router.Route>
</Router.Route>
];
Router.run(routes, Router.HistoryLocation, function(Handler) {
React.render(<Handler />, document.body);
});
+197
View File
@@ -0,0 +1,197 @@
var BarChart = React.createClass({
mixins: [Router.Navigation, Router.State],
numElements: 15,
barHeight: 30,
barMargin: 5,
getInitialState: function() {
return {
item: this.props.items[0],
sort: 'commits',
rawData: [],
points: [],
min: 0,
max: 1,
canvasWidth: 500
};
},
calculateViewBoxWidth: function() {
this.setState({
canvasWidth: this.refs.svg.getDOMNode().offsetWidth
});
},
componentDidMount: function() {
this.fetchData();
this.calculateViewBoxWidth();
window.addEventListener('resize', this.calculateViewBoxWidth);
},
handleFilter: function(thing, i) {
if (thing === 'item' && this.props.items[i] !== this.state.item) {
this.setState({
item: this.props.items[i]
}, this.fetchData);
} else if (thing === 'sort' && ['commits', 'delta'][i] !== this.state.sort) {
this.setState({
sort: ['commits', 'delta'][i]
}, this.sort);
}
},
handleClick: function(point) {
var params = {org: this.getParams().org};
params[this.state.item] = point.item;
this.transitionTo(this.state.item, params);
},
fetchData: function() {
$.get(this.props.api, this.apiParams(), function(res){
this.setState({
rawData: res
}, this.sort);
}.bind(this));
},
sort: function() {
var sortFun = function(a, b) {
return Math.abs(b[this.state.sort]) - Math.abs(a[this.state.sort]);
}.bind(this);
var points = this.state.rawData.sort(sortFun).slice(0, this.numElements);
var min = 0, max = 1;
points.map(function(el) {
var val = el[this.state.sort];
if (val > max) {
max = val;
}
if (val < min) {
min = val;
}
}.bind(this));
this.setState({
points: points,
min: min,
max: max
});
},
apiParams: function() {
var params = _.clone(this.props.params);
params['item'] = this.state.item;
return params;
},
height: function() {
if (this.state.points.length === 0) {
return 0;
} else {
return this.y(this.state.points.length) - this.barMargin;
}
},
y: function(i) {
return i*(this.barHeight + this.barMargin);
},
render: function() {
return (
<div className="barchart-container">
<div className="filters">
<Selector thing="item"
items={this.props.items}
value={this.state.item}
onChange={this.handleFilter.bind(this, 'item')} />
<Selector thing="sort"
items={['commits', 'delta']}
value={this.state.sort}
onChange={this.handleFilter.bind(this, 'sort')} />
</div>
<svg ref="svg" className="barchart"
width="100%" height={this.height()}
viewBox={"0 0 "+ this.state.canvasWidth + " "+ this.height()}>
{this.state.points.map(this.renderBar)}
</svg>
</div>
);
},
renderBar: function(point, i) {
var maxWidth = this.state.canvasWidth,
val = point[this.state.sort],
min = this.state.min,
max = this.state.max,
max2 = (min < 0 ? max - min : max),
width = Math.abs(val)/max2*maxWidth,
height = this.barHeight,
offset = -min/max2*maxWidth,
x = (min >= 0 ? 0 : offset - (val >= 0 ? 0 : width)),
y = this.y(i);
return (
<Bar key={point.item} item={point.item} value={val}
color={Colors2[i]}
x={x} y={y} offset={offset} width={width} height={height}
onClick={this.handleClick.bind(this, point)} />
);
}
});
var Bar = React.createClass({
mixins: [Router.Navigation],
render: function() {
var val = this.props.value,
item = this.props.item,
offset = this.props.offset,
width = this.props.width,
label = item + ': ' + val,
labelPaddingH = 5, // Horizontal
labelPaddingV = 2, // Vertical
labelWidth = textWidth(label),
labelHeight = 16,
labelOuterWidth = labelWidth + 2*labelPaddingH,
labelOffsetWidth = labelOuterWidth + 2*labelPaddingH,
labelOuterHeight = labelHeight + 2*labelPaddingV,
labelMarginV = (this.props.height - labelOuterHeight)/2,
labelX = 0,
labelY = this.props.y + labelOuterHeight + 1, // 1 is magic
barX = this.props.x;
if (labelOffsetWidth <= width) {
if (offset > 0) {
if (barX === offset) {
labelX = barX + 2*labelPaddingH;
} else {
labelX = barX + width - labelOffsetWidth + 2*labelPaddingH;
}
} else {
labelX = barX + 2*labelPaddingH;
}
} else {
if (barX === offset) {
labelX = barX + width + 2*labelPaddingH;
} else if (labelOffsetWidth <= barX) {
labelX = barX - labelOffsetWidth + 2*labelPaddingH;
} else {
labelX = barX + width + labelPaddingH;
}
}
return (
<g onClick={this.props.onClick}>
<rect className="bar" fill={this.props.color}
width={width} height={this.props.height}
x={this.props.x} y={this.props.y} rx="2" ry="2" />
<rect className="label_underlay"
x={labelX - labelPaddingH} y={this.props.y + labelMarginV}
height={labelOuterHeight} width={labelOuterWidth}
rx="3" ry="3" />
<text className="label" x={labelX} y={labelY}>{label}</text>
</g>
);
}
});
+62
View File
@@ -0,0 +1,62 @@
var SVGNS = 'http://www.w3.org/2000/svg',
fontFamily = 'Helvetica Neue',
fontSize = '16px',
Router = ReactRouter;
var Selector = React.createClass({
names: {
"repo": "Repositories",
"team": "Teams",
"user": "Users",
"commits": "Commits",
"delta": "Delta"
},
itemWithName: function(name) {
for (item in this.names) {
if (this.names[item] === name) {
return item;
}
}
},
renderItem: function(item, i) {
var itemClass = (item === this.props.value ? 'active' : ''),
clickEvent = null;
if (this.props.onChange) {
clickEvent = this.props.onChange.bind(this, i);
}
return (
<li key={item} onClick={clickEvent} className={itemClass}>{this.names[item]}</li>
);
},
render: function() {
return (
<ul className={this.props.thing}>
{this.props.items.map(this.renderItem)}
</ul>
);
}
});
function textWidth(str) {
var svg = document.createElementNS(SVGNS, "svg");
text = document.createElementNS(SVGNS, "text");
svg.width = 500;
svg.height = 500;
svg.style.position = 'absolute';
svg.style.left = '-1000px';
text.appendChild(document.createTextNode(str))
text.style.fontFamily = fontFamily;
text.style.fontSize = fontSize;
svg.appendChild(text);
document.body.appendChild(svg);
var box = text.getBBox();
document.body.removeChild(svg);
return box.width;
}
+200
View File
@@ -0,0 +1,200 @@
var StackedAreaChart = React.createClass({
mixins: [Router.Navigation, Router.State],
numElements: 10,
height: 250,
getInitialState: function() {
return {
item: this.props.items[0],
rawData: [],
top: [],
max: 1,
weeks: [],
canvasWidth: 500
};
},
calculateViewBoxWidth: function() {
this.setState({
canvasWidth: this.refs.svg.getDOMNode().offsetWidth
});
},
componentDidMount: function() {
this.fetchData();
this.calculateViewBoxWidth();
window.addEventListener('resize', this.calculateViewBoxWidth);
},
handleFilter: function(thing, i) {
if (this.props.items[i] !== this.state.item) {
this.setState({
item: this.props.items[i]
}, this.fetchData);
}
},
handleClick: function(point) {
var params = {org: this.getParams().org};
params[this.state.item] = point.item;
this.transitionTo(this.state.item, params);
},
fetchData: function() {
$.get(this.props.api, this.apiParams(), function(res){
this.setState({
rawData: res
}, this.buildPoints);
}.bind(this));
},
apiParams: function() {
var params = _.clone(this.props.params);
params['item'] = this.state.item;
return params;
},
buildPoints: function() {
// Group commits by items
var counts = _.reduce(this.state.rawData, function(res, el) {
if (res[el.item] === undefined) {
res[el.item] = el.commits;
} else {
res[el.item] += el.commits;
}
return res;
}, {});
// Extract top items from
var top = _.chain(_.pairs(counts)) // Take [item, count] pairs from counts object
.sortBy(1).reverse() // sort them by count (descending)
.take(this.numElements) // take first N pairs
.pluck(0) // keep only items, omit the counts
.value();
var weeks = _.reduce(this.state.rawData, function(res, el) {
if (res[el.week] === undefined) {
res[el.week] = {};
}
res[el.week][el.item] = el.commits;
return res;
}, {});
var max = _.chain(this.state.rawData)
.reduce(function(res, el) {
if (res[el.week] === undefined) {
res[el.week] = 0;
}
res[el.week] += el.commits;
return res;
}, {})
.max()
.value();
this.setState({
top: top,
max: max,
weeks: weeks
});
},
buildPathD: function(points) {
var maxWidth = this.state.canvasWidth,
maxHeight = this.height,
len = points.length;
var d = _.map(points, function(point, i) {
return 'L'+ Math.floor(i/len*maxWidth) +','+ (maxHeight - point);
});
d.unshift('M0,'+maxHeight);
d.push('L'+ maxWidth +','+ maxHeight +'Z');
// for (var i = 0; i < missing; i++) {
// d.push('L'+ i +','+ this.props.height/2);
// }
// for (var i = 0; i < points.length; i++) {
// d.push('L'+ missing+i +','+ points[i]);
// }
// d.push('L'+ this.props.width +','+ this.props.height/2, 'Z');
return d.join(' ');
},
render: function() {
var maxWidth = this.state.canvasWidth,
maxHeight = this.height,
rtop = this.state.top.reverse(),
max = this.state.max;
var points = _.chain(this.state.weeks)
.map(function(items, week) {
var values = _.map(rtop, function(item) {
return items[item] || 0;
});
var sum = 0;
var points = _.map(values, function(val) {
sum += Math.floor(val/max*maxHeight);
return sum;
});
return [week, points];
})
.sort(0)
.value();
var paths = _.reduce(rtop, function(res, item, i) {
res[item] = _.map(points, function(pair) {
return pair[1][i];
}).slice(-15);
return res;
}, {});
var i = -1;
var colors = {}
var areas = _.map(paths, function(path, item) {
i++;
colors[item] = Colors2[i];
return (
<StackedArea key={'sa-item-'+ item}
item={item}
path={roundPathCorners(this.buildPathD(path), 5)}
color={Colors2[i]} />
);
}.bind(this));
return (
<div className="sachart-container">
<div className="filters">
<Selector thing="item"
items={this.props.items}
value={this.state.item}
onChange={this.handleFilter.bind(this, 'item')} />
<Selector thing="sort"
items={['commits']}
value={'commits'} />
</div>
<svg ref="svg" className="sachart"
width="100%" height={maxHeight}
viewBox={"0 0 "+ this.state.canvasWidth + " "+ maxHeight}>
{areas.reverse()}
</svg>
<ul className="legend">
{_.pairs(colors).map(function(pair){
return (
<li><div className="color-dot" style={{backgroundColor: pair[1]}}></div>{pair[0]}</li>
);
})}
</ul>
</div>
);
}
});
var StackedArea = React.createClass({
render: function() {
return (
<path d={this.props.path} fill={this.props.color} shapeRendering="optimizeQuality" />
);
}
});