From 63e67d580739079dd1bf87ed674032d97597bcd3 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期五, 09 十月 2026 11:27:38 +0800
Subject: [PATCH] docs(交接): 记录 2026 十一假期人员流动量情况 Excel 制作与口径
---
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java | 219 ++++++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 186 insertions(+), 33 deletions(-)
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
index e0b9c88..a7c8e83 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -49,6 +49,7 @@
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
@@ -73,9 +74,11 @@
private static final Color AXIS_COLOR = Color.BLACK;
private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102);
private static final double UNIT_LABEL_BAND = 58.0;
- private static final double RIGHT_LABEL_BAND = 74.0;
+ private static final double 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),
@@ -100,6 +103,23 @@
public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
int valueDecimals, int width, int height) {
+ return lineChart(yAxisLabel, series, percentAxis, valueDecimals, DAY_LABELS, width, height);
+ }
+
+ public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
+ int valueDecimals, List<String> dayLabels, int width, int height) {
+ return lineChart(yAxisLabel, series, percentAxis, valueDecimals, dayLabels, width, height,
+ Collections.emptyList());
+ }
+
+ /**
+ * Renders a line chart and pins the listed labels to a fixed anchor. The overrides are
+ * opt-in: a chart that passes an empty list keeps the automatic placement unchanged.
+ */
+ public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
+ int valueDecimals, List<String> dayLabels, int width, int height,
+ List<LineLabelOverride> labelOverrides) {
+ List<String> axisLabels = (dayLabels == null || dayLabels.isEmpty()) ? DAY_LABELS : dayLabels;
XYSeriesCollection dataset = new XYSeriesCollection();
for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
ChartSeries item = series.get(seriesIndex);
@@ -107,7 +127,7 @@
for (int i = 0; i < item.getValues().size(); i++) {
BigDecimal value = item.getValues().get(i);
if (value != null) {
- xySeries.add(i, value.doubleValue());
+ xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue());
}
}
dataset.addSeries(xySeries);
@@ -129,7 +149,7 @@
plot.setRangeGridlinesVisible(false);
plot.setDomainTickBandPaint(null);
plot.setRangeTickBandPaint(null);
- SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
+ 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);
@@ -139,8 +159,8 @@
NumberAxis rangeAxis = new NumberAxis();
rangeAxis.setAutoRangeIncludesZero(false);
- rangeAxis.setLowerMargin(0.12);
- rangeAxis.setUpperMargin(0.16);
+ rangeAxis.setLowerMargin(0.04);
+ rangeAxis.setUpperMargin(0.07);
rangeAxis.setTickLabelFont(TICK_FONT);
rangeAxis.setLabelFont(LABEL_FONT);
rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
@@ -171,7 +191,8 @@
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
}
plot.setRenderer(renderer);
- return render(chart, width, height, yAxisLabel, true, percentAxis, valueDecimals);
+ return render(chart, width, scaleLineChartHeight(height),
+ yAxisLabel, true, percentAxis, valueDecimals, labelOverrides);
}
public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
@@ -451,6 +472,13 @@
private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
boolean axisChart, boolean percentItemLabels, int valueDecimals) {
+ return render(chart, width, height, unitLabel, axisChart, percentItemLabels,
+ valueDecimals, Collections.emptyList());
+ }
+
+ private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
+ boolean axisChart, boolean percentItemLabels, int valueDecimals,
+ List<LineLabelOverride> labelOverrides) {
try {
reserveUnitLabelBand(chart, axisChart, unitLabel);
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -470,7 +498,8 @@
drawDateLabel(graphics, info.getPlotInfo().getDataArea());
}
drawXYItemLabels(graphics, image, chart,
- info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
+ info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals,
+ labelOverrides);
drawStackedBarLabels(graphics, image, chart,
info.getPlotInfo().getDataArea(), percentItemLabels);
} finally {
@@ -611,7 +640,7 @@
occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(),
width, metrics.getHeight()));
} else {
- horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX),
+ horizontalCallouts.add(new HorizontalStackCallout(row, Math.min(startX, endX),
Math.max(startX, endX), centerY, text, seriesColor(row)));
}
} else {
@@ -641,7 +670,8 @@
}
}
if (horizontal) {
- drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied);
+ drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied,
+ rows >= 3);
} else {
for (int column = 0; column < columns; column++) {
drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns,
@@ -653,7 +683,8 @@
private void drawHorizontalStackCallouts(Graphics2D graphics,
List<HorizontalStackCallout> items,
Rectangle2D dataArea, FontMetrics metrics,
- List<Rectangle2D> occupied) {
+ List<Rectangle2D> occupied,
+ boolean packIntoColumns) {
if (items.isEmpty()) return;
Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>();
for (HorizontalStackCallout item : items) {
@@ -669,36 +700,82 @@
Collections.sort(row, new Comparator<HorizontalStackCallout>() {
@Override
public int compare(HorizontalStackCallout first, HorizontalStackCallout second) {
- return Double.compare(first.getStartX(), second.getStartX());
+ return Integer.compare(first.getSeriesIndex(), second.getSeriesIndex());
}
});
- int widest = 0;
- double columnX = 0.0;
- for (HorizontalStackCallout item : row) {
- widest = Math.max(widest, metrics.stringWidth(item.getText()));
- columnX = Math.max(columnX, item.getEndX() + 8.0);
+ if (!packIntoColumns) {
+ int widest = 0;
+ double columnX = 0.0;
+ for (HorizontalStackCallout item : row) {
+ widest = Math.max(widest, metrics.stringWidth(item.getText()));
+ columnX = Math.max(columnX, item.getEndX() + 8.0);
+ }
+ // Keep the label column inside the reserved right margin.
+ columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest);
+ int height = metrics.getHeight();
+ double step = height + 3.0;
+ double centerY = row.get(0).getY();
+ double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0;
+ for (int i = 0; i < row.size(); i++) {
+ HorizontalStackCallout item = row.get(i);
+ int width = metrics.stringWidth(item.getText());
+ double x = columnX;
+ double y = top + i * step;
+ graphics.setColor(item.getColor());
+ graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND,
+ BasicStroke.JOIN_ROUND));
+ graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()),
+ (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0));
+ drawLabelWithHalo(graphics, item.getText(),
+ new Rectangle2D.Double(x, y, width, height), metrics, item.getColor());
+ occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0,
+ width + 4.0, height + 4.0));
+ }
+ continue;
}
- // Keep the label column inside the reserved right margin.
- columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest);
+ // Small slices are labelled outside the bar. Packing those labels
+ // into two columns keeps each day's callouts inside its own row.
+ int columnCount = row.size() <= 1 ? 1 : 2;
+ int rowCount = (int) Math.ceil(row.size() / (double) columnCount);
+ double[] columnWidths = new double[columnCount];
+ for (int i = 0; i < row.size(); i++) {
+ int column = columnCount == 1 ? 0 : i % columnCount;
+ columnWidths[column] = Math.max(columnWidths[column],
+ metrics.stringWidth(row.get(i).getText()));
+ }
+ double columnGap = 10.0;
+ double blockWidth = columnGap * (columnCount - 1);
+ for (double columnWidth : columnWidths) blockWidth += columnWidth;
+ double blockLeft = dataArea.getMaxX() + 8.0;
+ double blockRight = dataArea.getMaxX() + RIGHT_LABEL_BAND;
+ blockLeft += Math.max(0.0, (blockRight - blockLeft - blockWidth) / 2.0);
+ double[] columnCenters = new double[columnCount];
+ double cursor = blockLeft;
+ for (int column = 0; column < columnCount; column++) {
+ columnCenters[column] = cursor + columnWidths[column] / 2.0;
+ cursor += columnWidths[column] + columnGap;
+ }
int height = metrics.getHeight();
- double step = height + 3.0;
- double centerY = row.get(0).getY();
- double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0;
+ double rowStep = height + 3.0;
+ double totalHeight = rowCount * height + (rowCount - 1) * (rowStep - height);
+ double top = row.get(0).getY() - totalHeight / 2.0;
for (int i = 0; i < row.size(); i++) {
HorizontalStackCallout item = row.get(i);
+ int column = columnCount == 1 ? 0 : i % columnCount;
+ int rowIndex = columnCount == 1 ? i : i / columnCount;
+ int itemsInRow = Math.min(columnCount, row.size() - rowIndex * columnCount);
int width = metrics.stringWidth(item.getText());
- double x = columnX;
- double y = top + i * step;
- graphics.setColor(item.getColor());
- graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND,
- BasicStroke.JOIN_ROUND));
- graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()),
- (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0));
+ double centerX = itemsInRow == 1 && columnCount > 1
+ ? blockLeft + blockWidth / 2.0
+ : columnCenters[column];
+ double x = centerX - width / 2.0;
+ double y = top + rowIndex * rowStep;
drawLabelWithHalo(graphics, item.getText(),
new Rectangle2D.Double(x, y, width, height), metrics, item.getColor());
occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0,
width + 4.0, height + 4.0));
}
+
}
}
@@ -792,14 +869,16 @@
}
private static class HorizontalStackCallout {
+ private final int seriesIndex;
private final double startX;
private final double endX;
private final double y;
private final String text;
private final Color color;
- private HorizontalStackCallout(double startX, double endX, double y,
+ private HorizontalStackCallout(int seriesIndex, double startX, double endX, double y,
String text, Color color) {
+ this.seriesIndex = seriesIndex;
this.startX = startX;
this.endX = endX;
this.y = y;
@@ -807,6 +886,7 @@
this.color = color;
}
+ private int getSeriesIndex() { return seriesIndex; }
private double getStartX() { return startX; }
private double getEndX() { return endX; }
private double getY() { return y; }
@@ -835,7 +915,8 @@
private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
Rectangle2D dataArea, boolean percentItemLabels,
- String unitLabel, int valueDecimals) {
+ String unitLabel, int valueDecimals,
+ List<LineLabelOverride> labelOverrides) {
if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
|| dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
return;
@@ -877,7 +958,8 @@
List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
for (DayLabel label : labels) {
List<LineLabelCandidate> candidates = lineLabelCandidates(
- label, metrics, dataArea);
+ label, metrics, dataArea,
+ forcedAnchor(labelOverrides, label.getSeriesIndex(), itemIndex));
if (candidates.isEmpty()) {
candidateSets.clear();
break;
@@ -996,7 +1078,8 @@
}
private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics,
- Rectangle2D dataArea) {
+ Rectangle2D dataArea,
+ LineLabelAnchor forcedAnchor) {
List<LineLabelCandidate> result = new ArrayList<>();
int width = metrics.stringWidth(label.getText());
int height = metrics.getHeight();
@@ -1006,6 +1089,25 @@
double farHorizontalOffset = horizontalOffset + 18.0;
double verticalOffset = ITEM_LABEL_GAP + height / 2.0;
double farVerticalOffset = verticalOffset + height + 6.0;
+ if (forcedAnchor != null) {
+ switch (forcedAnchor) {
+ case ABOVE:
+ addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
+ break;
+ case BELOW:
+ addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
+ break;
+ case LEFT:
+ addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea);
+ break;
+ case RIGHT:
+ addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
+ break;
+ default:
+ break;
+ }
+ return result;
+ }
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);
@@ -1047,6 +1149,43 @@
addLabelCandidate(result, x, y, width, height,
-farHorizontalOffset, farVerticalOffset, dataArea);
return result;
+ }
+
+ /**
+ * Fixed positions a data label can be pinned to, relative to its own point.
+ */
+ public enum LineLabelAnchor {
+ ABOVE, BELOW, LEFT, RIGHT
+ }
+
+ /**
+ * Opt-in request that pins one data label (series + item) to a fixed anchor.
+ */
+ public static class LineLabelOverride {
+ private final int seriesIndex;
+ private final int itemIndex;
+ private final LineLabelAnchor anchor;
+
+ public LineLabelOverride(int seriesIndex, int itemIndex, LineLabelAnchor anchor) {
+ this.seriesIndex = seriesIndex;
+ this.itemIndex = itemIndex;
+ this.anchor = anchor;
+ }
+
+ public int getSeriesIndex() { return seriesIndex; }
+ public int getItemIndex() { return itemIndex; }
+ public LineLabelAnchor getAnchor() { return anchor; }
+ }
+
+ private static LineLabelAnchor forcedAnchor(List<LineLabelOverride> labelOverrides,
+ int seriesIndex, int itemIndex) {
+ if (labelOverrides == null) return null;
+ for (LineLabelOverride override : labelOverrides) {
+ if (override.getSeriesIndex() == seriesIndex && override.getItemIndex() == itemIndex) {
+ return override.getAnchor();
+ }
+ }
+ return null;
}
private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y,
@@ -1113,10 +1252,24 @@
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(1);
+ formatter.setMaximumFractionDigits(PERCENT_ITEM_LABEL_DECIMALS);
return formatter.format(value.doubleValue());
}
DecimalFormat formatter = new DecimalFormat("#,##0");
--
Gitblit v1.9.1