| | |
| | | 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 AXIS_COLOR = Color.BLACK; |
| | | 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 RIGHT_LABEL_BAND = 140.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 valueDecimals, int width, int height) { |
| | | return lineChart(yAxisLabel, series, percentAxis, valueDecimals, DAY_LABELS, width, height); |
| | | } |
| | | |
| | | public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis, |
| | | int valueDecimals, List<String> dayLabels, int width, int height) { |
| | | return lineChart(yAxisLabel, series, percentAxis, valueDecimals, dayLabels, width, height, |
| | | Collections.emptyList()); |
| | | } |
| | | |
| | | /** |
| | | * Renders a line chart and pins the listed labels to a fixed anchor. The overrides are |
| | | * opt-in: a chart that passes an empty list keeps the automatic placement unchanged. |
| | | */ |
| | | public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis, |
| | | int valueDecimals, List<String> dayLabels, int width, int height, |
| | | List<LineLabelOverride> labelOverrides) { |
| | | List<String> axisLabels = (dayLabels == null || dayLabels.isEmpty()) ? DAY_LABELS : dayLabels; |
| | | 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); |
| | |
| | | plot.setRangeGridlinesVisible(false); |
| | | plot.setDomainTickBandPaint(null); |
| | | plot.setRangeTickBandPaint(null); |
| | | SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0])); |
| | | SymbolAxis domainAxis = new SymbolAxis(null, axisLabels.toArray(new String[0])); |
| | | domainAxis.setGridBandPaint(new Color(0, 0, 0, 0)); |
| | | domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0)); |
| | | domainAxis.setLowerMargin(0.04); |
| | |
| | | |
| | | 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); |
| | |
| | | BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); |
| | | } |
| | | plot.setRenderer(renderer); |
| | | return render(chart, width, height, yAxisLabel, true, percentAxis, valueDecimals); |
| | | return render(chart, width, scaleLineChartHeight(height), |
| | | yAxisLabel, true, percentAxis, valueDecimals, labelOverrides); |
| | | } |
| | | |
| | | public byte[] pieChart(String valueSuffix, List<ChartCategory> categories, |
| | |
| | | |
| | | private byte[] render(JFreeChart chart, int width, int height, String unitLabel, |
| | | boolean axisChart, boolean percentItemLabels, int valueDecimals) { |
| | | return render(chart, width, height, unitLabel, axisChart, percentItemLabels, |
| | | valueDecimals, Collections.emptyList()); |
| | | } |
| | | |
| | | private byte[] render(JFreeChart chart, int width, int height, String unitLabel, |
| | | boolean axisChart, boolean percentItemLabels, int valueDecimals, |
| | | List<LineLabelOverride> labelOverrides) { |
| | | try { |
| | | reserveUnitLabelBand(chart, axisChart, unitLabel); |
| | | ByteArrayOutputStream out = new ByteArrayOutputStream(); |
| | |
| | | drawDateLabel(graphics, info.getPlotInfo().getDataArea()); |
| | | } |
| | | drawXYItemLabels(graphics, image, chart, |
| | | info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals); |
| | | info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals, |
| | | labelOverrides); |
| | | drawStackedBarLabels(graphics, image, chart, |
| | | info.getPlotInfo().getDataArea(), percentItemLabels); |
| | | } finally { |
| | |
| | | occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(), |
| | | width, metrics.getHeight())); |
| | | } else { |
| | | horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX), |
| | | horizontalCallouts.add(new HorizontalStackCallout(row, Math.min(startX, endX), |
| | | Math.max(startX, endX), centerY, text, seriesColor(row))); |
| | | } |
| | | } else { |
| | |
| | | } |
| | | } |
| | | if (horizontal) { |
| | | drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied); |
| | | drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied, |
| | | rows >= 3); |
| | | } else { |
| | | for (int column = 0; column < columns; column++) { |
| | | drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns, |
| | |
| | | private void drawHorizontalStackCallouts(Graphics2D graphics, |
| | | List<HorizontalStackCallout> items, |
| | | Rectangle2D dataArea, FontMetrics metrics, |
| | | List<Rectangle2D> occupied) { |
| | | List<Rectangle2D> occupied, |
| | | boolean packIntoColumns) { |
| | | if (items.isEmpty()) return; |
| | | Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>(); |
| | | for (HorizontalStackCallout item : items) { |
| | |
| | | Collections.sort(row, new Comparator<HorizontalStackCallout>() { |
| | | @Override |
| | | public int compare(HorizontalStackCallout first, HorizontalStackCallout second) { |
| | | return Double.compare(first.getStartX(), second.getStartX()); |
| | | return Integer.compare(first.getSeriesIndex(), second.getSeriesIndex()); |
| | | } |
| | | }); |
| | | int widest = 0; |
| | | double columnX = 0.0; |
| | | for (HorizontalStackCallout item : row) { |
| | | widest = Math.max(widest, metrics.stringWidth(item.getText())); |
| | | columnX = Math.max(columnX, item.getEndX() + 8.0); |
| | | if (!packIntoColumns) { |
| | | int widest = 0; |
| | | double columnX = 0.0; |
| | | for (HorizontalStackCallout item : row) { |
| | | widest = Math.max(widest, metrics.stringWidth(item.getText())); |
| | | columnX = Math.max(columnX, item.getEndX() + 8.0); |
| | | } |
| | | // Keep the label column inside the reserved right margin. |
| | | columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest); |
| | | int height = metrics.getHeight(); |
| | | double step = height + 3.0; |
| | | double centerY = row.get(0).getY(); |
| | | double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0; |
| | | for (int i = 0; i < row.size(); i++) { |
| | | HorizontalStackCallout item = row.get(i); |
| | | int width = metrics.stringWidth(item.getText()); |
| | | double x = columnX; |
| | | double y = top + i * step; |
| | | graphics.setColor(item.getColor()); |
| | | graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND, |
| | | BasicStroke.JOIN_ROUND)); |
| | | graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()), |
| | | (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0)); |
| | | drawLabelWithHalo(graphics, item.getText(), |
| | | new Rectangle2D.Double(x, y, width, height), metrics, item.getColor()); |
| | | occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0, |
| | | width + 4.0, height + 4.0)); |
| | | } |
| | | continue; |
| | | } |
| | | // Keep the label column inside the reserved right margin. |
| | | columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest); |
| | | // Small slices are labelled outside the bar. Packing those labels |
| | | // into two columns keeps each day's callouts inside its own row. |
| | | int columnCount = row.size() <= 1 ? 1 : 2; |
| | | int rowCount = (int) Math.ceil(row.size() / (double) columnCount); |
| | | double[] columnWidths = new double[columnCount]; |
| | | for (int i = 0; i < row.size(); i++) { |
| | | int column = columnCount == 1 ? 0 : i % columnCount; |
| | | columnWidths[column] = Math.max(columnWidths[column], |
| | | metrics.stringWidth(row.get(i).getText())); |
| | | } |
| | | double columnGap = 10.0; |
| | | double blockWidth = columnGap * (columnCount - 1); |
| | | for (double columnWidth : columnWidths) blockWidth += columnWidth; |
| | | double blockLeft = dataArea.getMaxX() + 8.0; |
| | | double blockRight = dataArea.getMaxX() + RIGHT_LABEL_BAND; |
| | | blockLeft += Math.max(0.0, (blockRight - blockLeft - blockWidth) / 2.0); |
| | | double[] columnCenters = new double[columnCount]; |
| | | double cursor = blockLeft; |
| | | for (int column = 0; column < columnCount; column++) { |
| | | columnCenters[column] = cursor + columnWidths[column] / 2.0; |
| | | cursor += columnWidths[column] + columnGap; |
| | | } |
| | | int height = metrics.getHeight(); |
| | | double step = height + 3.0; |
| | | double centerY = row.get(0).getY(); |
| | | double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0; |
| | | double rowStep = height + 3.0; |
| | | double totalHeight = rowCount * height + (rowCount - 1) * (rowStep - height); |
| | | double top = row.get(0).getY() - totalHeight / 2.0; |
| | | for (int i = 0; i < row.size(); i++) { |
| | | HorizontalStackCallout item = row.get(i); |
| | | int column = columnCount == 1 ? 0 : i % columnCount; |
| | | int rowIndex = columnCount == 1 ? i : i / columnCount; |
| | | int itemsInRow = Math.min(columnCount, row.size() - rowIndex * columnCount); |
| | | int width = metrics.stringWidth(item.getText()); |
| | | double x = columnX; |
| | | double y = top + i * step; |
| | | graphics.setColor(item.getColor()); |
| | | graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND, |
| | | BasicStroke.JOIN_ROUND)); |
| | | graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()), |
| | | (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0)); |
| | | double centerX = itemsInRow == 1 && columnCount > 1 |
| | | ? blockLeft + blockWidth / 2.0 |
| | | : columnCenters[column]; |
| | | double x = centerX - width / 2.0; |
| | | double y = top + rowIndex * rowStep; |
| | | drawLabelWithHalo(graphics, item.getText(), |
| | | new Rectangle2D.Double(x, y, width, height), metrics, item.getColor()); |
| | | occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0, |
| | | width + 4.0, height + 4.0)); |
| | | } |
| | | |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | private static class HorizontalStackCallout { |
| | | private final int seriesIndex; |
| | | private final double startX; |
| | | private final double endX; |
| | | private final double y; |
| | | private final String text; |
| | | private final Color color; |
| | | |
| | | private HorizontalStackCallout(double startX, double endX, double y, |
| | | private HorizontalStackCallout(int seriesIndex, double startX, double endX, double y, |
| | | String text, Color color) { |
| | | this.seriesIndex = seriesIndex; |
| | | this.startX = startX; |
| | | this.endX = endX; |
| | | this.y = y; |
| | |
| | | this.color = color; |
| | | } |
| | | |
| | | private int getSeriesIndex() { return seriesIndex; } |
| | | private double getStartX() { return startX; } |
| | | private double getEndX() { return endX; } |
| | | private double getY() { return y; } |
| | |
| | | |
| | | private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart, |
| | | Rectangle2D dataArea, boolean percentItemLabels, |
| | | String unitLabel, int valueDecimals) { |
| | | String unitLabel, int valueDecimals, |
| | | List<LineLabelOverride> labelOverrides) { |
| | | if (!(chart.getPlot() instanceof XYPlot) || dataArea == null |
| | | || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) { |
| | | return; |
| | |
| | | List<List<LineLabelCandidate>> candidateSets = new ArrayList<>(); |
| | | for (DayLabel label : labels) { |
| | | List<LineLabelCandidate> candidates = lineLabelCandidates( |
| | | label, metrics, dataArea); |
| | | label, metrics, dataArea, |
| | | forcedAnchor(labelOverrides, label.getSeriesIndex(), itemIndex)); |
| | | if (candidates.isEmpty()) { |
| | | candidateSets.clear(); |
| | | break; |
| | |
| | | } |
| | | |
| | | private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics, |
| | | Rectangle2D dataArea) { |
| | | Rectangle2D dataArea, |
| | | LineLabelAnchor forcedAnchor) { |
| | | List<LineLabelCandidate> result = new ArrayList<>(); |
| | | int width = metrics.stringWidth(label.getText()); |
| | | int height = metrics.getHeight(); |
| | |
| | | double farHorizontalOffset = horizontalOffset + 18.0; |
| | | double verticalOffset = ITEM_LABEL_GAP + height / 2.0; |
| | | double farVerticalOffset = verticalOffset + height + 6.0; |
| | | if (forcedAnchor != null) { |
| | | switch (forcedAnchor) { |
| | | case ABOVE: |
| | | addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea); |
| | | break; |
| | | case BELOW: |
| | | addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea); |
| | | break; |
| | | case LEFT: |
| | | addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea); |
| | | break; |
| | | case RIGHT: |
| | | addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea); |
| | | break; |
| | | default: |
| | | break; |
| | | } |
| | | return result; |
| | | } |
| | | 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, |
| | | -farHorizontalOffset, farVerticalOffset, dataArea); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * Fixed positions a data label can be pinned to, relative to its own point. |
| | | */ |
| | | public enum LineLabelAnchor { |
| | | ABOVE, BELOW, LEFT, RIGHT |
| | | } |
| | | |
| | | /** |
| | | * Opt-in request that pins one data label (series + item) to a fixed anchor. |
| | | */ |
| | | public static class LineLabelOverride { |
| | | private final int seriesIndex; |
| | | private final int itemIndex; |
| | | private final LineLabelAnchor anchor; |
| | | |
| | | public LineLabelOverride(int seriesIndex, int itemIndex, LineLabelAnchor anchor) { |
| | | this.seriesIndex = seriesIndex; |
| | | this.itemIndex = itemIndex; |
| | | this.anchor = anchor; |
| | | } |
| | | |
| | | public int getSeriesIndex() { return seriesIndex; } |
| | | public int getItemIndex() { return itemIndex; } |
| | | public LineLabelAnchor getAnchor() { return anchor; } |
| | | } |
| | | |
| | | private static LineLabelAnchor forcedAnchor(List<LineLabelOverride> labelOverrides, |
| | | int seriesIndex, int itemIndex) { |
| | | if (labelOverrides == null) return null; |
| | | for (LineLabelOverride override : labelOverrides) { |
| | | if (override.getSeriesIndex() == seriesIndex && override.getItemIndex() == itemIndex) { |
| | | return override.getAnchor(); |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y, |
| | |
| | | return total; |
| | | } |
| | | |
| | | 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()); |
| | | } |
| | | DecimalFormat formatter = new DecimalFormat("#,##0"); |