From 8ad75d5872872b79abada58589adad0df4614db6 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期三, 07 十月 2026 11:59:44 +0800
Subject: [PATCH] fix(holiday): 走势图按年度配置截取假期天数

---
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java |  390 ++++++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 309 insertions(+), 81 deletions(-)

diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
index 8d359c4..3b4357d 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -49,6 +49,7 @@
 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;
@@ -73,8 +74,11 @@
     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 ITEM_LABEL_GAP = 12.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),
@@ -89,11 +93,21 @@
     }
 
     public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis) {
-        return lineChart(yAxisLabel, series, percentAxis, 1100, 590);
+        return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, 2, 1100, 590);
     }
 
     public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                             int width, int height) {
+        return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, 2, width, height);
+    }
+
+    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
+                            int valueDecimals, int width, int height) {
+        return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, valueDecimals, width, height);
+    }
+
+    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
+                            List<String> dayLabels, int valueDecimals, int width, int height) {
         XYSeriesCollection dataset = new XYSeriesCollection();
         for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
             ChartSeries item = series.get(seriesIndex);
@@ -101,7 +115,7 @@
             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);
@@ -123,7 +137,8 @@
         plot.setRangeGridlinesVisible(false);
         plot.setDomainTickBandPaint(null);
         plot.setRangeTickBandPaint(null);
-        SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
+        List<String> axisLabels = dayLabels == null || dayLabels.isEmpty() ? DAY_LABELS : dayLabels;
+        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);
@@ -133,8 +148,8 @@
 
         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);
@@ -147,7 +162,7 @@
         }
         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));
@@ -165,7 +180,8 @@
                     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,
@@ -211,7 +227,7 @@
         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,
@@ -281,7 +297,7 @@
             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> {
@@ -444,7 +460,7 @@
     }
 
     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();
@@ -464,7 +480,7 @@
                     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 {
@@ -605,7 +621,7 @@
                         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 {
@@ -635,7 +651,8 @@
             }
         }
         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,
@@ -647,7 +664,8 @@
     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) {
@@ -663,36 +681,82 @@
             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));
             }
+
         }
     }
 
@@ -786,14 +850,16 @@
     }
 
     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;
@@ -801,6 +867,7 @@
             this.color = color;
         }
 
+        private int getSeriesIndex() { return seriesIndex; }
         private double getStartX() { return startX; }
         private double getEndX() { return endX; }
         private double getY() { return y; }
@@ -829,7 +896,7 @@
 
     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;
@@ -841,7 +908,6 @@
         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));
@@ -849,13 +915,6 @@
         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++) {
@@ -866,7 +925,7 @@
                 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.
@@ -876,35 +935,95 @@
                     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 {
@@ -937,10 +1056,99 @@
         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) {
@@ -966,27 +1174,47 @@
         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(

--
Gitblit v1.9.1