zhizhijie
2026-10-06 bee9e18c3af284ff432a4472f1d409f5a6dc09a7
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
New file
@@ -0,0 +1,1186 @@
package com.trafficaudit.holiday.service;
import lombok.Data;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.StandardChartTheme;
import org.jfree.chart.axis.AxisLocation;
import org.jfree.chart.axis.CategoryAxis;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.axis.SymbolAxis;
import org.jfree.chart.axis.ValueAxis;
import org.jfree.chart.labels.StandardPieSectionLabelGenerator;
import org.jfree.chart.plot.CategoryPlot;
import org.jfree.chart.plot.PiePlot;
import org.jfree.chart.plot.Plot;
import org.jfree.chart.plot.PieLabelRecord;
import org.jfree.chart.plot.PiePlotState;
import org.jfree.chart.text.TextFragment;
import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.category.StackedBarRenderer;
import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
import org.jfree.chart.ui.RectangleEdge;
import org.jfree.chart.ui.RectangleInsets;
import org.jfree.chart.ui.TextAnchor;
import org.jfree.data.DefaultKeyedValues;
import org.jfree.data.KeyedValues;
import org.jfree.data.category.CategoryDataset;
import org.jfree.data.category.DefaultCategoryDataset;
import org.jfree.data.general.DefaultPieDataset;
import org.jfree.data.xy.XYDataset;
import org.jfree.data.xy.XYSeries;
import org.jfree.data.xy.XYSeriesCollection;
import org.springframework.stereotype.Service;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Paint;
import java.awt.Shape;
import java.awt.image.BufferedImage;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.io.ByteArrayOutputStream;
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;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class HolidayTrendChartService {
    private static final Font BASE_FONT = new Font("Microsoft YaHei", Font.PLAIN, 20);
    private static final Font TICK_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
    private static final Font LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
    private static final Font ITEM_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 20f);
    private static final Font LEGEND_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
    private static final Font STACK_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 17f);
    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 = 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),
            new Color(79, 129, 189),
            new Color(155, 187, 89),
            new Color(128, 100, 162),
            new Color(75, 172, 198)
    };
    static {
        System.setProperty("java.awt.headless", "true");
    }
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis) {
        return lineChart(yAxisLabel, series, percentAxis, 1100, 590);
    }
    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);
            XYSeries xySeries = new XYSeries(item.getName(), false, false);
            for (int i = 0; i < item.getValues().size(); i++) {
                BigDecimal value = item.getValues().get(i);
                if (value != null) {
                    xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue());
                }
            }
            dataset.addSeries(xySeries);
        }
        JFreeChart chart = ChartFactory.createXYLineChart(
                null, null, null, dataset, PlotOrientation.VERTICAL, true, false, false);
        applyTheme(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
        chart.getLegend().setPosition(RectangleEdge.BOTTOM);
        chart.getLegend().setItemFont(LEGEND_FONT);
        chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
        chart.getLegend().setBackgroundPaint(Color.WHITE);
        chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
        XYPlot plot = chart.getXYPlot();
        stylePlot(plot);
        plot.setDomainGridlinesVisible(false);
        plot.setRangeGridlinesVisible(false);
        plot.setDomainTickBandPaint(null);
        plot.setRangeTickBandPaint(null);
        SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
        domainAxis.setGridBandPaint(new Color(0, 0, 0, 0));
        domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0));
        domainAxis.setLowerMargin(0.04);
        domainAxis.setUpperMargin(0.04);
        plot.setDomainAxis(domainAxis);
        styleDomainAxis(plot.getDomainAxis());
        NumberAxis rangeAxis = new NumberAxis();
        rangeAxis.setAutoRangeIncludesZero(false);
        rangeAxis.setLowerMargin(0.04);
        rangeAxis.setUpperMargin(0.07);
        rangeAxis.setTickLabelFont(TICK_FONT);
        rangeAxis.setLabelFont(LABEL_FONT);
        rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
        rangeAxis.setLabelPaint(AXIS_COLOR);
        rangeAxis.setAxisLinePaint(AXIS_COLOR);
        rangeAxis.setTickMarkPaint(AXIS_COLOR);
        if (percentAxis) {
            rangeAxis.setRange(0.0, 1.0);
            rangeAxis.setAutoRangeIncludesZero(true);
        }
        NumberFormat valueFormat = percentAxis
                ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
        valueFormat.setMaximumFractionDigits(percentAxis ? 0 : valueDecimals);
        rangeAxis.setNumberFormatOverride(valueFormat);
        plot.setRangeAxis(rangeAxis);
        plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
        XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(true, true);
        renderer.setDefaultShapesVisible(true);
        renderer.setDefaultShapesFilled(true);
        renderer.setDefaultLinesVisible(true);
        renderer.setDefaultItemLabelsVisible(false);
        for (int i = 0; i < series.size(); i++) {
            renderer.setSeriesPaint(i, seriesColor(i));
            renderer.setSeriesFillPaint(i, seriesColor(i));
            renderer.setSeriesShape(i, seriesShape(i));
            renderer.setSeriesStroke(i, new BasicStroke(6.0f,
                    BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        }
        plot.setRenderer(renderer);
        return render(chart, width, scaleLineChartHeight(height),
                yAxisLabel, true, percentAxis, valueDecimals);
    }
    public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
                           int width, int height) {
        DefaultPieDataset<String> dataset = new DefaultPieDataset<>();
        for (ChartCategory category : categories) {
            dataset.setValue(category.getName(), category.getValue());
        }
        PiePlot<String> piePlot = new WaterRightPiePlot<>(dataset);
        JFreeChart chart = new JFreeChart(null, JFreeChart.DEFAULT_TITLE_FONT, piePlot, true);
        applyTheme(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
        chart.getLegend().setPosition(RectangleEdge.BOTTOM);
        chart.getLegend().setItemFont(LEGEND_FONT);
        chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
        chart.getLegend().setBackgroundPaint(Color.WHITE);
        chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
        PiePlot plot = piePlot;
        plot.setBackgroundPaint(Color.WHITE);
        plot.setOutlineVisible(false);
        plot.setShadowPaint(null);
        plot.setLabelFont(ITEM_LABEL_FONT);
        plot.setLabelPaint(ITEM_LABEL_COLOR);
        plot.setLabelBackgroundPaint(Color.WHITE);
        plot.setLabelOutlinePaint(new Color(160, 160, 160));
        plot.setLabelShadowPaint(null);
        plot.setLabelLinkPaint(Color.BLACK);
        plot.setLabelLinkStroke(new BasicStroke(2.0f));
        plot.setLabelGap(0.02);
        plot.setMaximumLabelWidth(0.24);
        plot.setSimpleLabels(false);
        plot.setInteriorGap(0.06);
        plot.setStartAngle(90.0);
        for (ChartCategory category : categories) {
            plot.setSectionOutlinePaint(category.getName(), Color.WHITE);
            plot.setSectionOutlineStroke(category.getName(), new BasicStroke(1.2f));
        }
        plot.setLabelGenerator(new StandardPieSectionLabelGenerator(
                "{0} {2}", new DecimalFormat("0"), new DecimalFormat("0.00%")));
        int index = 0;
        for (ChartCategory category : categories) {
            plot.setSectionPaint(category.getName(), seriesColor(index++));
        }
        return render(chart, width, height, valueSuffix, false, false, 2);
    }
    public byte[] stackedBarChart(String yAxisLabel, List<String> categories,
                                  List<ChartSeries> series, boolean percentAxis,
                                  int width, int height) {
        DefaultCategoryDataset dataset = new DefaultCategoryDataset();
        for (ChartSeries item : series) {
            for (int i = 0; i < categories.size() && i < item.getValues().size(); i++) {
                BigDecimal value = item.getValues().get(i);
                if (value != null) {
                    dataset.addValue(value, item.getName(), categories.get(i));
                }
            }
        }
        JFreeChart chart = ChartFactory.createStackedBarChart(
                null, null, null, dataset, PlotOrientation.HORIZONTAL, true, false, false);
        applyTheme(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
        chart.getLegend().setPosition(RectangleEdge.BOTTOM);
        chart.getLegend().setItemFont(LEGEND_FONT);
        chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
        chart.getLegend().setBackgroundPaint(Color.WHITE);
        chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
        CategoryPlot plot = chart.getCategoryPlot();
        plot.setBackgroundPaint(Color.WHITE);
        plot.setDomainGridlinesVisible(false);
        plot.setRangeGridlinesVisible(false);
        plot.setOutlineVisible(false);
        plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
        // Horizontal stacked bars: value axis along the bottom, no vertical axis line.
        plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT);
        CategoryAxis domainAxis = plot.getDomainAxis();
        domainAxis.setAxisLineVisible(false);
        domainAxis.setTickMarksVisible(false);
        styleDomainAxis(domainAxis);
        if (series.size() >= 3) {
            RectangleInsets insets = plot.getInsets();
            plot.setInsets(new RectangleInsets(insets.getTop(), insets.getLeft(),
                    insets.getBottom(), insets.getRight() + RIGHT_LABEL_BAND));
        }
        NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
        rangeAxis.setTickLabelFont(TICK_FONT);
        rangeAxis.setLabelFont(LABEL_FONT);
        rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
        rangeAxis.setLabelPaint(AXIS_COLOR);
        rangeAxis.setAxisLinePaint(AXIS_COLOR);
        rangeAxis.setTickMarkPaint(AXIS_COLOR);
        rangeAxis.setAutoRangeIncludesZero(true);
        rangeAxis.setLowerMargin(0.05);
        rangeAxis.setUpperMargin(0.12);
        if (percentAxis) {
            rangeAxis.setRange(0.0, 1.0);
        }
        NumberFormat valueFormat = percentAxis
                ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
        valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2);
        rangeAxis.setNumberFormatOverride(valueFormat);
        StackedBarRenderer renderer = new StackedBarRenderer();
        renderer.setDefaultItemLabelsVisible(false);
        renderer.setBarPainter(new org.jfree.chart.renderer.category.StandardBarPainter());
        renderer.setShadowVisible(false);
        renderer.setItemMargin(0.05);
        for (int i = 0; i < series.size(); i++) {
            renderer.setSeriesPaint(i, seriesColor(i));
        }
        plot.setRenderer(renderer);
        return render(chart, width, height, yAxisLabel, true, percentAxis, 2);
    }
    private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> {
        private DefaultKeyedValues<K> waterKeys;
        private WaterRightPiePlot(org.jfree.data.general.PieDataset<K> dataset) {
            super(dataset);
        }
        @Override
        protected void drawLeftLabels(KeyedValues<K> leftKeys, Graphics2D g2,
                                      Rectangle2D plotArea, Rectangle2D linkArea,
                                      float maxLabelWidth, PiePlotState state) {
            DefaultKeyedValues<K> filtered = new DefaultKeyedValues<>();
            waterKeys = new DefaultKeyedValues<>();
            for (int i = 0; i < leftKeys.getItemCount(); i++) {
                K key = leftKeys.getKey(i);
                if ("水路".equals(String.valueOf(key))) {
                    waterKeys.addValue(key, leftKeys.getValue(i));
                } else {
                    filtered.addValue(key, leftKeys.getValue(i));
                }
            }
            super.drawLeftLabels(filtered, g2, plotArea, linkArea, maxLabelWidth, state);
        }
        @Override
        protected void drawRightLabels(KeyedValues<K> rightKeys, Graphics2D g2,
                                       Rectangle2D plotArea, Rectangle2D linkArea,
                                       float maxLabelWidth, PiePlotState state) {
            DefaultKeyedValues<K> merged = new DefaultKeyedValues<>();
            if (waterKeys != null) {
                for (int i = 0; i < waterKeys.getItemCount(); i++) {
                    merged.addValue(waterKeys.getKey(i), waterKeys.getValue(i));
                }
            }
            for (int i = 0; i < rightKeys.getItemCount(); i++) {
                merged.addValue(rightKeys.getKey(i), rightKeys.getValue(i));
            }
            super.drawRightLabels(merged, g2, plotArea, linkArea, maxLabelWidth, state);
        }
        @Override
        protected void drawLeftLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) {
            recolorLabel(record);
            super.drawLeftLabel(g2, state, record);
        }
        @Override
        protected void drawRightLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) {
            recolorLabel(record);
            super.drawRightLabel(g2, state, record);
        }
        private void recolorLabel(PieLabelRecord record) {
            Paint paint = getSectionPaint(record.getKey());
            Object label = record.getLabel();
            if (paint == null || label == null) return;
            recolorPaintFields(label, paint, new IdentityHashMap<Object, Boolean>());
        }
        private void recolorPaintFields(Object node, Paint paint,
                                        Map<Object, Boolean> visited) {
            if (node == null || visited.put(node, Boolean.TRUE) != null) return;
            if (node instanceof Paint || node instanceof String || node instanceof Number
                    || node instanceof Font) {
                return;
            }
            if (node instanceof Iterable) {
                for (Object child : (Iterable<?>) node) {
                    recolorPaintFields(child, paint, visited);
                }
                return;
            }
            if (!node.getClass().getName().startsWith("org.jfree.chart.text")) return;
            Class<?> type = node.getClass();
            while (type != null && type != Object.class) {
                for (Field field : type.getDeclaredFields()) {
                    if (Modifier.isStatic(field.getModifiers())) continue;
                    try {
                        field.setAccessible(true);
                        Object value = field.get(node);
                        if (value instanceof Paint && node instanceof TextFragment) {
                            field.set(node, paint);
                        } else if (value instanceof Iterable
                                || value != null && value.getClass().getName()
                                .startsWith("org.jfree.chart.text")) {
                            recolorPaintFields(value, paint, visited);
                        }
                    } catch (IllegalAccessException ignored) {
                        // Keep the default label paint if a JFreeChart field cannot be read.
                    }
                }
                type = type.getSuperclass();
            }
        }
    }
    private Shape seriesShape(int seriesIndex) {
        if (seriesIndex % 3 == 1) {
            return new Rectangle2D.Double(-6.0, -6.0, 12.0, 12.0);
        }
        if (seriesIndex % 3 == 2) {
            Path2D.Double diamond = new Path2D.Double();
            diamond.moveTo(0.0, -7.0);
            diamond.lineTo(7.0, 0.0);
            diamond.lineTo(0.0, 7.0);
            diamond.lineTo(-7.0, 0.0);
            diamond.closePath();
            return diamond;
        }
        return new Ellipse2D.Double(-6.0, -6.0, 12.0, 12.0);
    }
    private void stylePlot(XYPlot plot) {
        plot.setBackgroundPaint(Color.WHITE);
        plot.setOutlineVisible(false);
        plot.setDomainGridlinesVisible(false);
        plot.setRangeGridlinesVisible(false);
    }
    private void styleDomainAxis(org.jfree.chart.axis.CategoryAxis axis) {
        axis.setTickLabelFont(TICK_FONT);
        axis.setLabelFont(LABEL_FONT);
        axis.setTickLabelPaint(MUTED_TEXT_COLOR);
        axis.setLabelPaint(AXIS_COLOR);
        axis.setAxisLinePaint(AXIS_COLOR);
        axis.setTickMarkPaint(AXIS_COLOR);
        axis.setCategoryMargin(0.05);
    }
    private void styleDomainAxis(org.jfree.chart.axis.ValueAxis axis) {
        axis.setTickLabelFont(TICK_FONT);
        axis.setLabelFont(LABEL_FONT);
        axis.setTickLabelPaint(MUTED_TEXT_COLOR);
        axis.setLabelPaint(AXIS_COLOR);
        axis.setAxisLinePaint(AXIS_COLOR);
        axis.setTickMarkPaint(AXIS_COLOR);
    }
    private void applyTheme(JFreeChart chart) {
        StandardChartTheme theme = new StandardChartTheme("CN");
        theme.setExtraLargeFont(BASE_FONT.deriveFont(Font.BOLD, 22f));
        theme.setLargeFont(BASE_FONT.deriveFont(Font.BOLD, 18f));
        theme.setRegularFont(BASE_FONT);
        theme.setSmallFont(TICK_FONT);
        theme.setChartBackgroundPaint(Color.WHITE);
        theme.setPlotBackgroundPaint(Color.WHITE);
        theme.setAxisLabelPaint(AXIS_COLOR);
        theme.setTickLabelPaint(MUTED_TEXT_COLOR);
        theme.setLegendItemPaint(MUTED_TEXT_COLOR);
        theme.setLegendBackgroundPaint(Color.WHITE);
        theme.apply(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
    }
    private Color seriesColor(int index) {
        return SERIES_COLORS[index % SERIES_COLORS.length];
    }
    private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
                         boolean axisChart, boolean percentItemLabels, int valueDecimals) {
        try {
            reserveUnitLabelBand(chart, axisChart, unitLabel);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            org.jfree.chart.ChartRenderingInfo info = new org.jfree.chart.ChartRenderingInfo();
            java.awt.image.BufferedImage image =
                    chart.createBufferedImage(width, height, info);
            Graphics2D graphics = image.createGraphics();
            try {
                graphics.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING,
                        java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
                if (axisChart && isHorizontalStackedChart(chart)) {
                    drawUnitLabelTopRight(graphics, unitLabel, info.getPlotInfo().getDataArea());
                    drawStackedValueAxis(graphics, info.getPlotInfo().getDataArea());
                } else if (axisChart) {
                    drawAxisArrows(graphics, info.getPlotInfo().getDataArea());
                    drawUnitLabel(graphics, unitLabel, info.getPlotInfo().getDataArea());
                    drawDateLabel(graphics, info.getPlotInfo().getDataArea());
                }
                drawXYItemLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
                drawStackedBarLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels);
            } finally {
                graphics.dispose();
            }
            javax.imageio.ImageIO.write(image, "png", out);
            return out.toByteArray();
        } catch (Exception e) {
            throw new IllegalArgumentException("走势图图片生成失败: " + e.getMessage(), e);
        }
    }
    private void drawAxisArrows(Graphics2D graphics, Rectangle2D dataArea) {
        if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return;
        graphics.setColor(AXIS_COLOR);
        graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        int x = (int) Math.round(dataArea.getX());
        int y = (int) Math.round(dataArea.getY());
        int right = (int) Math.round(dataArea.getMaxX());
        int bottom = (int) Math.round(dataArea.getMaxY());
        graphics.drawLine(x, y, x, bottom);
        graphics.drawLine(x, bottom, right, bottom);
        drawArrowHead(graphics, right, bottom, 1, 0);
        drawArrowHead(graphics, x, y, 0, -1);
    }
    private void drawArrowHead(Graphics2D graphics, int x, int y, int dx, int dy) {
        int length = 11;
        int width = 5;
        if (dx != 0) {
            graphics.drawLine(x, y, x - length, y - width);
            graphics.drawLine(x, y, x - length, y + width);
        } else {
            graphics.drawLine(x, y, x - width, y + length);
            graphics.drawLine(x, y, x + width, y + length);
        }
    }
    private void drawUnitLabel(Graphics2D graphics, String unitLabel, Rectangle2D dataArea) {
        if (unitLabel == null || unitLabel.isEmpty()) return;
        graphics.setFont(LABEL_FONT);
        graphics.setColor(MUTED_TEXT_COLOR);
        java.awt.FontMetrics metrics = graphics.getFontMetrics();
        int centerX = (int) Math.round(dataArea.getX());
        int x = Math.max(4, centerX - metrics.stringWidth(unitLabel) / 2);
        int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0));
        graphics.drawString(unitLabel, x, y);
    }
    private void drawUnitLabelTopRight(Graphics2D graphics, String unitLabel,
                                       Rectangle2D dataArea) {
        if (unitLabel == null || unitLabel.isEmpty() || dataArea == null) return;
        graphics.setFont(LABEL_FONT);
        graphics.setColor(MUTED_TEXT_COLOR);
        FontMetrics metrics = graphics.getFontMetrics();
        int x = (int) Math.round(dataArea.getMaxX() - metrics.stringWidth(unitLabel));
        int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0));
        graphics.drawString(unitLabel, x, y);
    }
    private void reserveUnitLabelBand(JFreeChart chart, boolean axisChart, String unitLabel) {
        if (!axisChart || unitLabel == null || unitLabel.isEmpty()) return;
        Plot plot = chart.getPlot();
        RectangleInsets insets = plot.getInsets();
        plot.setInsets(new RectangleInsets(Math.max(insets.getTop(), UNIT_LABEL_BAND),
                insets.getLeft(), insets.getBottom(), insets.getRight()));
    }
    private boolean isHorizontalStackedChart(JFreeChart chart) {
        return chart.getPlot() instanceof CategoryPlot
                && PlotOrientation.HORIZONTAL.equals(
                ((CategoryPlot) chart.getPlot()).getOrientation());
    }
    private void drawDateLabel(Graphics2D graphics, Rectangle2D dataArea) {
        if (dataArea == null || dataArea.getWidth() <= 0) return;
        graphics.setFont(LABEL_FONT);
        graphics.setColor(MUTED_TEXT_COLOR);
        int right = (int) Math.round(dataArea.getMaxX());
        int bottom = (int) Math.round(dataArea.getMaxY());
        graphics.drawString("日期", right - 50, bottom - 28);
    }
    private void drawStackedValueAxis(Graphics2D graphics, Rectangle2D dataArea) {
        if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return;
        graphics.setColor(AXIS_COLOR);
        graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        int left = (int) Math.round(dataArea.getX());
        int right = (int) Math.round(dataArea.getMaxX());
        int bottom = (int) Math.round(dataArea.getMaxY());
        graphics.drawLine(left, bottom, right, bottom);
    }
    private void drawStackedBarLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
                                     Rectangle2D dataArea, boolean percentAxis) {
        if (!(chart.getPlot() instanceof CategoryPlot) || dataArea == null
                || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
            return;
        }
        CategoryPlot plot = (CategoryPlot) chart.getPlot();
        CategoryDataset dataset = plot.getDataset();
        CategoryAxis domainAxis = plot.getDomainAxis();
        ValueAxis rangeAxis = plot.getRangeAxis();
        if (dataset == null || domainAxis == null || rangeAxis == null) return;
        graphics.setFont(STACK_LABEL_FONT);
        FontMetrics metrics = graphics.getFontMetrics();
        int columns = dataset.getColumnCount();
        int rows = dataset.getRowCount();
        boolean horizontal = PlotOrientation.HORIZONTAL.equals(plot.getOrientation());
        List<List<StackCallout>> callouts = new ArrayList<>();
        for (int column = 0; column < columns; column++) callouts.add(new ArrayList<>());
        List<HorizontalStackCallout> horizontalCallouts = new ArrayList<>();
        List<Rectangle2D> occupied = new ArrayList<>();
        for (int column = 0; column < columns; column++) {
            double cumulative = 0.0;
            for (int row = 0; row < rows; row++) {
                Number number = dataset.getValue(row, column);
                if (number == null) continue;
                double value = number.doubleValue();
                if (value == 0.0) continue;
                String text = formatStackLabel(value, percentAxis);
                if (horizontal) {
                    double startX = rangeAxis.valueToJava2D(cumulative, dataArea,
                            plot.getRangeAxisEdge());
                    double endX = rangeAxis.valueToJava2D(cumulative + value, dataArea,
                            plot.getRangeAxisEdge());
                    double segmentWidth = Math.abs(endX - startX);
                    double centerY = domainAxis.getCategoryMiddle(column, columns, dataArea,
                            plot.getDomainAxisEdge());
                    int width = metrics.stringWidth(text);
                    if (segmentWidth >= width + 4.0) {
                        int textX = (int) Math.round((startX + endX - width) / 2.0);
                        int baseline = (int) Math.round(centerY
                                + (metrics.getAscent() - metrics.getDescent()) / 2.0);
                        graphics.setColor(Color.WHITE);
                        graphics.drawString(text, textX, baseline);
                        occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(),
                                width, metrics.getHeight()));
                    } else {
                        horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX),
                                Math.max(startX, endX), centerY, text, seriesColor(row)));
                    }
                } else {
                    double top = rangeAxis.valueToJava2D(cumulative + value, dataArea,
                            plot.getRangeAxisEdge());
                    double bottom = rangeAxis.valueToJava2D(cumulative, dataArea,
                            plot.getRangeAxisEdge());
                    double segmentHeight = Math.abs(bottom - top);
                    double x = domainAxis.getCategoryMiddle(column, columns, dataArea,
                            plot.getDomainAxisEdge());
                    if (segmentHeight >= metrics.getHeight() + 4.0) {
                        int width = metrics.stringWidth(text);
                        int y = (int) Math.round(Math.min(top, bottom)
                                + (segmentHeight - metrics.getHeight()) / 2.0
                                + metrics.getAscent());
                        graphics.setColor(Color.WHITE);
                        graphics.drawString(text, (int) Math.round(x - width / 2.0), y);
                        occupied.add(new Rectangle2D.Double(x - width / 2.0,
                                y - metrics.getAscent(), width, metrics.getHeight()));
                    } else if (estimatePercentShare(value, percentAxis) <= 0.03
                            || !percentAxis) {
                        callouts.get(column).add(new StackCallout(x,
                                (top + bottom) / 2.0, text, seriesColor(row)));
                    }
                }
                cumulative += value;
            }
        }
        if (horizontal) {
            drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied);
        } else {
            for (int column = 0; column < columns; column++) {
                drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns,
                        dataArea, metrics, occupied);
            }
        }
    }
    private void drawHorizontalStackCallouts(Graphics2D graphics,
                                              List<HorizontalStackCallout> items,
                                              Rectangle2D dataArea, FontMetrics metrics,
                                              List<Rectangle2D> occupied) {
        if (items.isEmpty()) return;
        Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>();
        for (HorizontalStackCallout item : items) {
            Long key = Long.valueOf(Math.round(item.getY()));
            List<HorizontalStackCallout> row = rows.get(key);
            if (row == null) {
                row = new ArrayList<>();
                rows.put(key, row);
            }
            row.add(item);
        }
        for (List<HorizontalStackCallout> row : rows.values()) {
            Collections.sort(row, new Comparator<HorizontalStackCallout>() {
                @Override
                public int compare(HorizontalStackCallout first, HorizontalStackCallout second) {
                    return Double.compare(first.getStartX(), second.getStartX());
                }
            });
            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));
            }
        }
    }
    private void drawStackCalloutColumn(Graphics2D graphics, BufferedImage image,
                                        List<StackCallout> items, int column, int columnCount,
                                        Rectangle2D dataArea, FontMetrics metrics,
                                        List<Rectangle2D> occupied) {
        if (items.isEmpty()) return;
        Collections.sort(items, new Comparator<StackCallout>() {
            @Override
            public int compare(StackCallout first, StackCallout second) {
                return Double.compare(first.getY(), second.getY());
            }
        });
        double categoryWidth = dataArea.getWidth() / Math.max(1, columnCount);
        double barHalf = Math.min(80.0, Math.max(18.0, categoryWidth * 0.30));
        double spacing = metrics.getHeight() + 4.0;
        double totalHeight = (items.size() - 1) * spacing;
        double averageY = 0.0;
        for (StackCallout item : items) averageY += item.getY();
        averageY /= items.size();
        double startY = averageY - totalHeight / 2.0 - metrics.getHeight() / 2.0;
        double maxStartY = dataArea.getMaxY() - metrics.getHeight() - 3.0 - totalHeight;
        startY = Math.max(dataArea.getMinY() + 3.0, Math.min(startY, maxStartY));
        double centerX = items.get(0).getX();
        double maxWidth = 0.0;
        for (StackCallout item : items) {
            maxWidth = Math.max(maxWidth, metrics.stringWidth(item.getText()));
        }
        double rightLeft = centerX + barHalf + 14.0;
        double leftLeft = centerX - barHalf - 14.0 - maxWidth;
        double rightPenalty = stackLanePenalty(rightLeft, startY, items, metrics,
                occupied, dataArea);
        double leftPenalty = stackLanePenalty(leftLeft, startY, items, metrics,
                occupied, dataArea);
        boolean rightSide = rightPenalty <= leftPenalty;
        double laneLeft = rightSide ? rightLeft : leftLeft;
        for (int i = 0; i < items.size(); i++) {
            StackCallout item = items.get(i);
            int width = metrics.stringWidth(item.getText());
            int height = metrics.getHeight();
            int y = (int) Math.round(startY + i * spacing);
            int textX = (int) Math.round(rightSide ? laneLeft : laneLeft + maxWidth - width);
            int textY = y + metrics.getAscent();
            double lineStartX = centerX + (rightSide ? barHalf : -barHalf);
            double lineEndX = rightSide ? laneLeft : laneLeft + maxWidth;
            graphics.setColor(item.getColor());
            graphics.setStroke(new BasicStroke(1.8f, BasicStroke.CAP_ROUND,
                    BasicStroke.JOIN_ROUND));
            graphics.drawLine((int) Math.round(lineStartX), (int) Math.round(item.getY()),
                    (int) Math.round(lineEndX), (int) Math.round(y + height / 2.0));
            drawLabelWithHalo(graphics, item.getText(),
                    new Rectangle2D.Double(textX, y, width, height), metrics, item.getColor());
            occupied.add(new Rectangle2D.Double(textX - 2.0, y - 2.0,
                    width + 4.0, height + 4.0));
        }
    }
    private double stackLanePenalty(double laneLeft, double startY, List<StackCallout> items,
                                    FontMetrics metrics, List<Rectangle2D> occupied,
                                    Rectangle2D dataArea) {
        double penalty = 0.0;
        for (int i = 0; i < items.size(); i++) {
            double width = metrics.stringWidth(items.get(i).getText());
            double height = metrics.getHeight();
            Rectangle2D rectangle = new Rectangle2D.Double(laneLeft, startY + i * (height + 4.0),
                    width, height);
            if (rectangle.getMinX() < dataArea.getMinX()
                    || rectangle.getMaxX() > dataArea.getMaxX()
                    || rectangle.getMaxY() > dataArea.getMaxY()) {
                penalty += 10000.0;
            }
            penalty += overlapArea(rectangle, occupied) * 100.0;
        }
        return penalty;
    }
    private double estimatePercentShare(double value, boolean percentAxis) {
        return percentAxis ? value : value / 1.0;
    }
    private String formatStackLabel(double value, boolean percentAxis) {
        if (percentAxis) {
            NumberFormat formatter = NumberFormat.getPercentInstance();
            formatter.setMaximumFractionDigits(1);
            return formatter.format(value);
        }
        return new DecimalFormat("#,##0").format(value);
    }
    private static class HorizontalStackCallout {
        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,
                                       String text, Color color) {
            this.startX = startX;
            this.endX = endX;
            this.y = y;
            this.text = text;
            this.color = color;
        }
        private double getStartX() { return startX; }
        private double getEndX() { return endX; }
        private double getY() { return y; }
        private String getText() { return text; }
        private Color getColor() { return color; }
    }
    private static class StackCallout {
        private final double x;
        private final double y;
        private final String text;
        private final Color color;
        private StackCallout(double x, double y, String text, Color color) {
            this.x = x;
            this.y = y;
            this.text = text;
            this.color = color;
        }
        private double getX() { return x; }
        private double getY() { return y; }
        private String getText() { return text; }
        private Color getColor() { return color; }
    }
    private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
                                  Rectangle2D dataArea, boolean percentItemLabels,
                                  String unitLabel, int valueDecimals) {
        if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
                || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
            return;
        }
        XYPlot plot = (XYPlot) chart.getXYPlot();
        XYDataset dataset = plot.getDataset();
        if (dataset == null) return;
        graphics.setFont(ITEM_LABEL_FONT);
        FontMetrics metrics = graphics.getFontMetrics();
        List<Rectangle2D> placedLabels = 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 < itemCount; itemIndex++) {
            List<DayLabel> labels = new ArrayList<>();
            for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
                if (itemIndex >= dataset.getItemCount(seriesIndex)) continue;
                Number yValue = dataset.getY(seriesIndex, itemIndex);
                Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
                if (yValue == null || point == null) continue;
                labels.add(new DayLabel(seriesIndex, point,
                        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.
            Collections.sort(labels, new Comparator<DayLabel>() {
                @Override
                public int compare(DayLabel first, DayLabel second) {
                    return Double.compare(first.getPoint().getY(), second.getPoint().getY());
                }
            });
            List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
            for (DayLabel label : labels) {
                List<LineLabelCandidate> candidates = lineLabelCandidates(
                        label, metrics, dataArea);
                if (candidates.isEmpty()) {
                    candidateSets.clear();
                    break;
                }
                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() - 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 {
        private final int seriesIndex;
        private final Point2D point;
        private final String text;
        private DayLabel(int seriesIndex, Point2D point, String text) {
            this.seriesIndex = seriesIndex;
            this.point = point;
            this.text = text;
        }
        private int getSeriesIndex() { return seriesIndex; }
        private Point2D getPoint() { return point; }
        private String getText() { return text; }
    }
    private Point2D itemPoint(XYPlot plot, XYDataset dataset, int seriesIndex,
                              int itemIndex, Rectangle2D dataArea) {
        Number yValue = dataset.getY(seriesIndex, itemIndex);
        if (yValue == null || Double.isNaN(yValue.doubleValue())
                || Double.isInfinite(yValue.doubleValue())) {
            return null;
        }
        double x = plot.getDomainAxis().valueToJava2D(dataset.getXValue(seriesIndex, itemIndex),
                dataArea, plot.getDomainAxisEdge());
        double y = plot.getRangeAxis().valueToJava2D(yValue.doubleValue(),
                dataArea, plot.getRangeAxisEdge());
        return new Point2D.Double(x, y);
    }
    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) {
        double minX = dataArea.getMinX() + 16.0;
        double minY = dataArea.getMinY() + 2.0;
        double maxX = dataArea.getMaxX() - rectangle.getWidth() - 2.0;
        double maxY = dataArea.getMaxY() - rectangle.getHeight() - 2.0;
        if (maxX < minX || maxY < minY) return null;
        double x = Math.max(minX, Math.min(rectangle.getX(), maxX));
        double y = Math.max(minY, Math.min(rectangle.getY(), maxY));
        return new Rectangle2D.Double(x, y, rectangle.getWidth(), rectangle.getHeight());
    }
    private double overlapArea(Rectangle2D rectangle, List<Rectangle2D> occupied) {
        double total = 0.0;
        for (Rectangle2D other : occupied) {
            double width = Math.min(rectangle.getMaxX(), other.getMaxX())
                    - Math.max(rectangle.getMinX(), other.getMinX());
            double height = Math.min(rectangle.getMaxY(), other.getMaxY())
                    - Math.max(rectangle.getMinY(), other.getMinY());
            if (width > 0.0 && height > 0.0) total += width * height;
        }
        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(PERCENT_ITEM_LABEL_DECIMALS);
            return formatter.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);
        graphics.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH,
                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        graphics.draw(outline);
        graphics.setColor(color);
        graphics.fill(outline);
    }
    public static final List<String> DAY_LABELS = Arrays.asList(
            "10月1日", "10月2日", "10月3日", "10月4日",
            "10月5日", "10月6日", "10月7日", "10月8日");
    public static List<ChartSeries> emptySeries(int size) {
        List<ChartSeries> result = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            result.add(new ChartSeries());
        }
        return result;
    }
    @Data
    public static class ChartSeries {
        private String name;
        private List<BigDecimal> values = new ArrayList<>();
    }
    @Data
    public static class ChartCategory {
        private String name;
        private BigDecimal value;
    }
}