1
0
Fork 0

Stacked area charts

This commit is contained in:
Gregory Eremin 2015-03-11 19:10:11 +07:00
parent 5f8ace43ef
commit e58046f227
10 changed files with 522 additions and 84 deletions

View File

@ -11,8 +11,12 @@
<body></body> <body></body>
<script src="/bower_components/react/react.js"></script> <script src="/bower_components/react/react.js"></script>
<script src="/bower_components/react-router/build/global/ReactRouter.js"></script> <script src="/bower_components/react-router/build/global/ReactRouter.js"></script>
<script src="/bower_components/lodash/lodash.js"></script>
<script src="/bower_components/jquery/dist/jquery.js"></script> <script src="/bower_components/jquery/dist/jquery.js"></script>
<script src="/scripts/colors.js"></script> <script src="/scripts/colors.js"></script>
<script src="/scripts/charts.js"></script> <script src="/scripts/svground.js"></script>
<script src="/scripts/app.js"></script> <script src="/scripts/compiled/charts.js"></script>
<script src="/scripts/compiled/bar_chart.js"></script>
<script src="/scripts/compiled/stacked_area_chart.js"></script>
<script src="/scripts/compiled/app.js"></script>
</html> </html>

View File

@ -5,6 +5,7 @@
"react": "0.12.2", "react": "0.12.2",
"react-router": "0.12.4", "react-router": "0.12.4",
"jquery": "2.1.3", "jquery": "2.1.3",
"lodash": "3.5.0",
"normalize.css": "3.0.2" "normalize.css": "3.0.2"
} }
} }

View File

@ -87,11 +87,14 @@ var Menu = React.createClass({
return ( return (
<section className="menu"> <section className="menu">
<ul> <ul>
<li key="empact" className="logo-button"> <li key="empact">
<Link to="org" params={this.getParams()}> <Link to="org" params={this.getParams()} className="logo-button">
<div className="logo e">e</div> <div className="logo e">e</div>
<div className="logo mp">mp</div> <div className="logo m">m</div>
<div className="logo act">act</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> </Link>
</li> </li>
<li key="orgs-header" className="nav header">Organizations:</li> <li key="orgs-header" className="nav header">Organizations:</li>
@ -119,9 +122,18 @@ var OrgStats = React.createClass({
var org = Storage.get('org', this.getParams().org); var org = Storage.get('org', this.getParams().org);
return ( return (
<section className="content"> <section className="content">
<InfoBlock image={org.avatar_url} title={org.login} text={org.descr} /> <InfoBlock key={'info-block-org-'+ this.getParams().org}
<BarChart key={this.getParams().team} api="/api/stat/orgs/top" image={org.avatar_url}
params={this.getParams()} items={["repo", "team", "user"]} /> 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> </section>
); );
} }
@ -133,12 +145,18 @@ var TeamStats = React.createClass({
render: function(){ render: function(){
return ( return (
<section className="content"> <section className="content">
<InfoBlock key={"info-block-"+ this.getParams().team} <InfoBlock key={"info-block-team-"+ this.getParams().team}
image="https://media.licdn.com/mpr/mpr/p/8/005/058/14b/0088c48.jpg" image="https://media.licdn.com/mpr/mpr/p/8/005/058/14b/0088c48.jpg"
title={this.getParams().team} title={this.getParams().team}
text={"The most awesome team in "+ this.getParams().org} /> text={"The most awesome team in "+ this.getParams().org} />
<BarChart key={'bar-chart-'+ this.getParams().team} api="/api/stat/teams/top" <BarChart key={'bar-chart-team-'+ this.getParams().team}
params={this.getParams()} items={["repo", "user"]} /> 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> </section>
); );
} }
@ -149,9 +167,16 @@ var UserStats = React.createClass({
render: function(){ render: function(){
return ( return (
<section className="content"> <section className="content">
<InfoBlock title={this.getParams().user} /> <InfoBlock key={'info-block-user-'+ this.getParams().user}
<BarChart key={'bar-chart-'+ this.getParams().user} api="/api/stat/users/top" title={this.getParams().user} />
params={this.getParams()} items={["repo"]} /> <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> </section>
); );
} }
@ -162,9 +187,16 @@ var RepoStats = React.createClass({
render: function(){ render: function(){
return ( return (
<section className="content"> <section className="content">
<InfoBlock title={this.getParams().repo} /> <InfoBlock key={'info-block-repo'+ this.getParams().repo}
<BarChart key={this.getParams().team} api="/api/stat/repos/top" title={this.getParams().repo} />
params={this.getParams()} items={["user", "team"]} /> <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> </section>
); );
} }

View File

@ -1,8 +1,3 @@
var SVGNS = 'http://www.w3.org/2000/svg',
fontFamily = 'Helvetica Neue',
fontSize = '16px',
Router = ReactRouter;
var BarChart = React.createClass({ var BarChart = React.createClass({
mixins: [Router.Navigation, Router.State], mixins: [Router.Navigation, Router.State],
@ -85,8 +80,7 @@ var BarChart = React.createClass({
}, },
apiParams: function() { apiParams: function() {
// Deep copy, but don't use jQuery.extend var params = _.clone(this.props.params);
var params = JSON.parse(JSON.stringify(this.props.params));
params['item'] = this.state.item; params['item'] = this.state.item;
return params; return params;
}, },
@ -178,7 +172,9 @@ var Bar = React.createClass({
labelX = barX + 2*labelPaddingH; labelX = barX + 2*labelPaddingH;
} }
} else { } else {
if (labelOffsetWidth <= barX) { if (barX === offset) {
labelX = barX + width + 2*labelPaddingH;
} else if (labelOffsetWidth <= barX) {
labelX = barX - labelOffsetWidth + 2*labelPaddingH; labelX = barX - labelOffsetWidth + 2*labelPaddingH;
} else { } else {
labelX = barX + width + labelPaddingH; labelX = barX + width + labelPaddingH;
@ -199,58 +195,3 @@ var Bar = React.createClass({
); );
} }
}); });
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 = 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;
}

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;
}

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" />
);
}
});

172
app/scripts/svground.js Normal file
View File

@ -0,0 +1,172 @@
/*
* Source: http://plnkr.co/edit/kGnGGyoOCKil02k04snu?p=info
*/
/*****************************************************************************
* *
* SVG Path Rounding Function *
* Copyright (C) 2014 Yona Appletree *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
* You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* *
*****************************************************************************/
/**
* SVG Path rounding function. Takes an input path string and outputs a path
* string where all line-line corners have been rounded. Only supports absolute
* commands at the moment.
*
* @param pathString The SVG input path
* @param radius The amount to round the corners, either a value in the SVG
* coordinate space, or, if useFractionalRadius is true, a value
* from 0 to 1.
* @param useFractionalRadius If true, the curve radius is expressed as a
* fraction of the distance between the point being curved and
* the previous and next points.
* @returns A new SVG path string with the rounding
*/
function roundPathCorners(pathString, radius, useFractionalRadius) {
function moveTowardsLength(movingPoint, targetPoint, amount) {
var width = (targetPoint.x - movingPoint.x);
var height = (targetPoint.y - movingPoint.y);
var distance = Math.sqrt(width*width + height*height);
return moveTowardsFractional(movingPoint, targetPoint, Math.min(1, amount / distance));
}
function moveTowardsFractional(movingPoint, targetPoint, fraction) {
return {
x: movingPoint.x + (targetPoint.x - movingPoint.x)*fraction,
y: movingPoint.y + (targetPoint.y - movingPoint.y)*fraction
};
}
// Adjusts the ending position of a command
function adjustCommand(cmd, newPoint) {
if (cmd.length > 2) {
cmd[cmd.length - 2] = newPoint.x;
cmd[cmd.length - 1] = newPoint.y;
}
}
// Gives an {x, y} object for a command's ending position
function pointForCommand(cmd) {
return {
x: parseFloat(cmd[cmd.length - 2]),
y: parseFloat(cmd[cmd.length - 1]),
};
}
// Split apart the path, handing concatonated letters and numbers
var pathParts = pathString
.split(/[,\s]/)
.reduce(function(parts, part){
var match = part.match("([a-zA-Z])(.+)");
if (match) {
parts.push(match[1]);
parts.push(match[2]);
} else {
parts.push(part);
}
return parts;
}, []);
// Group the commands with their arguments for easier handling
var commands = pathParts.reduce(function(commands, part) {
if (parseFloat(part) == part && commands.length) {
commands[commands.length - 1].push(part);
} else {
commands.push([part]);
}
return commands;
}, []);
// The resulting commands, also grouped
var resultCommands = [];
if (commands.length > 1) {
var startPoint = pointForCommand(commands[0]);
// Handle the close path case with a "virtual" closing line
var virtualCloseLine = null;
if (commands[commands.length - 1][0] == "Z" && commands[0].length > 2) {
virtualCloseLine = ["L", startPoint.x, startPoint.y];
commands[commands.length - 1] = virtualCloseLine;
}
// We always use the first command (but it may be mutated)
resultCommands.push(commands[0]);
for (var cmdIndex=1; cmdIndex < commands.length; cmdIndex++) {
var prevCmd = resultCommands[resultCommands.length - 1];
var curCmd = commands[cmdIndex];
// Handle closing case
var nextCmd = (curCmd == virtualCloseLine)
? commands[1]
: commands[cmdIndex + 1];
// Nasty logic to decide if this path is a candidite.
if (nextCmd && prevCmd && (prevCmd.length > 2) && curCmd[0] == "L" && nextCmd.length > 2 && nextCmd[0] == "L") {
// Calc the points we're dealing with
var prevPoint = pointForCommand(prevCmd);
var curPoint = pointForCommand(curCmd);
var nextPoint = pointForCommand(nextCmd);
// The start and end of the cuve are just our point moved towards the previous and next points, respectivly
var curveStart, curveEnd;
if (useFractionalRadius) {
curveStart = moveTowardsFractional(curPoint, prevCmd.origPoint || prevPoint, radius);
curveEnd = moveTowardsFractional(curPoint, nextCmd.origPoint || nextPoint, radius);
} else {
curveStart = moveTowardsLength(curPoint, prevPoint, radius);
curveEnd = moveTowardsLength(curPoint, nextPoint, radius);
}
// Adjust the current command and add it
adjustCommand(curCmd, curveStart);
curCmd.origPoint = curPoint;
resultCommands.push(curCmd);
// The curve control points are halfway between the start/end of the curve and
// the original point
var startControl = moveTowardsFractional(curveStart, curPoint, .5);
var endControl = moveTowardsFractional(curPoint, curveEnd, .5);
// Create the curve
var curveCmd = ["C", startControl.x, startControl.y, endControl.x, endControl.y, curveEnd.x, curveEnd.y];
// Save the original point for fractional calculations
curveCmd.origPoint = curPoint;
resultCommands.push(curveCmd);
} else {
// Pass through commands that don't qualify
resultCommands.push(curCmd);
}
}
// Fix up the starting point and restore the close path if the path was orignally closed
if (virtualCloseLine) {
var newStartPoint = pointForCommand(resultCommands[resultCommands.length-1]);
resultCommands.push(["Z"]);
adjustCommand(resultCommands[0], newStartPoint);
}
} else {
resultCommands = commands;
}
return resultCommands.reduce(function(str, c){ return str + c.join(" ") + " "; }, "");
}

View File

@ -18,6 +18,32 @@
fill: rgba(255, 255, 255, .7); fill: rgba(255, 255, 255, .7);
} }
.sachart-container {
box-sizing: border-box;
float: left;
width: 50%;
padding: 0 20px 20px 0;
}
.sachart-container .legend {
display: block;
margin: 0;
padding: 0;
}
.color-dot {
display: block;
float: left;
content: '';
width: 16px;
height: 16px;
margin: 1px 5px 0 0;
border-radius: 8px;
}
.sachart-container .legend li {
display: inline-block;
margin: 10px 15px 0 0;
}
.filters { .filters {
width: 100%; width: 100%;
} }

View File

@ -1,7 +1,7 @@
.logo-button { .logo-button {
display: inline-block; display: inline-block;
width: auto; width: auto;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, .6); box-shadow: 1px 1px 1px 0 rgba(0, 0, 0, 0.2), 1px 1px 0 0 #faca50;
border-radius: 5px; border-radius: 5px;
} }
@ -13,7 +13,7 @@
font-size: 22px; font-size: 22px;
padding: 4px 5px 6px; padding: 4px 5px 6px;
color: #fff; color: #fff;
text-shadow: 0 -1px 0 rgba(0, 0, 0, .5); text-shadow: 0 -1px 0 rgba(0, 0, 0, .3);
font-weight: 600; font-weight: 600;
} }

View File

@ -54,7 +54,7 @@ where
c.week >= :from and c.week >= :from and
c.week <= :to c.week <= :to
group by item, week group by item, week
order by week, commite desc` order by week, commits desc`
const teamTopQuery = ` const teamTopQuery = `
select select