Package structure and building

This commit is contained in:
2015-03-17 15:57:45 +07:00
parent d785af38c3
commit 03bcc505dc
22 changed files with 57 additions and 58 deletions
+309
View File
@@ -0,0 +1,309 @@
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=',
usersURL: '/api/users?org=',
getInitialState: function() {
return {
orgs: [],
org: null,
teams: [],
team: null
};
},
componentDidMount: function() {
this.loadOrgs();
this.loadTeams();
this.loadUsers();
},
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));
},
loadUsers: function() {
$.get(this.usersURL + this.getParams().org, function(res){
this.setState({users: res});
if (res !== null) {
for (var i = 0; i < res.length; i++) {
var user = res[i];
Storage.set('user', user.login, user);
}
}
}.bind(this));
},
render: function(){
return (
<div className="master">
<div className="app" id="app">
<Menu orgs={this.state.orgs} teams={this.state.teams} />
<Router.RouteHandler />
</div>
</div>
);
}
});
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}}
query={this.getQuery()}>
{org.login}
</Link>
</li>
)
}.bind(this);
var renderTeam = function(team) {
return (
<li key={'team-'+ team.name} className="nav team">
<Link to="team"
params={{org: team.owner, team: team.name}}
query={this.getQuery()}>
{team.name}
</Link>
</li>
)
}.bind(this);
return (
<div className="menu">
<ul>
<li className="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 className="nav header">Organizations:</li>
{this.props.orgs.map(renderOrg)}
<li className="nav header">Teams:</li>
{this.props.teams.map(renderTeam)}
</ul>
</div>
);
}
});
var Org = React.createClass({
render: function(){
return (
<Router.RouteHandler />
);
}
});
var Dashboard = React.createClass({
mixins: [Router.State],
render: function(){
var p = this.getParams(),
infoImage, infoTitle,
bcApi, bcItems,
sacApi, sacItems;
if (p.team) {
infoTitle = p.team;
bcApi = '/api/stat/teams/top';
bcItems = ['repo', 'user'],
sacApi = '/api/stat/teams/activity';
sacItems = ['user', 'repo'];
} else if (p.user) {
var info = Storage.get('user', p.user);
infoImage = info ? info.avatar_url : null;
infoTitle = info && info.name ? info.name : p.user;
bcApi = '/api/stat/users/top';
bcItems = ['repo'],
sacApi = '/api/stat/users/activity';
sacItems = ['repo'];
} else if (p.repo) {
infoTitle = p.repo;
bcApi = '/api/stat/repos/top';
bcItems = ['user', 'team'],
sacApi = '/api/stat/repos/activity';
sacItems = ['user', 'team'];
} else {
var info = Storage.get('org', p.org);
infoImage = info.avatar_url;
infoTitle = info.login;
bcApi = '/api/stat/orgs/top';
bcItems = ['repo', 'team', 'user'],
sacApi = '/api/stat/orgs/activity';
sacItems = ['team', 'user', 'repo'];
}
return (
<div className="content">
<InfoBlock image={infoImage} title={infoTitle} />
<BarChart api={bcApi} params={this.getParams()} items={bcItems} />
<StackedAreaChart api={sacApi} params={this.getParams()} items={sacItems} />
</div>
);
}
});
var NotFound = React.createClass({
render: function(){
return (
<div className="content">NOT FOUND :(</div>
);
}
});
var SelectOrg = React.createClass({
render: function(){
return (
<div className="content">Please select organization from the menu!</div>
);
}
});
var InfoBlock = React.createClass({
render: function() {
return (
<div className="info-block">
<div className={'img'+ (this.props.image ? '' : ' empty')}
style={{backgroundImage: "url("+ (this.props.image || '') +")"}} />
<h1>{this.props.title}</h1>
</div>
)
}
});
var WeekIntervalSelector = React.createClass({
mixins: [ReactRouter.Navigation, ReactRouter.State],
getInitialState: function() {
var ms = 1000,
daySeconds = 86400,
weekSeconds = daySeconds*7,
today = new Date(),
sunday = new Date(today - daySeconds*ms*today.getDay()),
perfectSunday = new Date(Date.UTC(sunday.getFullYear(), sunday.getMonth(), sunday.getDate())),
lastWeek = perfectSunday.setHours(0)/ms,
firstWeek = lastWeek - 51*weekSeconds;
var weeks = [];
for (var i = lastWeek; i >= firstWeek; i -= weekSeconds) {
weeks.push(i);
};
return {
weeks: weeks.sort()
};
},
handleChange: function(thing, e) {
var params = this.getQuery();
params[thing.slice(0, 1)] = e.target.value/100;
this.transitionTo(document.location.pathname, null, params);
},
render: function() {
var daySeconds = 86400,
weekSeconds = daySeconds*7,
lastWeek = this.state.weeks[this.state.weeks.length-1],
from = (this.getQuery().f ? parseInt(this.getQuery().f, 10)*100 : lastWeek - 7*weekSeconds),
to = (this.getQuery().t ? parseInt(this.getQuery().t, 10)*100 : lastWeek);
var weeksBefore = _(this.state.weeks)
.filter(function(week) {
return week < to;
})
.reverse()
.value();
var weeksAfter = _(this.state.weeks)
.filter(function(week) {
return week > from;
})
.reverse()
.value();
var renderOption = function(ts) {
return (
<option key={ts} value={ts}>{formatDate(ts, true)}</option>
);
};
return (
<div className="week-selector">
<span>from</span>
<div ref="from" className="selector">
<em ref="label">{formatDate(from)}</em>
<select ref="select" value={from} onChange={this.handleChange.bind(this, 'from')}>
{weeksBefore.map(renderOption)}
</select>
</div>
<span>to</span>
<div ref="to" className="selector">
<em ref="label">{formatDate(to)}</em>
<select ref="select" value={to} onChange={this.handleChange.bind(this, 'to')}>
{weeksAfter.map(renderOption)}
</select>
</div>
</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={Dashboard} />
<Router.Route name="team" path="teams/:team" handler={Dashboard} />
<Router.Route name="user" path="users/:user" handler={Dashboard} />
<Router.Route name="repo" path="repos/:repo" handler={Dashboard} />
</Router.Route>
</Router.Route>
];
Router.run(routes, Router.HistoryLocation, function(Handler) {
React.render(<Handler />, document.body);
});
+33
View File
@@ -0,0 +1,33 @@
var ChartAnimationMixin = {
animDuration: 350,
animate: function(ref, attr, from, to) {
var node = ref.getDOMNode(),
anim = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
anim.setAttributeNS(null, 'attributeType', 'XML');
anim.setAttributeNS(null, 'attributeName', attr);
anim.setAttributeNS(null, 'values', from +';'+ to);
anim.setAttributeNS(null, 'dur', this.animDuration +'ms');
anim.setAttributeNS(null, 'calcMode', 'spline');
// Easings to consider:
// easeOutCirc: 0.075 0.82 0.165 1
// easeOutBack: 0.175 0.885 0.32 1.275
// easeInOutCubic: 0.645 0.045 0.355 1
anim.setAttributeNS(null, 'keySplines', '0.175 0.885 0.32 1.275');
anim.setAttributeNS(null, 'repeatCount', '1');
anim.addEventListener('endEvent', function() {
node.setAttributeNS(null, attr, to);
});
node.appendChild(anim);
anim.beginElement();
},
clearAnimations: function(ref) {
var node = ref.getDOMNode();
while (node.firstChild && node.firstChild.nodeName === 'animate') {
node.removeChild(node.firstChild);
}
}
};
+278
View File
@@ -0,0 +1,278 @@
var BarChart = React.createClass({
mixins: [ReactRouter.Navigation, ReactRouter.State, SVGChartMixin, ChartDataMixin],
sorts: ['commits', 'delta'],
numElements: 15,
barHeight: 30,
barMargin: 5,
getInitialState: function() {
return {
item: this.props.items[0],
sort: 'commits',
rawData: [],
points: [],
min: 0,
max: 1
};
},
componentDidMount: function() {
this.calculateViewBoxWidth();
window.addEventListener('resize', this.calculateViewBoxWidth);
this.componentWillReceiveProps(this.props);
},
componentWillReceiveProps: function(newProps) {
this.setState({
item: (_.isEqual(newProps.items, this.props.items)
? this.state.item
: newProps.items[0]),
state: 'newProps'
}, this.fetchData);
},
shouldComponentUpdate: function(newProps, newState) {
if (!newState.canvasWidth) {
return false;
}
if (newState.state !== 'newPoints') {
return false;
}
return true;
},
handleFilter: function(thing, i) {
if (thing === 'item' && this.props.items[i] !== this.state.item) {
this.setState({
item: this.props.items[i],
state: 'newProps'
}, this.fetchData);
} else if (thing === 'sort' && this.sorts[i] !== this.state.sort) {
this.setState({
sort: this.sorts[i],
state: 'newProps'
}, this.handleNewData);
}
},
handleClick: function(point) {
var params = {org: this.getParams().org};
params[this.state.item] = point.item;
this.transitionTo(this.state.item, params, this.getQuery());
},
handleNewData: function() {
var min = 0, max = 1;
var points = _.chain(this.state.rawData)
.sort(function(a, b) {
return Math.abs(b[this.state.sort]) - Math.abs(a[this.state.sort]);
}.bind(this))
.take(this.numElements)
.value();
for (var i = points.length; i < this.numElements; i++) {
var point = {};
point[this.state.sort] = 0;
points.push(point);
}
this.setState({
points: points,
min: _.min(points, this.state.sort)[this.state.sort],
max: _.max(points, this.state.sort)[this.state.sort],
state: 'newPoints'
});
},
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() {
var words = {
items: {
repo: 'repositories',
team: 'teams',
user: 'contributors'
},
item: {
repo: 'repository',
team: 'team'
},
actions: {
repo: 'which were the most attended by',
team: 'which were the most active working on',
user: 'which were the most active working on'
}
},
who = this.getParams().repo || this.getParams().team || this.getParams().user || this.getParams().org;
var params = Object.keys(this.getParams());
params.splice(params.indexOf('org'), 1);
var subject = params[0];
return (
<div className="barchart-container">
<div className="whatsgoingon">
This bar chart represents <em>{words.items[this.state.item]}</em> {words.actions[this.state.item]} <em>{who}</em> {words.item[subject]} <WeekIntervalSelector />
</div>
<div className="filters">
<Selector thing="sort"
title="Show"
items={this.sorts}
value={this.state.sort}
onChange={this.handleFilter.bind(this, 'sort')} />
<Selector thing="item"
title="Grouped by"
items={this.props.items}
value={this.state.item}
onChange={this.handleFilter.bind(this, 'item')} />
</div>
<svg ref="svg" className="barchart" key="barchart-svg"
width="100%" height={this.height()}
viewBox={"0 0 "+ (this.state.canvasWidth || 0) + " "+ 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.floor(Math.abs(val)/max2*maxWidth),
height = this.barHeight,
offset = (min < 0 ? -min : 0)/max2*maxWidth,
x = (min >= 0 ? 0 : offset - (val >= 0 ? 0 : width)),
y = this.y(i);
return (
<Bar key={'bar-'+ i}
item={point.item}
value={val}
color={Colors[i]}
x={x}
y={y}
offset={offset}
width={width}
height={height}
max={maxWidth}
onClick={this.handleClick.bind(this, point)} />
);
}
});
var Bar = React.createClass({
mixins: [ReactRouter.Navigation, ChartAnimationMixin],
height: 30,
labelPaddingH: 5, // Label horizontal padding
labelPaddingV: 2, // Label vertical padding
labelMarginV: 5, // Same as padding
labelHeight: 16, // Text size
labelOuterHeight: 20, // labelHeight + 2*labelPaddingV,
getInitialState: function() {
return {
labelX: 0,
lastLabelX: this.labelPaddingH
};
},
componentDidMount: function() {
this.calculateLabelPosition();
},
componentWillReceiveProps: function(newProps) {
if (_.isEqual(this.props, newProps)) {
return;
}
this.setState({
lastBarX: (this.props.x !== undefined ? this.props.x : newProps.x),
lastBarWidth: (this.props.width !== undefined ? this.props.width : newProps.width),
lastLabelX: this.state.labelX
}, this.calculateLabelPosition);
},
calculateLabelPosition: function() {
var val = this.props.value,
offset = this.props.offset,
label = this.props.item + ': ' + numberFormat(val),
labelWidth = textWidth(label),
labelOffsetWidth = labelWidth + 2*this.labelPaddingH,
labelX;
if (offset === 0) {
labelX = this.labelPaddingH;
} else {
if (val < 0) {
if (offset >= labelOffsetWidth) {
labelX = offset - labelOffsetWidth + this.labelPaddingH;
} else {
labelX = offset + this.labelPaddingH;
}
} else {
if (offset + labelOffsetWidth <= this.props.max) {
labelX = offset + this.labelPaddingH;
} else {
labelX = offset - labelOffsetWidth + this.labelPaddingH;
}
}
}
this.setState({
labelX: labelX,
barWidth: (this.props.item && this.props.width < 5 ? 5 : this.props.width)
}, this.animateAll);
},
animateAll: function() {
this.clearAnimations(this.refs.bar);
this.clearAnimations(this.refs.label);
this.animate(this.refs.bar, 'width', this.state.lastBarWidth, this.state.barWidth);
this.animate(this.refs.bar, 'x', this.state.lastBarX, this.props.x);
this.animate(this.refs.label, 'x', this.state.lastLabelX, this.state.labelX);
},
render: function() {
var label = this.props.item ? (this.props.item + ': ' + numberFormat(this.props.value)) : '',
labelWidth = textWidth(label),
labelOuterWidth = (labelWidth == 0 ? 0 : labelWidth + 2*this.labelPaddingH),
barX = (this.state.lastBarX && this.state.lastBarX !== this.props.x
? this.state.lastBarX
: this.props.x),
barWidth = (this.state.lastBarWidth && this.state.lastBarWidth !== this.state.barWidth
? this.state.lastBarWidth
: this.state.barWidth);
return (
<g onClick={this.props.onClick}>
<rect ref="bar" className="bar"
fill={this.props.color}
width={barWidth}
height={this.props.height}
x={barX}
y={this.props.y}
rx="2"
ry="2" />
<text ref="label" className="label"
x={this.state.labelX}
y={this.props.y + this.labelMarginV + this.labelHeight}>
{label}
</text>
</g>
);
}
});
+36
View File
@@ -0,0 +1,36 @@
var SVGChartMixin = {
calculateViewBoxWidth: function() {
this.setState({
canvasWidth: this.refs.svg.getDOMNode().offsetWidth
});
}
};
var fontFamily = 'Helvetica Neue, Helvetica, sans-serif',
fontSize = 16;
function numberFormat(num) {
// FIXME: Not supported in IE10- and Safari
return Intl.NumberFormat().format(num);
}
function textWidth(str) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', "svg");
text = document.createElementNS('http://www.w3.org/2000/svg', "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 +'px';
svg.appendChild(text);
document.body.appendChild(svg);
var box = text.getBBox();
document.body.removeChild(svg);
return box.width;
}
+48
View File
@@ -0,0 +1,48 @@
var ChartDataMixin = {
apiParams: function() {
var params = _.clone(this.props.params);
params['item'] = this.state.item;
if (this.getQuery().f && this.getQuery().t) {
params['from'] = parseInt(this.getQuery().f, 10)*100;
params['to'] = parseInt(this.getQuery().t, 10)*100;
}
return params;
},
apiParamsHash: function() {
var params = this.apiParams();
var pairs = _.chain(params)
.keys()
.sort()
.map(function(param) {
return [param, params[param]];
})
.value();
return JSON.stringify(pairs);
},
fetchData: function() {
var paramsHash = this.apiParamsHash();
if (!this.state.item) {
return;
}
if (this.state.currentApi === this.props.api && this.state.currentParams === paramsHash) {
return;
}
// console.log('-----> fetching', this.props.api, this.state.item);
this.setState({
currentApi: this.props.api,
currentParams: paramsHash,
state: 'loadingData'
}, function() {
$.get(this.props.api, this.apiParams(), function(res){
this.setState({
rawData: res || [],
state: 'newData'
}, this.handleNewData);
}.bind(this));
}.bind(this));
}
};
+31
View File
@@ -0,0 +1,31 @@
var Selector = React.createClass({
names: {
"repo": "Repositories",
"team": "Teams",
"user": "Users",
"commits": "Commits",
"delta": "Delta"
},
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 (
<div className="selector">
<div className="title">{this.props.title}</div>
<ul className="items">
{this.props.items.map(this.renderItem)}
</ul>
</div>
);
}
});
+451
View File
@@ -0,0 +1,451 @@
var StackedAreaChart = React.createClass({
mixins: [ReactRouter.Navigation, ReactRouter.State, SVGChartMixin, ChartDataMixin],
canvasHeight: 350,
xAxisHeight: 20,
maxItems: 10,
maxWeeks: 30,
words: {
items: {
repo: 'repositories',
team: 'teams',
user: 'contributors'
},
item: {
repo: 'repository',
team: 'team'
},
actions: {
repo: 'which were the most attended by',
team: 'which were the most active working on',
user: 'which were the most active working on'
}
},
getInitialState: function() {
return {
item: this.props.items[0],
rawData: [],
topItems: [],
weeklyData: [],
maxCommitsPerWeek: 1
};
},
componentDidMount: function() {
this.calculateViewBoxWidth();
window.addEventListener('resize', this.calculateViewBoxWidth);
},
componentWillReceiveProps: function(newProps) {
// If new items are the same as old then don't reset current item
this.setState({
item: (_.isEqual(newProps.items, this.props.items)
? this.state.item
: newProps.items[0]),
state: 'loadingData'
}, this.fetchData);
},
shouldComponentUpdate: function(newProps, newState) {
// Don't re-render unless canvas width is calculated
if (!newState.canvasWidth) {
return false;
}
// We're working with animations here so we render only in one particular state
if (newState.state !== 'pleaseRender') {
return false;
}
return true;
},
handleFilter: function(thing, i) {
if (this.props.items[i] !== this.state.item) {
this.setState({
item: this.props.items[i],
state: 'loadingData'
}, this.fetchData);
}
},
handleClick: function(item) {
var params = {org: this.getParams().org};
params[this.state.item] = item;
this.transitionTo(this.state.item, params, this.getQuery());
},
handleFocusIn: function(i) {
var node = this.refs.container.getDOMNode();
node.className = 'sac focused item-'+ i;
},
handleFocusOut: function() {
var node = this.refs.container.getDOMNode();
node.className = 'sac';
},
handleNewData: function() {
// [week, ...]
var weeksList = _(this.state.rawData).pluck('week').uniq().sort().reverse().take(this.maxWeeks).value();
if (weeksList.length < 2) {
this.setState({
weeks: [],
state: 'pleaseRender'
});
return;
}
// {item: commits, ...}
var commitsByItem = _.reduce(this.state.rawData, function(res, el) {
if (weeksList.indexOf(el.week) === -1) {
return res;
}
if (res[el.item] === undefined) {
res[el.item] = el.commits;
} else {
res[el.item] += el.commits;
}
return res;
}, {});
// [item, ...]
var topItems = _(_.pairs(commitsByItem)) // Take [item, count] pairs from counts object
.sortBy(1).reverse() // sort them by count (descending)
.take(this.maxItems) // take first N pairs
.pluck(0) // keep only items, omit the counts
.value();
for (var i = topItems.length; i < this.maxItems; i++) {
topItems[i] = null;
};
// {week: {item: commits, ...}, ...}
var weeklyData = _.reduce(this.state.rawData, function(res, el) {
if (weeksList.indexOf(el.week) === -1) {
return res;
}
if (res[el.week] === undefined) {
res[el.week] = {};
}
if (topItems.indexOf(el.item) > -1) {
res[el.week][el.item] = el.commits;
}
return res;
}, {});
var maxCommitsPerWeek = _.max(_.map(weeksList, function(week) {
return _.sum(_.values(weeklyData[week]));
}));
this.setState({
topItems: topItems,
weeklyData: weeklyData,
maxCommitsPerWeek: maxCommitsPerWeek,
state: 'pleaseRender'
});
},
buildPathD: function(dots) {
var maxWidth = this.state.canvasWidth,
maxHeight = this.canvasHeight;
var dots = this.extendDotsWithCoordinates(dots);
var first = dots.shift(); // Don't draw a line to the first dot, it should be a move
var d = _.map(dots, function(dot){ return 'L'+ dot.x +','+ dot.y; });
d.unshift('M'+ first.x +','+ first.y); // Prepend first move
d.push('L'+ maxWidth +','+ maxHeight); // Draw a line to the bottom right corner
d.push('L0,'+ maxHeight +' Z'); // And then to a bottom left corner
return d.join(' ');
},
extendDotsWithCoordinates: function(dots) {
var maxWidth = this.state.canvasWidth,
maxHeight = this.canvasHeight,
maxValue = this.state.maxCommitsPerWeek,
len = dots.length;
return _.map(dots, function(dot, i) {
dot.x = i/(len-1)*maxWidth;
dot.y = maxHeight - dot.norm*maxHeight*0.96;
return dot;
});
},
render: function() {
var renderArea = function(pair, i) {
var item = pair[0], path = pair[1];
return (
<StackedArea key={'area-'+ i}
item={item} i={i}
d={roundPathCorners(this.buildPathD(path), 4)}
color={Colors[i]}
onMouseOver={this.handleFocusIn.bind(this, i)} />
);
}.bind(this);
var renderDot = function(item, i, dot, j) {
if (dot.val === 0) {
return null;
}
var maxWidth = this.state.canvasWidth,
maxHeight = this.canvasHeight,
radius = 10,
x = dot.x,
y = dot.y;
if (x < radius) {
x = radius
} else if (x > maxWidth - radius) {
x = maxWidth - radius;
}
if (y < radius) {
y = radius;
} else if (y > maxHeight - radius) {
y = maxHeight - radius;
}
return (
<Dot key={'dot-'+ i +'-'+ j}
item={item} i={i}
value={dot.val}
x={x}
y={y}
onMouseOver={this.handleFocusIn.bind(this, i)} />
);
}.bind(this);
var renderLegend = function(item, i){
return (
<li key={'legend-'+ item}
className={'label label-'+ i}
onMouseOver={this.handleFocusIn.bind(this, i)}
onMouseOut={this.handleFocusOut.bind(this, i)}
onClick={this.handleClick.bind(this, item)}
>
<div className="color-dot" style={{backgroundColor: Colors[i]}}></div>
{item}
</li>
);
}.bind(this);
var maxWidth = this.state.canvasWidth,
maxHeight = this.canvasHeight,
top = this.state.topItems,
max = this.state.maxCommitsPerWeek;
// [week, [dot, ...]]
var dotsByWeek = _(this.state.weeklyData)
.map(function(items, week) {
var values = _.map(top, function(item) {
return items[item] || 0;
});
var sum = 0;
var dots = _.map(values, function(val) {
sum += val/max;
return {
val: val,
norm: sum
};
});
return [parseInt(week, 10), dots];
})
.sort(0)
.reverse()
.take(this.maxWeeks)
.reverse()
.value();
// [item, [dot, ...]]
var dotsByItem = _.map(top, function(item, i) {
var dots = _.map(dotsByWeek, function(pair) {
var dots = pair[1];
return dots[i];
});
return[item, dots];
});
var renderedDots = _.map(dotsByItem, function(pair, i) {
var item = pair[0], path = pair[1];
var dots = this.extendDotsWithCoordinates(path);
return dots.map(renderDot.bind(this, item, i));
}.bind(this));
var legend = _(dotsByItem).pluck(0).filter(function(el){ return el !== null; }).value();
// Text generation stuff
var words = this.words,
who = this.getParams().repo ||
this.getParams().team ||
this.getParams().user ||
this.getParams().org;
var params = Object.keys(this.getParams());
params.splice(params.indexOf('org'), 1);
var subject = params[0];
return (
<div ref="container" className="sac">
<div className="whatsgoingon">
This stacked area chart represents <em>{words.items[this.state.item]}</em> {words.actions[this.state.item]} <em>{who}</em> {words.item[subject]} <WeekIntervalSelector />
</div>
<div className="filters">
<Selector thing="sort"
title="Show"
items={['commits']}
value={'commits'} />
<Selector thing="item"
title="Grouped by"
items={this.props.items}
value={this.state.item}
onChange={this.handleFilter.bind(this, 'item')} />
</div>
<svg ref="svg" className="sachart" key="sachart-svg"
width="100%"
height={this.canvasHeight + this.xAxisHeight}
viewBox={"0 0 "+ (this.state.canvasWidth || 0) + " "+ (this.canvasHeight + this.xAxisHeight)}
onMouseOut={this.handleFocusOut}
>
<g ref="areas">{dotsByItem.map(renderArea).reverse()}</g>
<g ref="dots">{renderedDots}</g>
<Axis
weeks={_.pluck(dotsByWeek, 0)}
y={this.canvasHeight + 3}
width={this.state.canvasWidth} />
</svg>
<ul className="legend">
{legend.map(renderLegend)}
</ul>
</div>
);
}
});
var StackedArea = React.createClass({
mixins: [ChartAnimationMixin],
getInitialState: function() {
return {};
},
componentWillReceiveProps: function(newProps) {
this.setState({
lastd: this.props.d || newProps.d,
}, this.animateAll);
},
animateAll: function() {
this.clearAnimations(this.refs.path);
this.animate(this.refs.path, 'd', this.state.lastd, this.props.d);
},
render: function() {
return (
<path ref="path"
className={'path path-'+ this.props.i}
d={this.state.lastd || this.props.d}
fill={this.props.color}
onMouseOver={this.props.onMouseOver}
shapeRendering="optimizeQuality" />
);
}
});
var Dot = React.createClass({
mixins: [ChartAnimationMixin],
radius: 10,
getInitialState: function() {
return {};
},
componentWillReceiveProps: function(newProps) {
this.setState({
lastY: this.props.y || newProps.y
}, this.animateAll);
},
animateAll: function() {
this.clearAnimations(this.refs.dot);
this.animate(this.refs.dot, 'cy', this.state.lastY, this.props.y);
},
render: function() {
return (
<g className={'dot dot-'+ this.props.i} onMouseOver={this.props.onMouseOver}>
<circle ref="dot"
cx={this.props.x}
cy={this.state.lastY || this.props.y}
r={this.radius} />
<text ref="value"
x={this.props.x}
y={this.props.y+4}>
{this.props.value}
</text>
</g>
);
}
});
var Axis = React.createClass({
render: function() {
if (this.props.weeks.length === 0) {
return null;
}
var renderMark = function(week, i) {
var len = this.props.weeks.length,
x = i/(len - 1)*this.props.width,
showLabel,
ta = (i === 0 // Text anchor for the leftmost label
? 'start'
: (i === len - 1 // Text anchor for the rightmost label
? 'end'
: 'middle')); // Text anchor for other labels
// Thin out labels
if (len > 20) {
showLabel = (i % 3 === 0);
} else if (len > 10) {
showLabel = (i % 2 === 0);
} else {
showLabel = true;
}
return (
<g key={'mark-'+ i}>
<line className="axis"
x1={x}
y1={this.props.y}
x2={x}
y2={this.props.y + 4} />
{!showLabel ? null : <text className="axis-mark"
x={x}
y={this.props.y + 15}
textAnchor={ta}
>
{formatDate(week)}
</text>}
</g>
);
}.bind(this);
return (
<g ref="axis">
<line className="axis"
x1="0"
y1={this.props.y}
x2={this.props.width}
y2={this.props.y} />
{this.props.weeks.map(renderMark)}
<line className="axis"
x1={this.props.width - 1}
y1={this.props.y}
x2={this.props.width - 1}
y2={this.props.y + 4} />
</g>
)
}
});