| | |
| | | import java.lang.reflect.Field; |
| | | import java.lang.reflect.Modifier; |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.text.DecimalFormat; |
| | | import java.text.NumberFormat; |
| | | import java.util.ArrayList; |
| | |
| | | private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102); |
| | | private static final double UNIT_LABEL_BAND = 58.0; |
| | | private static final double RIGHT_LABEL_BAND = 74.0; |
| | | private static final double ITEM_LABEL_GAP = 12.0; |
| | | private static final double ITEM_LABEL_GAP = 10.0; |
| | | private static final float HALO_OUTLINE_WIDTH = 4.0f; |
| | | private static final int PERCENT_ITEM_LABEL_DECIMALS = 1; |
| | | private static final double LINE_CHART_HEIGHT_SCALE = 1.20; |
| | | private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55); |
| | | private static final Color[] SERIES_COLORS = { |
| | | new Color(192, 80, 77), |
| | |
| | | |
| | | public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis, |
| | | int width, int height) { |
| | | return lineChart(yAxisLabel, series, percentAxis, 2, width, height); |
| | | } |
| | | |
| | | public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis, |
| | | int valueDecimals, int width, int height) { |
| | | XYSeriesCollection dataset = new XYSeriesCollection(); |
| | | for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) { |
| | | ChartSeries item = series.get(seriesIndex); |
| | |
| | | for (int i = 0; i < item.getValues().size(); i++) { |
| | | BigDecimal value = item.getValues().get(i); |
| | | if (value != null) { |
| | | xySeries.add(i, value.doubleValue()); |
| | | xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue()); |
| | | } |
| | | } |
| | | dataset.addSeries(xySeries); |
| | |
| | | |
| | | NumberAxis rangeAxis = new NumberAxis(); |
| | | rangeAxis.setAutoRangeIncludesZero(false); |
| | | rangeAxis.setLowerMargin(0.12); |
| | | rangeAxis.setUpperMargin(0.16); |
| | | rangeAxis.setLowerMargin(0.04); |
| | | rangeAxis.setUpperMargin(0.07); |
| | | rangeAxis.setTickLabelFont(TICK_FONT); |
| | | rangeAxis.setLabelFont(LABEL_FONT); |
| | | rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR); |
| | |
| | | } |
| | | NumberFormat valueFormat = percentAxis |
| | | ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##"); |
| | | valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2); |
| | | valueFormat.setMaximumFractionDigits(percentAxis ? 0 : valueDecimals); |
| | | rangeAxis.setNumberFormatOverride(valueFormat); |
| | | plot.setRangeAxis(rangeAxis); |
| | | plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0)); |
| | |
| | | BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); |
| | | } |
| | | plot.setRenderer(renderer); |
| | | return render(chart, width, height, yAxisLabel, true, percentAxis); |
| | | return render(chart, width, scaleLineChartHeight(height), |
| | | yAxisLabel, true, percentAxis, valueDecimals); |
| | | } |
| | | |
| | | public byte[] pieChart(String valueSuffix, List<ChartCategory> categories, |
| | |
| | | for (ChartCategory category : categories) { |
| | | plot.setSectionPaint(category.getName(), seriesColor(index++)); |
| | | } |
| | | return render(chart, width, height, valueSuffix, false, false); |
| | | return render(chart, width, height, valueSuffix, false, false, 2); |
| | | } |
| | | |
| | | public byte[] stackedBarChart(String yAxisLabel, List<String> categories, |
| | |
| | | renderer.setSeriesPaint(i, seriesColor(i)); |
| | | } |
| | | plot.setRenderer(renderer); |
| | | return render(chart, width, height, yAxisLabel, true, percentAxis); |
| | | return render(chart, width, height, yAxisLabel, true, percentAxis, 2); |
| | | } |
| | | |
| | | private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> { |
| | |
| | | } |
| | | |
| | | private byte[] render(JFreeChart chart, int width, int height, String unitLabel, |
| | | boolean axisChart, boolean percentItemLabels) { |
| | | boolean axisChart, boolean percentItemLabels, int valueDecimals) { |
| | | try { |
| | | reserveUnitLabelBand(chart, axisChart, unitLabel); |
| | | ByteArrayOutputStream out = new ByteArrayOutputStream(); |
| | |
| | | drawDateLabel(graphics, info.getPlotInfo().getDataArea()); |
| | | } |
| | | drawXYItemLabels(graphics, image, chart, |
| | | info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel); |
| | | info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals); |
| | | drawStackedBarLabels(graphics, image, chart, |
| | | info.getPlotInfo().getDataArea(), percentItemLabels); |
| | | } finally { |
| | |
| | | |
| | | private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart, |
| | | Rectangle2D dataArea, boolean percentItemLabels, |
| | | String unitLabel) { |
| | | String unitLabel, int valueDecimals) { |
| | | if (!(chart.getPlot() instanceof XYPlot) || dataArea == null |
| | | || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) { |
| | | return; |
| | |
| | | graphics.setFont(ITEM_LABEL_FONT); |
| | | FontMetrics metrics = graphics.getFontMetrics(); |
| | | List<Rectangle2D> placedLabels = new ArrayList<>(); |
| | | List<Rectangle2D> markers = new ArrayList<>(); |
| | | if (unitLabel != null && !unitLabel.isEmpty()) { |
| | | placedLabels.add(new Rectangle2D.Double(0.0, 0.0, |
| | | dataArea.getMinX() + 100.0, dataArea.getMinY() + 30.0)); |
| | |
| | | int itemCount = 0; |
| | | for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) { |
| | | itemCount = Math.max(itemCount, dataset.getItemCount(seriesIndex)); |
| | | for (int itemIndex = 0; itemIndex < dataset.getItemCount(seriesIndex); itemIndex++) { |
| | | Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea); |
| | | if (point != null) { |
| | | markers.add(new Rectangle2D.Double(point.getX() - 9.0, |
| | | point.getY() - 9.0, 18.0, 18.0)); |
| | | } |
| | | } |
| | | } |
| | | |
| | | for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) { |
| | |
| | | Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea); |
| | | if (yValue == null || point == null) continue; |
| | | labels.add(new DayLabel(seriesIndex, point, |
| | | formatLineLabel(yValue, percentItemLabels))); |
| | | formatLineLabel(yValue, percentItemLabels, valueDecimals))); |
| | | } |
| | | // A day's labels are stacked in the same top-to-bottom order as its data |
| | | // points, so a label never looks attached to a neighbouring line. |
| | |
| | | return Double.compare(first.getPoint().getY(), second.getPoint().getY()); |
| | | } |
| | | }); |
| | | double previousBottom = Double.NEGATIVE_INFINITY; |
| | | List<List<LineLabelCandidate>> candidateSets = new ArrayList<>(); |
| | | for (DayLabel label : labels) { |
| | | int width = metrics.stringWidth(label.getText()); |
| | | int height = metrics.getHeight(); |
| | | double x = label.getPoint().getX() - width / 2.0; |
| | | double top = Math.max(label.getPoint().getY() - ITEM_LABEL_GAP - height, |
| | | previousBottom + 3.0); |
| | | Rectangle2D chosen = null; |
| | | double bestPenalty = Double.MAX_VALUE; |
| | | for (int step = 0; step <= 4; step++) { |
| | | Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double( |
| | | x, top + step * (height + 4.0), width, height), dataArea); |
| | | if (candidate == null) continue; |
| | | double penalty = overlapArea(candidate, placedLabels) * 1000.0 |
| | | + overlapArea(candidate, markers); |
| | | if (penalty < bestPenalty) { |
| | | bestPenalty = penalty; |
| | | chosen = candidate; |
| | | } |
| | | if (penalty <= 0.0) break; |
| | | List<LineLabelCandidate> candidates = lineLabelCandidates( |
| | | label, metrics, dataArea); |
| | | if (candidates.isEmpty()) { |
| | | candidateSets.clear(); |
| | | break; |
| | | } |
| | | if (chosen == null) continue; |
| | | candidateSets.add(candidates); |
| | | } |
| | | if (candidateSets.isEmpty()) continue; |
| | | |
| | | // Dynamic programming keeps each label near its own point while preserving |
| | | // the same top-to-bottom order as the data points for that date. |
| | | List<int[]> backPointers = new ArrayList<>(); |
| | | List<LineLabelCandidate> firstCandidates = candidateSets.get(0); |
| | | double[] previousCosts = new double[firstCandidates.size()]; |
| | | for (int i = 0; i < firstCandidates.size(); i++) { |
| | | previousCosts[i] = lineLabelCost(firstCandidates.get(i), labels.get(0), |
| | | labels, placedLabels); |
| | | } |
| | | |
| | | for (int labelIndex = 1; labelIndex < candidateSets.size(); labelIndex++) { |
| | | List<LineLabelCandidate> previousCandidates = candidateSets.get(labelIndex - 1); |
| | | List<LineLabelCandidate> currentCandidates = candidateSets.get(labelIndex); |
| | | double[] currentCosts = new double[currentCandidates.size()]; |
| | | Arrays.fill(currentCosts, Double.MAX_VALUE); |
| | | int[] back = new int[currentCandidates.size()]; |
| | | Arrays.fill(back, -1); |
| | | for (int currentIndex = 0; currentIndex < currentCandidates.size(); currentIndex++) { |
| | | LineLabelCandidate current = currentCandidates.get(currentIndex); |
| | | double currentCost = lineLabelCost(current, labels.get(labelIndex), |
| | | labels, placedLabels); |
| | | for (int previousIndex = 0; previousIndex < previousCandidates.size(); previousIndex++) { |
| | | if (previousCosts[previousIndex] == Double.MAX_VALUE) continue; |
| | | LineLabelCandidate previous = previousCandidates.get(previousIndex); |
| | | if (previous.getRectangle().getCenterY() |
| | | > current.getRectangle().getCenterY() + 0.1) { |
| | | continue; |
| | | } |
| | | double overlapPenalty = overlapArea(previous.getRectangle(), |
| | | Collections.singletonList(current.getRectangle())) * 1000000.0; |
| | | double cost = previousCosts[previousIndex] + currentCost + overlapPenalty; |
| | | if (cost < currentCosts[currentIndex]) { |
| | | currentCosts[currentIndex] = cost; |
| | | back[currentIndex] = previousIndex; |
| | | } |
| | | } |
| | | } |
| | | previousCosts = currentCosts; |
| | | backPointers.add(back); |
| | | } |
| | | |
| | | int bestLastIndex = -1; |
| | | double bestCost = Double.MAX_VALUE; |
| | | for (int i = 0; i < previousCosts.length; i++) { |
| | | if (previousCosts[i] < bestCost) { |
| | | bestCost = previousCosts[i]; |
| | | bestLastIndex = i; |
| | | } |
| | | } |
| | | if (bestLastIndex < 0) continue; |
| | | |
| | | int[] chosenIndexes = new int[candidateSets.size()]; |
| | | chosenIndexes[chosenIndexes.length - 1] = bestLastIndex; |
| | | for (int labelIndex = chosenIndexes.length - 2; labelIndex >= 0; labelIndex--) { |
| | | int nextIndex = chosenIndexes[labelIndex + 1]; |
| | | chosenIndexes[labelIndex] = backPointers.get(labelIndex)[nextIndex]; |
| | | } |
| | | for (int labelIndex = 0; labelIndex < chosenIndexes.length; labelIndex++) { |
| | | DayLabel label = labels.get(labelIndex); |
| | | Rectangle2D chosen = candidateSets.get(labelIndex) |
| | | .get(chosenIndexes[labelIndex]).getRectangle(); |
| | | drawLabelWithHalo(graphics, label.getText(), chosen, metrics, |
| | | seriesColor(label.getSeriesIndex())); |
| | | placedLabels.add(new Rectangle2D.Double(chosen.getX() - 5.0, chosen.getY() - 5.0, |
| | | chosen.getWidth() + 10.0, chosen.getHeight() + 10.0)); |
| | | previousBottom = chosen.getMaxY(); |
| | | placedLabels.add(new Rectangle2D.Double(chosen.getX() - 4.0, chosen.getY() - 4.0, |
| | | chosen.getWidth() + 8.0, chosen.getHeight() + 8.0)); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private static class LineLabelCandidate { |
| | | private final Rectangle2D rectangle; |
| | | |
| | | private LineLabelCandidate(Rectangle2D rectangle) { |
| | | this.rectangle = rectangle; |
| | | } |
| | | |
| | | private Rectangle2D getRectangle() { return rectangle; } |
| | | } |
| | | |
| | | private static class DayLabel { |
| | |
| | | return new Point2D.Double(x, y); |
| | | } |
| | | |
| | | private void addLabelCandidate(List<Rectangle2D> result, double x, double y, |
| | | int width, int height, double dx, double dy) { |
| | | result.add(new Rectangle2D.Double(x + dx - width / 2.0, |
| | | y + dy - height / 2.0, width, height)); |
| | | private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics, |
| | | Rectangle2D dataArea) { |
| | | List<LineLabelCandidate> result = new ArrayList<>(); |
| | | int width = metrics.stringWidth(label.getText()); |
| | | int height = metrics.getHeight(); |
| | | double x = label.getPoint().getX(); |
| | | double y = label.getPoint().getY(); |
| | | double horizontalOffset = ITEM_LABEL_GAP + width / 2.0; |
| | | double farHorizontalOffset = horizontalOffset + 18.0; |
| | | double verticalOffset = ITEM_LABEL_GAP + height / 2.0; |
| | | double farVerticalOffset = verticalOffset + height + 6.0; |
| | | addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, farHorizontalOffset, 0.0, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, -farHorizontalOffset, 0.0, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | horizontalOffset, -verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | horizontalOffset, verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -horizontalOffset, -verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -horizontalOffset, verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | farHorizontalOffset, -verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | farHorizontalOffset, verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -farHorizontalOffset, -verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -farHorizontalOffset, verticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, 0.0, -farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, 0.0, farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | horizontalOffset, -farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | horizontalOffset, farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -horizontalOffset, -farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -horizontalOffset, farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | farHorizontalOffset, -farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | farHorizontalOffset, farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -farHorizontalOffset, -farVerticalOffset, dataArea); |
| | | addLabelCandidate(result, x, y, width, height, |
| | | -farHorizontalOffset, farVerticalOffset, dataArea); |
| | | return result; |
| | | } |
| | | |
| | | private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y, |
| | | int width, int height, double dx, double dy, |
| | | Rectangle2D dataArea) { |
| | | Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double( |
| | | x + dx - width / 2.0, y + dy - height / 2.0, width, height), dataArea); |
| | | if (candidate == null) return; |
| | | for (LineLabelCandidate existing : result) { |
| | | if (Math.abs(existing.getRectangle().getX() - candidate.getX()) < 0.5 |
| | | && Math.abs(existing.getRectangle().getY() - candidate.getY()) < 0.5) { |
| | | return; |
| | | } |
| | | } |
| | | result.add(new LineLabelCandidate(candidate)); |
| | | } |
| | | |
| | | private double lineLabelCost(LineLabelCandidate candidate, DayLabel label, |
| | | List<DayLabel> sameDayLabels, |
| | | List<Rectangle2D> placedLabels) { |
| | | Rectangle2D rectangle = candidate.getRectangle(); |
| | | double dx = rectangle.getCenterX() - label.getPoint().getX(); |
| | | double dy = rectangle.getCenterY() - label.getPoint().getY(); |
| | | double ownDistance = Math.sqrt(dx * dx + dy * dy); |
| | | double cost = ownDistance * 10.0 + overlapArea(rectangle, placedLabels) * 10000.0; |
| | | Rectangle2D ownMarker = new Rectangle2D.Double(label.getPoint().getX() - 9.0, |
| | | label.getPoint().getY() - 9.0, 18.0, 18.0); |
| | | if (rectangle.intersects(ownMarker)) cost += 3000.0; |
| | | for (DayLabel other : sameDayLabels) { |
| | | if (other == label) continue; |
| | | double otherDx = rectangle.getCenterX() - other.getPoint().getX(); |
| | | double otherDy = rectangle.getCenterY() - other.getPoint().getY(); |
| | | double otherDistance = Math.sqrt(otherDx * otherDx + otherDy * otherDy); |
| | | if (otherDistance < ownDistance - 1.0) { |
| | | cost += (ownDistance - otherDistance) * 5000.0; |
| | | } |
| | | Rectangle2D otherMarker = new Rectangle2D.Double(other.getPoint().getX() - 9.0, |
| | | other.getPoint().getY() - 9.0, 18.0, 18.0); |
| | | if (rectangle.intersects(otherMarker)) cost += 2000.0; |
| | | } |
| | | return cost; |
| | | } |
| | | |
| | | private Rectangle2D boundToDataArea(Rectangle2D rectangle, Rectangle2D dataArea) { |
| | |
| | | return total; |
| | | } |
| | | |
| | | private String formatLineLabel(Number value, boolean percentItemLabels) { |
| | | static int scaleLineChartHeight(int height) { |
| | | return (int) Math.round(height * LINE_CHART_HEIGHT_SCALE); |
| | | } |
| | | |
| | | static long scaleLineChartHeight(long height) { |
| | | return Math.round(height * LINE_CHART_HEIGHT_SCALE); |
| | | } |
| | | |
| | | static BigDecimal displayValue(BigDecimal value, boolean percentItemLabels, int valueDecimals) { |
| | | if (value == null) return null; |
| | | int scale = percentItemLabels ? PERCENT_ITEM_LABEL_DECIMALS + 2 : valueDecimals; |
| | | return value.setScale(scale, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | private String formatLineLabel(Number value, boolean percentItemLabels, int valueDecimals) { |
| | | if (percentItemLabels) { |
| | | NumberFormat formatter = NumberFormat.getPercentInstance(); |
| | | formatter.setMaximumFractionDigits(1); |
| | | formatter.setMaximumFractionDigits(PERCENT_ITEM_LABEL_DECIMALS); |
| | | return formatter.format(value.doubleValue()); |
| | | } |
| | | return new DecimalFormat("#,##0.##").format(value.doubleValue()); |
| | | DecimalFormat formatter = new DecimalFormat("#,##0"); |
| | | formatter.setMaximumFractionDigits(valueDecimals); |
| | | return formatter.format(value.doubleValue()); |
| | | } |
| | | |
| | | private void drawLabelWithHalo(Graphics2D graphics, String text, Rectangle2D rectangle, |
| | | FontMetrics metrics, Color color) { |
| | | int x = (int) Math.round(rectangle.getX()); |
| | | int y = (int) Math.round(rectangle.getY() + metrics.getAscent()); |
| | | // A stroked white outline keeps the digit legible even when a line runs |
| | | // underneath it, which lets labels stay right next to their own point. |
| | | java.awt.font.TextLayout layout = new java.awt.font.TextLayout( |
| | | text, graphics.getFont(), graphics.getFontRenderContext()); |
| | | java.awt.Shape outline = layout.getOutline( |
| | | java.awt.geom.AffineTransform.getTranslateInstance(x, y)); |
| | | graphics.setColor(Color.WHITE); |
| | | for (int dx = -1; dx <= 1; dx++) { |
| | | for (int dy = -1; dy <= 1; dy++) { |
| | | if (dx != 0 || dy != 0) graphics.drawString(text, x + dx, y + dy); |
| | | } |
| | | } |
| | | graphics.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH, |
| | | BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); |
| | | graphics.draw(outline); |
| | | graphics.setColor(color); |
| | | graphics.drawString(text, x, y); |
| | | graphics.fill(outline); |
| | | } |
| | | |
| | | public static final List<String> DAY_LABELS = Arrays.asList( |