var StackedAreaChart = React.createClass({ mixins: [ReactRouter.Navigation, ReactRouter.State, SVGChartMixin, ChartDataMixin], canvasHeight: 350, xAxisHeight: 20, maxItems: 10, maxWeeks: 30, words: { actions: { // Item repo: "made to", team: "made by the most active", user: "made by the most active" }, items: { // Item repo: "repositories", team: "teams", user: "users" }, whatHappened: { // Item-Target "user-repo": "working on", "team-repo": "working on", "team-org": "to repositories of", "user-org": "to repositories of", "repo-org": "that were most actively modified by the members of", "user-team": "working on repositories of the", "repo-team": "that were most actively modified by the members of the", "repo-user": "that were most actively modified by" }, targetSuffix: { // Subject of current context repo: "repository", team: "team" }, }, 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]; // NOTE: Rounded bottom corners is a side-effect return ( <StackedArea key={'area-'+ i} item={item} i={i} d={roundPathCorners(this.buildPathD(path), 3)} 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, target = (this.getParams().repo ? 'repo' : this.getParams().team ? 'team' : this.getParams().user ? 'user' : 'org'); subject = this.getParams()[target]; return ( <div ref="container" className="sac"> <div className="whatsgoingon"> This stacked area chart shows <em>the number of commits</em> {words.actions[this.state.item]} <em>{words.items[this.state.item]}</em> {words.whatHappened[this.state.item +'-'+ target]} <em>{subject}</em> {words.targetSuffix[target]} <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} width={this.state.canvasWidth} height={this.xAxisHeight} /> </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({ topMargin: 2, markHeight: 5, 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 + this.topMargin} x2={x} y2={this.props.y + this.topMargin + this.markHeight} /> {!showLabel ? null : <text className="axis-mark" x={x} y={this.props.y + this.topMargin + 14} textAnchor={ta} > {formatDate(week)} </text>} </g> ); }.bind(this); return ( <g ref="axis"> <rect // This rect hides area bouncing glitches x="0" y={this.props.y} width={this.props.width} height={this.props.height} fill="#fff" /> <line className="axis" x1="0" y1={this.props.y + this.topMargin} x2={this.props.width} y2={this.props.y + this.topMargin} /> {this.props.weeks.map(renderMark)} <line className="axis" x1={this.props.width - 1} y1={this.props.y + this.topMargin} x2={this.props.width - 1} y2={this.props.y + this.topMargin + this.markHeight} /> </g> ) } });