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<ChartSeries> series, boolean percentAxis) {
|
return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, 2, 1100, 590);
|
}
|
|
public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
|
int width, int height) {
|
return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, 2, width, height);
|
}
|
|
public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
|
int valueDecimals, int width, int height) {
|
return lineChart(yAxisLabel, series, percentAxis, DAY_LABELS, valueDecimals, width, height);
|
}
|
|
public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
|
List<String> dayLabels, int valueDecimals, int width, int height) {
|
XYSeriesCollection dataset = new XYSeriesCollection();
|
for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
|
ChartSeries item = series.get(seriesIndex);
|
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<String> axisLabels = dayLabels == null || dayLabels.isEmpty() ? DAY_LABELS : dayLabels;
|
SymbolAxis domainAxis = new SymbolAxis(null, axisLabels.toArray(new String[0]));
|
domainAxis.setGridBandPaint(new Color(0, 0, 0, 0));
|
domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0));
|
domainAxis.setLowerMargin(0.04);
|
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(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<HorizontalStackCallout> items,
|
Rectangle2D dataArea, FontMetrics metrics,
|
List<Rectangle2D> occupied,
|
boolean packIntoColumns) {
|
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 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<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 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<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;
|
}
|
}
|