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