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 = 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), 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 series, boolean percentAxis) { return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, 2, 1100, 590); } public byte[] lineChart(String yAxisLabel, List series, boolean percentAxis, int width, int height) { return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, 2, width, height); } public byte[] lineChart(String yAxisLabel, List 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 series, boolean percentAxis, List dayLabels, 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); List 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); 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 categories, int width, int height) { DefaultPieDataset dataset = new DefaultPieDataset<>(); for (ChartCategory category : categories) { dataset.setValue(category.getName(), category.getValue()); } PiePlot 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 categories, List 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> extends PiePlot { private DefaultKeyedValues waterKeys; private WaterRightPiePlot(org.jfree.data.general.PieDataset dataset) { super(dataset); } @Override protected void drawLeftLabels(KeyedValues leftKeys, Graphics2D g2, Rectangle2D plotArea, Rectangle2D linkArea, float maxLabelWidth, PiePlotState state) { DefaultKeyedValues 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 rightKeys, Graphics2D g2, Rectangle2D plotArea, Rectangle2D linkArea, float maxLabelWidth, PiePlotState state) { DefaultKeyedValues 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()); } private void recolorPaintFields(Object node, Paint paint, Map 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> callouts = new ArrayList<>(); for (int column = 0; column < columns; column++) callouts.add(new ArrayList<>()); List horizontalCallouts = new ArrayList<>(); List 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(row, 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, rows >= 3); } 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 items, Rectangle2D dataArea, FontMetrics metrics, List occupied, boolean packIntoColumns) { if (items.isEmpty()) return; Map> rows = new LinkedHashMap<>(); for (HorizontalStackCallout item : items) { Long key = Long.valueOf(Math.round(item.getY())); List row = rows.get(key); if (row == null) { row = new ArrayList<>(); rows.put(key, row); } row.add(item); } for (List row : rows.values()) { Collections.sort(row, new Comparator() { @Override public int compare(HorizontalStackCallout first, HorizontalStackCallout second) { return Integer.compare(first.getSeriesIndex(), second.getSeriesIndex()); } }); 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; } // 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 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 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 void drawStackCalloutColumn(Graphics2D graphics, BufferedImage image, List items, int column, int columnCount, Rectangle2D dataArea, FontMetrics metrics, List occupied) { if (items.isEmpty()) return; Collections.sort(items, new Comparator() { @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 items, FontMetrics metrics, List 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 int seriesIndex; private final double startX; private final double endX; private final double y; private final String text; private final Color color; 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.text = text; this.color = color; } private int getSeriesIndex() { return seriesIndex; } 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 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 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() { @Override public int compare(DayLabel first, DayLabel second) { return Double.compare(first.getPoint().getY(), second.getPoint().getY()); } }); List> candidateSets = new ArrayList<>(); for (DayLabel label : labels) { List 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 backPointers = new ArrayList<>(); List 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 previousCandidates = candidateSets.get(labelIndex - 1); List 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 lineLabelCandidates(DayLabel label, FontMetrics metrics, Rectangle2D dataArea) { List 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 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 sameDayLabels, List 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 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 DAY_LABELS = Arrays.asList( "10月1日", "10月2日", "10月3日", "10月4日", "10月5日", "10月6日", "10月7日", "10月8日"); public static List emptySeries(int size) { List 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 values = new ArrayList<>(); } @Data public static class ChartCategory { private String name; private BigDecimal value; } }