package scrum.server.common; import ilarkesto.base.Utl; import ilarkesto.base.time.Date; import ilarkesto.core.logging.Log; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.List; import java.util.Locale; import org.jfree.chart.ChartFactory; import org.jfree.chart.ChartUtilities; import org.jfree.chart.JFreeChart; import org.jfree.chart.axis.AxisLocation; import org.jfree.chart.axis.DateAxis; import org.jfree.chart.axis.DateTickUnit; import org.jfree.chart.axis.NumberAxis; import org.jfree.chart.axis.NumberTickUnit; import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.renderer.xy.XYItemRenderer; import org.jfree.data.Range; import org.jfree.data.xy.DefaultXYDataset; import scrum.server.css.ScreenCssBuilder; import scrum.server.project.Project; import scrum.server.project.ProjectDao; import scrum.server.project.ProjectSprintSnapshot; import scrum.server.sprint.Sprint; import scrum.server.sprint.SprintDao; import scrum.server.sprint.SprintDaySnapshot; public class BurndownChart { private static final Log LOG = Log.get(BurndownChart.class); private static final Color COLOR_PAST_LINE = Utl.parseHtmlColor(ScreenCssBuilder.cBurndownLine); private static final Color COLOR_PROJECTION_LINE = Utl.parseHtmlColor(ScreenCssBuilder.cBurndownProjectionLine); private static final Color COLOR_OPTIMUM_LINE = Utl.parseHtmlColor(ScreenCssBuilder.cBurndownOptimalLine); // --- dependencies --- private ProjectDao projectDao; private SprintDao sprintDao; public void setProjectDao(ProjectDao projectDao) { this.projectDao = projectDao; } public void setSprintDao(SprintDao sprintDao) { this.sprintDao = sprintDao; } // --- --- public static byte[] createBurndownChartAsByteArray(Sprint sprint, int width, int height) { ByteArrayOutputStream out = new ByteArrayOutputStream(); new BurndownChart().writeSprintBurndownChart(out, sprint, width, height); return out.toByteArray(); } public void writeProjectBurndownChart(OutputStream out, String projectId, int width, int height) { Project project = projectDao.getById(projectId); List<ProjectSprintSnapshot> snapshots = project.getSprintSnapshots(); project.getCurrentSprintSnapshot().update(); writeProjectBurndownChart(out, snapshots, project.getBegin(), project.getEnd().addDays(1), width, height); } public void writeSprintBurndownChart(OutputStream out, String sprintId, int width, int height) { Sprint sprint = sprintDao.getById(sprintId); if (sprint == null) throw new IllegalArgumentException("Sprint " + sprintId + " does not exist."); writeSprintBurndownChart(out, sprint, width, height); } public void writeSprintBurndownChart(OutputStream out, Sprint sprint, int width, int height) { List<SprintDaySnapshot> snapshots = sprint.getDaySnapshots(); if (snapshots.isEmpty()) { Date date = Date.today(); date = Date.latest(date, sprint.getBegin()); date = Date.earliest(date, sprint.getEnd()); sprint.getDaySnapshot(date).updateWithCurrentSprint(); snapshots = sprint.getDaySnapshots(); } writeSprintBurndownChart(out, snapshots, sprint.getBegin(), sprint.getEnd().addDays(1), width, height); } private void writeProjectBurndownChart(OutputStream out, List<ProjectSprintSnapshot> snapshots, Date firstDay, Date lastDay, int width, int height) { List<BurndownSnapshot> burndownSnapshots = new ArrayList<BurndownSnapshot>(snapshots); DefaultXYDataset data = createSprintBurndownChartDataset(burndownSnapshots, firstDay, lastDay); double tick = 1.0; double max = getMaximum(data); while (max / tick > 25) { tick *= 2; if (max / tick <= 25) break; tick *= 2.5; if (max / tick <= 25) break; tick *= 2; } JFreeChart chart = createSprintBurndownChart(data, "Date", "Work", firstDay, lastDay, 10, 30, max * 1.1, tick); try { ChartUtilities.writeScaledChartAsPNG(out, chart, width, height, 1, 1); } catch (IOException e) { throw new RuntimeException(e); } } private void writeSprintBurndownChart(OutputStream out, List<SprintDaySnapshot> snapshots, Date firstDay, Date lastDay, int width, int height) { LOG.debug("Creating burndown chart:", snapshots.size(), "snapshots from", firstDay, "to", lastDay, "(" + width + "x" + height + " px)"); List<BurndownSnapshot> burndownSnapshots = new ArrayList<BurndownSnapshot>(snapshots); DefaultXYDataset data = createSprintBurndownChartDataset(burndownSnapshots, firstDay, lastDay); double tick = 1.0; double max = getMaximum(data); while (max / tick > 25) { tick *= 2; if (max / tick <= 25) break; tick *= 2.5; if (max / tick <= 25) break; tick *= 2; } JFreeChart chart = createSprintBurndownChart(data, null, null, firstDay, lastDay, 1, 3, max * 1.1, tick); try { ChartUtilities.writeScaledChartAsPNG(out, chart, width, height, 1, 1); out.flush(); } catch (IOException e) { throw new RuntimeException(e); } } static JFreeChart createSprintBurndownChart(DefaultXYDataset data, String dateAxisLabel, String valueAxisLabel, Date firstDay, Date lastDay, int dateMarkTickUnit, int dateLabelTickUnit, double upperBoundary, double valueLabelTickUnit) { JFreeChart chart = ChartFactory.createXYLineChart("", "", "", data, PlotOrientation.VERTICAL, false, true, false); XYItemRenderer renderer = chart.getXYPlot().getRenderer(); renderer.setSeriesPaint(0, COLOR_PAST_LINE); renderer.setSeriesStroke(0, new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_BEVEL)); renderer.setSeriesPaint(1, COLOR_PROJECTION_LINE); renderer.setSeriesStroke(1, new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_BEVEL, 1.0f, new float[] { 4, 8 }, 4)); renderer.setSeriesPaint(2, COLOR_OPTIMUM_LINE); renderer.setSeriesStroke(2, new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_BEVEL)); DateAxis domainAxis1 = new DateAxis(dateAxisLabel); domainAxis1.setLabelFont(new Font(domainAxis1.getLabelFont().getName(), Font.PLAIN, 7)); domainAxis1.setDateFormatOverride(new SimpleDateFormat("d.", Locale.US)); domainAxis1.setTickUnit(new DateTickUnit(DateTickUnit.DAY, dateLabelTickUnit)); domainAxis1.setAxisLineVisible(false); Range range = new Range(firstDay.toMillis(), lastDay.toMillis()); domainAxis1.setRange(range); DateAxis domainAxis2 = new DateAxis(); domainAxis2.setTickUnit(new DateTickUnit(DateTickUnit.DAY, dateMarkTickUnit)); domainAxis2.setTickMarksVisible(false); domainAxis2.setTickLabelsVisible(false); domainAxis2.setRange(range); chart.getXYPlot().setDomainAxis(0, domainAxis2); chart.getXYPlot().setDomainAxis(1, domainAxis1); chart.getXYPlot().setDomainAxisLocation(1, AxisLocation.BOTTOM_OR_RIGHT); NumberAxis rangeAxis = new NumberAxis(valueAxisLabel); rangeAxis.setLabelFont(new Font(rangeAxis.getLabelFont().getName(), Font.PLAIN, 6)); rangeAxis.setNumberFormatOverride(NumberFormat.getIntegerInstance()); rangeAxis.setTickUnit(new NumberTickUnit(valueLabelTickUnit)); rangeAxis.setLowerBound(0); rangeAxis.setUpperBound(upperBoundary); chart.getXYPlot().setRangeAxis(rangeAxis); chart.getXYPlot().getRenderer().setBaseStroke(new BasicStroke(2f)); chart.setBackgroundPaint(Color.WHITE); return chart; } static double getMaximum(DefaultXYDataset data) { double max = 0; for (int i = 0; i < data.getSeriesCount(); i++) { for (int j = 0; j < data.getItemCount(i); j++) { double value = data.getYValue(i, j); if (value > max) { max = value; } } } return max; } static DefaultXYDataset createSprintBurndownChartDataset(List<BurndownSnapshot> snapshots, Date firstDay, Date lastDay) { if (snapshots.isEmpty()) throw new IllegalArgumentException("snapshots.isEmpty()"); List<Double> mainDates = new ArrayList<Double>(); List<Double> mainValues = new ArrayList<Double>(); List<Double> extrapolationDates = new ArrayList<Double>(); List<Double> extrapolationValues = new ArrayList<Double>(); List<Double> idealDates = new ArrayList<Double>(); List<Double> idealValues = new ArrayList<Double>(); double burnedWork = 0; double remainingWork = 0; double work = 0; double jump = 0; double newBurnedWork; double newRemainingWork; double newWork; double idealWork = 0; double idealPerDayBurndown = idealWork / (firstDay.getPeriodTo(lastDay).toDays()); Date lastIdealDate; burnedWork = 0; remainingWork = 0; work = 0; mainDates.add((double) firstDay.toMillis()); mainValues.add(0d); idealDates.add((double) firstDay.toMillis()); idealValues.add(0d); lastIdealDate = firstDay; for (int i = 0; i < snapshots.size(); i++) { BurndownSnapshot snapshot = snapshots.get(i); Date snapshotDate = snapshot.getDate(); double snapshotDateMillis = snapshotDate.toMillis(); double snapshotDateNextMillis = snapshotDate.addDays(1).toMillis(); newBurnedWork = snapshot.getBurnedWork(); newRemainingWork = snapshot.getRemainingWork(); newWork = newBurnedWork + newRemainingWork; jump = newWork - work; if (jump != 0) { mainDates.add(snapshotDateMillis); mainValues.add(remainingWork + jump); idealWork -= (lastIdealDate.getPeriodTo(snapshotDate).toDays() * idealPerDayBurndown); idealDates.add(snapshotDateMillis); idealValues.add(idealWork); if (idealWork == 0) idealWork += (jump); idealDates.add(snapshotDateMillis); idealValues.add(idealWork); idealPerDayBurndown = idealWork / (snapshotDate.getPeriodTo(lastDay).toDays()); lastIdealDate = snapshotDate; work = newWork; } mainDates.add(snapshotDateNextMillis); mainValues.add(newRemainingWork); remainingWork = newRemainingWork; burnedWork = newBurnedWork; // BurndownSnapshot snapshot = snapshots.get(i); // Date snapshotDate = snapshot.getDate(); // double snapshotDateMillis = snapshotDate.toMillis(); // newBurnedWork = snapshot.getBurnedWork(); // newRemainingWork = snapshot.getRemainingWork(); // newWork = newBurnedWork + newRemainingWork; // // mainDates.add(snapshotDateMillis); // mainValues.add(work - newBurnedWork); // // if (newWork != work) { // mainDates.add(snapshotDateMillis); // mainValues.add(newRemainingWork); // // idealWork -= (lastIdealDate.getPeriodTo(snapshotDate).toDays() * idealPerDayBurndown); // // idealDates.add(snapshotDateMillis); // idealValues.add(idealWork); // // idealWork += (newWork - work); // // idealDates.add(snapshotDateMillis); // idealValues.add(idealWork); // // idealPerDayBurndown = idealWork / (snapshotDate.getPeriodTo(lastDay).toDays()); // // lastIdealDate = snapshotDate; // // work = newWork; // // } // // burnedWork = newBurnedWork; // remainingWork = newRemainingWork; } idealWork -= (lastIdealDate.getPeriodTo(lastDay).toDays() * idealPerDayBurndown); idealDates.add((double) lastDay.toMillis()); idealValues.add(idealWork); DefaultXYDataset dataset = new DefaultXYDataset(); dataset.addSeries("Main", toArray(mainDates, mainValues)); Date d = snapshots.get(snapshots.size() - 1).getDate().addDays(1); double extrapolationPerDayBurndown = burnedWork / (firstDay.getPeriodTo(d).toDays()); double remaining = remainingWork; extrapolationDates.add((double) d.toMillis()); extrapolationValues.add(remaining); extrapolationDates.add((double) lastDay.toMillis()); extrapolationValues.add(remaining - d.getPeriodTo(lastDay).toDays() * extrapolationPerDayBurndown); dataset.addSeries("Extrapolation", toArray(extrapolationDates, extrapolationValues)); dataset.addSeries("Ideal", toArray(idealDates, idealValues)); return dataset; } private static double[][] toArray(List<Double> a, List<Double> b) { int min = Math.min(a.size(), b.size()); double[][] array = new double[2][min]; for (int i = 0; i < min; i++) { array[0][i] = a.get(i); array[1][i] = b.get(i); } return array; } }