From 3762507c27355872d5073963bb4f262c03b965a8 Mon Sep 17 00:00:00 2001 From: Radek Stepan Date: Wed, 20 Jan 2016 16:00:55 +0100 Subject: [PATCH] show a chart --- src/js/components/Chart.jsx | 138 ++++++++++++++++++++++++++++++++- src/js/stores/projectsStore.js | 113 ++++++++++++++------------- 2 files changed, 198 insertions(+), 53 deletions(-) diff --git a/src/js/components/Chart.jsx b/src/js/components/Chart.jsx index 83ec7dc..021a89b 100644 --- a/src/js/components/Chart.jsx +++ b/src/js/components/Chart.jsx @@ -6,6 +6,9 @@ d3Tip(d3); import Format from '../mixins/Format.js'; +import lines from '../modules/chart/lines.js'; +import axes from '../modules/chart/axes.js'; + export default React.createClass({ displayName: 'Chart.jsx', @@ -32,7 +35,140 @@ export default React.createClass({ }, componentDidMount() { - console.log(this.refs); + let milestone = this.props.milestone, + issues = milestone.issues; + // Total number of points in the milestone. + let total = issues.open.size + issues.closed.size; + + // An issue may have been closed before the start of a milestone. + let head = issues.closed.list[0].closed_at; + if (issues.length && milestone.created_at > head) { + // This is the new start. + milestone.created_at = head; + } + + // Actual, ideal & trend lines. + let actual = lines.actual(issues.closed.list, milestone.created_at, total); + let ideal = lines.ideal(milestone.created_at, milestone.due_on, total); + let trend = lines.trend(actual, milestone.created_at, milestone.due_on); + + // Get available space. + let { height, width } = this.refs.el.getBoundingClientRect(); + + let margin = { 'top': 30, 'right': 30, 'bottom': 40, 'left': 50 }; + width -= margin.left + margin.right; + height -= margin.top + margin.bottom; + + // Scales. + let x = d3.time.scale().range([ 0, width ]); + let y = d3.scale.linear().range([ height, 0 ]); + + // Axes. + let xAxis = axes.horizontal(height, x); + let yAxis = axes.vertical(width, y); + + // Line generator. + let line = d3.svg.line() + .interpolate("linear") + .x((d) => x(new Date(d.date))) // convert to Date only now + .y((d) => y(d.points)); + + // Get the minimum and maximum date, and initial points. + let first = ideal[0], last = ideal[ideal.length - 1]; + x.domain([ new Date(first.date), new Date(last.date) ]); + y.domain([ 0, first.points ]).nice(); + + // Add an SVG element with the desired dimensions and margin. + let svg = d3.select(this.refs.el).append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // Add the clip path so that lines are not drawn outside of the boundary. + svg.append("defs").append("svg:clipPath") + .attr("id", "clip") + .append("svg:rect") + .attr("id", "clip-rect") + .attr("x", 0) + .attr("y", 0) + .attr("width", width) + .attr("height", height); + + // Add the days x-axis. + svg.append("g") + .attr("class", "x axis day") + .attr("transform", `translate(0,${height})`) + .call(xAxis); + + // Add the months x-axis. + let m = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]; + + let mAxis = xAxis + .orient("top") + .tickSize(height) + .tickFormat((d) => m[d.getMonth()]) + .ticks(2); + + svg.append("g") + .attr("class", "x axis month") + .attr("transform", `translate(0,${height})`) + .call(mAxis); + + // Add the y-axis. + svg.append("g") + .attr("class", "y axis") + .call(yAxis); + + // Add a line showing where we are now. + svg.append("svg:line") + .attr("class", "today") + .attr("x1", x(new Date)) + .attr("y1", 0) + .attr("x2", x(new Date)) + .attr("y2", height); + + // Add the ideal line path. + svg.append("path") + .attr("class", "ideal line") + // Piecewise linear segments, as in a polyline. + .attr("d", line.interpolate("linear")(ideal)); + + // Add the trendline path. + svg.append("path") + .attr("class", "trendline line") + // Piecewise linear segments, as in a polyline. + .attr("d", line.interpolate("linear")(trend)); + + // Add the actual line path. + svg.append("path") + .attr("class", "actual line") + // Piecewise linear segments, as in a polyline. + .attr("d", line.interpolate("linear").y((d) => y(d.points))(actual)); + + // Collect the tooltip here. + let tooltip = d3.tip().attr('class', 'd3-tip') + .html(({ number, title }) => `#${number}: ${title}`); + + svg.call(tooltip); + + // Show when we closed an issue. + svg.selectAll("a.issue") + .data(actual.slice(1)) // skip the starting point + .enter() + // A wrapping link. + .append('svg:a') + .attr("xlink:href", ({ html_url }) => html_url ) + .attr("xlink:show", 'new') + .append('svg:circle') + .attr("cx", ({ date }) => x(new Date(date))) + .attr("cy", ({ points }) => y(points)) + .attr("r", ({ radius }) => 5) + .on('mouseover', tooltip.show) + .on('mouseout', tooltip.hide); } }); diff --git a/src/js/stores/projectsStore.js b/src/js/stores/projectsStore.js index acce1d4..c285422 100644 --- a/src/js/stores/projectsStore.js +++ b/src/js/stores/projectsStore.js @@ -59,56 +59,6 @@ class ProjectsStore extends Store { }); } - // Fetch milestones and issues for a project. - getProject(user, p) { - // Fetch their milestones. - milestones.fetchAll(user, p, this.cb((err, milestones) => { // async - // Save the error if project does not exist. - if (err) return this.saveError(p, err); - // Now add in the issues. - milestones.forEach((milestone) => { - // Do we have this milestone already? Skip fetching issues then. - if (!_.find(p.milestones, ({ number }) => { - return milestone.number === number; - })) { - // Fetch all the issues for this milestone. - this.getIssues(user, p, milestone); - } - }); - })); - } - - // Fetch a single milestone. - getMilestone(user, p, m) { - // Fetch the single milestone. - milestones.fetch(user, { - 'owner': p.owner, - 'name': p.name, - 'milestone': m - }, this.cb((err, milestone) => { // async - // Save the error if project does not exist. - if (err) return this.saveError(p, err); - // Now add in the issues. - this.getIssues(user, p, milestone); - })); - } - - // Fetch all issues for a milestone. - getIssues(user, p, m) { - issues.fetchAll(user, { - 'owner': p.owner, - 'name': p.name, - 'milestone': m.number - }, this.cb((err, obj) => { // async - // Save any errors on the project. - if (err) return this.saveError(p, err); - // Add in the issues to the milestone. - _.extend(m, { 'issues': obj }); - // Save the milestone. - this.addMilestone(p, m); - })); - } - // Fetch milestone(s) and issues for a project(s). onProjectsLoad(args) { let projects = this.get('list'); @@ -138,7 +88,7 @@ class ProjectsStore extends Store { } } else { // For all projects. - projects.forEach(_.partial(this.getProject, user)); + _.each(projects, _.partial(this.getProject, user), this); } })); } @@ -244,8 +194,57 @@ class ProjectsStore extends Store { } } + // Fetch milestones and issues for a project. + getProject(user, p) { + // Fetch their milestones. + milestones.fetchAll(user, p, this.cb((err, milestones) => { // async + // Save the error if project does not exist. + if (err) return this.saveError(p, err); + // Now add in the issues. + milestones.forEach((milestone) => { + // Do we have this milestone already? Skip fetching issues then. + if (!_.find(p.milestones, ({ number }) => { + return milestone.number === number; + })) { + // Fetch all the issues for this milestone. + this.getIssues(user, p, milestone); + } + }); + })); + } + + // Fetch a single milestone. + getMilestone(user, p, m) { + // Fetch the single milestone. + milestones.fetch(user, { + 'owner': p.owner, + 'name': p.name, + 'milestone': m + }, this.cb((err, milestone) => { // async + // Save the error if project does not exist. + if (err) return this.saveError(p, err); + // Now add in the issues. + this.getIssues(user, p, milestone); + })); + } + + // Fetch all issues for a milestone. + getIssues(user, p, m) { + issues.fetchAll(user, { + 'owner': p.owner, + 'name': p.name, + 'milestone': m.number + }, this.cb((err, obj) => { // async + // Save any errors on the project. + if (err) return this.saveError(p, err); + // Add in the issues to the milestone. + _.extend(m, { 'issues': obj }); + // Save the milestone. + this.addMilestone(p, m); + })); + } + // Add a milestone for a project. - // TODO: check if this milestone exists already. addMilestone(project, milestone) { // Add in the stats. let i, j; @@ -253,6 +252,16 @@ class ProjectsStore extends Store { // We are supposed to exist already. if ((i = this.findIndex(project)) < 0) { throw 500; } + // Does the milestone exist already? + let milestones; + if (milestones = this.get(`list.${i}.milestones`)) { + j = _.findIndex(milestones, { 'number': milestone.number }); + // Just make an update then. + if (j != -1) { + return this.set(`list.${i}.milestones.${j}`, milestone); + } + } + // Push the milestone and return the index. j = this.push(`list.${i}.milestones`, milestone);