diff --git a/app/app.html b/app/app.html index fae4856..f00bf16 100644 --- a/app/app.html +++ b/app/app.html @@ -15,8 +15,11 @@ - - - + + + + + + diff --git a/app/scripts/src/bar_chart.jsx b/app/scripts/src/bar_chart.jsx deleted file mode 100644 index 6de0e49..0000000 --- a/app/scripts/src/bar_chart.jsx +++ /dev/null @@ -1,268 +0,0 @@ -var BarChart = React.createClass({ - mixins: [Router.Navigation, Router.State], - - numElements: 15, - barHeight: 30, - barMargin: 5, - - getInitialState: function() { - return { - currentApi: null, - currentParams: null, - item: this.props.items[0], - sort: 'commits', - rawData: [], - points: [], - oldPoints: [], - min: 0, - max: 1, - canvasWidth: 500, - }; - }, - - componentDidMount: function() { - this.calculateViewBoxWidth(); - window.addEventListener('resize', this.calculateViewBoxWidth); - }, - - componentWillReceiveProps: function(newProps) { - this.setState({ - 'item': newProps.items[0], - 'sort': 'commits' - }, this.fetchData); - }, - - calculateViewBoxWidth: function() { - this.setState({ - canvasWidth: this.refs.svg.getDOMNode().offsetWidth - }); - }, - - 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() { - if (!this.apiParams().item) { - return; - } - if (this.state.currentApi === this.props.api && - this.state.currentParams === JSON.stringify(this.apiParams())) { - return; - } - - console.log('-----> fetching', this.state.currentApi, this.props.api); - this.setState({ - currentApi: this.props.api, - currentParams: JSON.stringify(this.apiParams()) - }, function() { - $.get(this.props.api, this.apiParams(), function(res){ - this.setState({ - rawData: res, - oldPoints: this.state.points - }, this.sort); - }.bind(this)); - }.bind(this)); - }, - - sort: function() { - console.log('-----> sorting'); - 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 ( -
-
- - -
- - {this.state.points.map(this.renderBar)} - -
- ); - }, - - 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 ( - - ); - } -}); - -var Bar = React.createClass({ - mixins: [Router.Navigation], - - getInitialState: function() { - return {lastx: 0, lastw: 0}; - }, - - componentWillReceiveProps: function(newProps) { - console.log("New bar props!", newProps.item, newProps.x, newProps.width); - this.setState({ - lastx: this.props.x, - lastw: this.props.width - }, this.animate); - }, - - animate: function() { - var bar = this.refs.bar.getDOMNode(), - anim = anim = document.createElementNS(SVGNS, 'animate'); - - if (bar.childNodes.length > 0) { - bar.removeChild(bar.childNodes[0]); - } - - anim.setAttributeNS(null, 'attributeType', 'XML'); - anim.setAttributeNS(null, 'attributeName', 'width'); - anim.setAttributeNS(null, 'from', this.state.lastw); - anim.setAttributeNS(null, 'to', this.props.width); - anim.setAttributeNS(null, 'dur', '250ms'); - anim.setAttributeNS(null, 'repeatCount', '1'); - bar.appendChild(anim); - anim.beginElement(); - }, - - 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 ( - - - - {label} - - ); - } -}); diff --git a/app/scripts/src/charts.jsx b/app/scripts/src/charts.jsx deleted file mode 100644 index 5e0112f..0000000 --- a/app/scripts/src/charts.jsx +++ /dev/null @@ -1,89 +0,0 @@ -var SVGNS = 'http://www.w3.org/2000/svg', - fontFamily = 'Helvetica Neue', - fontSize = '16px', - Router = ReactRouter; - -var Chart = { - calculateViewBoxWidth: function() { - this.setState({ - canvasWidth: this.refs.svg.getDOMNode().offsetWidth - }); - }, - - animate: function(ref, attr, from, to) { - var node = ref.getDOMNode(), - anim = anim = document.createElementNS(SVGNS, 'animate'); - - _.map(node.childNodes, function(el) { - node.removeChild(el); - }); - - anim.setAttributeNS(null, 'attributeType', 'XML'); - anim.setAttributeNS(null, 'attributeName', attr); - anim.setAttributeNS(null, 'from', from); - anim.setAttributeNS(null, 'to', to); - anim.setAttributeNS(null, 'dur', '350ms'); - // anim.setAttributeNS(null, 'keySplines', [this.easing, this.easing, this.easing].join(';')); - anim.setAttributeNS(null, 'repeatCount', '1'); - node.appendChild(anim); - anim.beginElement(); - } -}; - -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 ( -
  • {this.names[item]}
  • - ); - }, - - render: function() { - return ( - - ); - } -}); - -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; -} diff --git a/app/scripts/src/charts/animation.jsx b/app/scripts/src/charts/animation.jsx new file mode 100644 index 0000000..f4ee0a6 --- /dev/null +++ b/app/scripts/src/charts/animation.jsx @@ -0,0 +1,28 @@ +var ChartAnimationMixin = { + animDuration: 350, + + animate: function(ref, attr, from, to) { + var node = ref.getDOMNode(), + anim = 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'); + anim.setAttributeNS(null, 'keySplines', this.easing); + anim.setAttributeNS(null, 'repeatCount', '1'); + node.appendChild(anim); + anim.beginElement(); + setTimeout(function() { + node.setAttributeNS(null, attr, to); + }, this.animDuration); + }, + + clearAnimations: function(ref) { + var node = ref.getDOMNode(); + while (node.firstChild) { + node.removeChild(node.firstChild); + } + } +}; diff --git a/app/scripts/src/charts/bar_chart.jsx b/app/scripts/src/charts/bar_chart.jsx new file mode 100644 index 0000000..8d7a9d3 --- /dev/null +++ b/app/scripts/src/charts/bar_chart.jsx @@ -0,0 +1,268 @@ +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: newProps.items[0], + sort: 'commits', + 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); + }, + + 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); + } + + // console.log("Setting points!"); + 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() { + // console.log("Render barchart!", this.state); + return ( +
    +
    + + +
    + + {this.state.points.map(this.renderBar)} + +
    + ); + }, + + 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 ( + + ); + } +}); + +var Bar = React.createClass({ + mixins: [ReactRouter.Navigation, ChartAnimationMixin], + + // easing: '0.075 0.82 0.165 1', // easeOutCirc + easing: '0.175 0.885 0.32 1.275', // easeOutBack + + 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: 2*this.labelPaddingH + }; + }, + + componentDidMount: function() { + this.calculateLabelPosition(); + }, + + componentWillReceiveProps: function(newProps) { + if (_.isEqual(this.props, newProps)) { + return; + } + + // console.log("New bar props!", newProps.item, newProps.x, newProps.width); + 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, + width = this.props.width, + label = this.props.item + ': ' + val, + labelWidth = textWidth(label), + labelOuterWidth = labelWidth + 2*this.labelPaddingH, + labelOffsetWidth = labelOuterWidth + 2*this.labelPaddingH, + labelMarginV = (this.props.height - this.labelOuterHeight)/2, + labelX, + labelY = this.props.y + this.labelOuterHeight + 1, // 1 is magic + barX = this.props.x, + barX2 = barX + width; + + if (offset === 0) { + labelX = 2*this.labelPaddingH; + } else { + if (val < 0) { + if (offset >= labelOffsetWidth) { + labelX = offset - labelOffsetWidth + 2*this.labelPaddingH; + } else { + labelX = offset + 2*this.labelPaddingH; + } + } else { + if (offset + labelOffsetWidth <= this.props.max) { + labelX = offset + 2*this.labelPaddingH; + } else { + labelX = offset - labelOffsetWidth + 2*this.labelPaddingH; + } + } + } + + this.setState({ + labelX: labelX + }, this.animateAll); + }, + + animateAll: function() { + // console.log("animate bar!", this.state, this.props); + this.clearAnimations(this.refs.bar); + this.clearAnimations(this.refs.underlay); + this.animate(this.refs.bar, 'width', this.state.lastBarWidth, this.props.width); + this.animate(this.refs.bar, 'x', this.state.lastBarX, this.props.x); + var ph = this.labelPaddingH; + this.animate(this.refs.underlay, 'x', this.state.lastLabelX - ph, this.state.labelX - ph); + // this.animate(this.refs.label, 'x', this.state.lastLabelX, this.state.labelX); + }, + + render: function() { + var label = this.props.item ? (this.props.item + ': ' + this.props.value) : '', + labelWidth = textWidth(label), + labelOuterWidth = labelWidth + 2*this.labelPaddingH; + + // var width = this.state.lastBarWidth === 0 ? this.props.width : this.state.lastBarWidth; + return ( + + + + + {label} + + ); + } +}); diff --git a/app/scripts/src/charts/charts.jsx b/app/scripts/src/charts/charts.jsx new file mode 100644 index 0000000..2837823 --- /dev/null +++ b/app/scripts/src/charts/charts.jsx @@ -0,0 +1,31 @@ +var SVGChartMixin = { + calculateViewBoxWidth: function() { + this.setState({ + canvasWidth: this.refs.svg.getDOMNode().offsetWidth + }); + } +}; + +var fontFamily = 'Helvetica Neue, Helvetica, sans-serif', + fontSize = 16; + +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; +} diff --git a/app/scripts/src/charts/data.jsx b/app/scripts/src/charts/data.jsx new file mode 100644 index 0000000..7a7a635 --- /dev/null +++ b/app/scripts/src/charts/data.jsx @@ -0,0 +1,44 @@ +var ChartDataMixin = { + apiParams: function() { + var params = _.clone(this.props.params); + params['item'] = this.state.item; + 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)); + } +}; diff --git a/app/scripts/src/charts/selector.jsx b/app/scripts/src/charts/selector.jsx new file mode 100644 index 0000000..2c2993e --- /dev/null +++ b/app/scripts/src/charts/selector.jsx @@ -0,0 +1,28 @@ +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 ( +
  • {this.names[item]}
  • + ); + }, + + render: function() { + return ( + + ); + } +}); diff --git a/app/scripts/src/stacked_area_chart.jsx b/app/scripts/src/charts/stacked_area_chart.jsx similarity index 74% rename from app/scripts/src/stacked_area_chart.jsx rename to app/scripts/src/charts/stacked_area_chart.jsx index e07f392..89edb63 100644 --- a/app/scripts/src/stacked_area_chart.jsx +++ b/app/scripts/src/charts/stacked_area_chart.jsx @@ -1,5 +1,5 @@ var StackedAreaChart = React.createClass({ - mixins: [Router.Navigation, Router.State, Chart], + mixins: [ReactRouter.Navigation, ReactRouter.State, SVGChartMixin, ChartDataMixin], numElements: 10, maxWeeks: 20, @@ -7,20 +7,15 @@ var StackedAreaChart = React.createClass({ getInitialState: function() { return { - currentApi: null, - currentParams: null, item: this.props.items[0], rawData: [], top: [], - max: 1, weeks: [], - canvasWidth: 0, - state: 'initial' + max: 1 }; }, componentDidMount: function() { - this.fetchData(); this.calculateViewBoxWidth(); window.addEventListener('resize', this.calculateViewBoxWidth); }, @@ -34,14 +29,12 @@ var StackedAreaChart = React.createClass({ }, shouldComponentUpdate: function(newProps, newState) { - // console.log("Should update?", newState.state); - if (newState.canvasWidth === 0) { + if (!newState.canvasWidth) { return false; } if (newState.state !== 'newPoints') { return false; } - // console.log("Updating!"); return true; }, @@ -60,37 +53,7 @@ var StackedAreaChart = React.createClass({ this.transitionTo(this.state.item, params); }, - fetchData: function() { - if (!this.apiParams().item) { - return; - } - if (this.state.currentApi === this.props.api && - this.state.currentParams === JSON.stringify(this.apiParams())) { - return; - } - - // console.log('-----> fetching', this.props.api, this.state.item); - this.setState({ - currentApi: this.props.api, - currentParams: JSON.stringify(this.apiParams()), - state: 'loadingData' - }, function() { - $.get(this.props.api, this.apiParams(), function(res){ - this.setState({ - rawData: res, - state: 'newData' - }, this.buildPoints); - }.bind(this)); - }.bind(this)); - }, - - apiParams: function() { - var params = _.clone(this.props.params); - params['item'] = this.state.item; - return params; - }, - - buildPoints: function() { + handleNewData: function() { // Group commits by items var weeksList = _.chain(this.state.rawData) .pluck('week') @@ -139,11 +102,10 @@ var StackedAreaChart = React.createClass({ return _.sum(_.values(weeks[week])); })); - this.setState({ top: top.reverse(), - max: max, weeks: weeks, + max: max, state: 'newPoints' }); }, @@ -153,9 +115,10 @@ var StackedAreaChart = React.createClass({ maxHeight = this.height, maxValue = this.state.max, len = points.length; + var d = _.map(points, function(point, i) { - return 'L'+ Math.floor(i/len*maxWidth) +','+ Math.floor(maxHeight - point); - }); + return 'L'+ Math.floor(i/(len-1)*maxWidth) +','+ Math.floor(maxHeight - point); + }); d.unshift('M0,'+ maxHeight); d.push('L'+ maxWidth +','+ maxHeight +'Z'); @@ -163,7 +126,6 @@ var StackedAreaChart = React.createClass({ }, render: function() { - // console.log("Rendering!"); var maxWidth = this.state.canvasWidth, maxHeight = this.height, rtop = this.state.top.reverse(), @@ -189,12 +151,6 @@ var StackedAreaChart = React.createClass({ .reverse() .value(); - var paths = _.reduce(rtop, function(res, item, i) { - res[item] = _.map(points, function(pair) { - return pair[1][i]; - }); - return res; - }, {}); var paths = _.map(rtop, function(item, i) { var itemPoints = _.map(points, function(pair) { return pair[1][i]; @@ -208,7 +164,6 @@ var StackedAreaChart = React.createClass({ if (item !== null) { colors[item] = Colors2[i]; } - // console.log("Building path for", item, path); return ( + viewBox={"0 0 "+ (this.state.canvasWidth || 0) + " "+ maxHeight}> {areas.reverse()}
      @@ -249,33 +204,28 @@ var StackedAreaChart = React.createClass({ }); var StackedArea = React.createClass({ - mixins: [Chart], - easing: '0.55, 0.055, 0.675, 0.19', + mixins: [ChartAnimationMixin], + easing: '0.175 0.885 0.32 1.275', // easeOutBack getInitialState: function() { - return {lastd: ''}; - }, - - componentDidMount: function() { - // console.log("-- mounted area"); + return {}; }, componentWillReceiveProps: function(newProps) { - // console.log("New area props!", newProps.item); this.setState({ - lastd: this.props.d, - }, this.state.lastd === '' ? null : this.animateAll); + lastd: this.props.d || newProps.d, + }, this.animateAll); }, animateAll: function() { - // console.log("Animating area", this.props.item); + this.clearAnimations(this.refs.path); this.animate(this.refs.path, 'd', this.state.lastd, this.props.d); }, render: function() { return ( );