From 2f3c2d2851f7693357feb9e62a56a64670755c6a Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期二, 06 十月 2026 14:25:27 +0800
Subject: [PATCH] fix(holiday): 拥挤度标签恢复两位小数
---
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java | 269 +++++++++++++++++++++++++++++++++++++++++++----------
1 files changed, 215 insertions(+), 54 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 8d359c4..d01765e 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;
@@ -74,7 +75,9 @@
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 = 12.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 Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
private static final Color[] SERIES_COLORS = {
new Color(192, 80, 77),
@@ -94,6 +97,11 @@
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);
@@ -101,7 +109,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);
@@ -147,7 +155,7 @@
}
NumberFormat valueFormat = percentAxis
? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
- valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2);
+ valueFormat.setMaximumFractionDigits(percentAxis ? 0 : valueDecimals);
rangeAxis.setNumberFormatOverride(valueFormat);
plot.setRangeAxis(rangeAxis);
plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
@@ -165,7 +173,7 @@
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
}
plot.setRenderer(renderer);
- return render(chart, width, height, yAxisLabel, true, percentAxis);
+ return render(chart, width, height, yAxisLabel, true, percentAxis, valueDecimals);
}
public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
@@ -211,7 +219,7 @@
for (ChartCategory category : categories) {
plot.setSectionPaint(category.getName(), seriesColor(index++));
}
- return render(chart, width, height, valueSuffix, false, false);
+ return render(chart, width, height, valueSuffix, false, false, 2);
}
public byte[] stackedBarChart(String yAxisLabel, List<String> categories,
@@ -281,7 +289,7 @@
renderer.setSeriesPaint(i, seriesColor(i));
}
plot.setRenderer(renderer);
- return render(chart, width, height, yAxisLabel, true, percentAxis);
+ return render(chart, width, height, yAxisLabel, true, percentAxis, 2);
}
private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> {
@@ -444,7 +452,7 @@
}
private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
- boolean axisChart, boolean percentItemLabels) {
+ boolean axisChart, boolean percentItemLabels, int valueDecimals) {
try {
reserveUnitLabelBand(chart, axisChart, unitLabel);
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -464,7 +472,7 @@
drawDateLabel(graphics, info.getPlotInfo().getDataArea());
}
drawXYItemLabels(graphics, image, chart,
- info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel);
+ info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
drawStackedBarLabels(graphics, image, chart,
info.getPlotInfo().getDataArea(), percentItemLabels);
} finally {
@@ -829,7 +837,7 @@
private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
Rectangle2D dataArea, boolean percentItemLabels,
- String unitLabel) {
+ String unitLabel, int valueDecimals) {
if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
|| dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
return;
@@ -841,7 +849,6 @@
graphics.setFont(ITEM_LABEL_FONT);
FontMetrics metrics = graphics.getFontMetrics();
List<Rectangle2D> placedLabels = new ArrayList<>();
- List<Rectangle2D> markers = new ArrayList<>();
if (unitLabel != null && !unitLabel.isEmpty()) {
placedLabels.add(new Rectangle2D.Double(0.0, 0.0,
dataArea.getMinX() + 100.0, dataArea.getMinY() + 30.0));
@@ -849,13 +856,6 @@
int itemCount = 0;
for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
itemCount = Math.max(itemCount, dataset.getItemCount(seriesIndex));
- for (int itemIndex = 0; itemIndex < dataset.getItemCount(seriesIndex); itemIndex++) {
- Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
- if (point != null) {
- markers.add(new Rectangle2D.Double(point.getX() - 9.0,
- point.getY() - 9.0, 18.0, 18.0));
- }
- }
}
for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) {
@@ -866,7 +866,7 @@
Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
if (yValue == null || point == null) continue;
labels.add(new DayLabel(seriesIndex, point,
- formatLineLabel(yValue, percentItemLabels)));
+ 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.
@@ -876,35 +876,95 @@
return Double.compare(first.getPoint().getY(), second.getPoint().getY());
}
});
- double previousBottom = Double.NEGATIVE_INFINITY;
+ List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
for (DayLabel label : labels) {
- int width = metrics.stringWidth(label.getText());
- int height = metrics.getHeight();
- double x = label.getPoint().getX() - width / 2.0;
- double top = Math.max(label.getPoint().getY() - ITEM_LABEL_GAP - height,
- previousBottom + 3.0);
- Rectangle2D chosen = null;
- double bestPenalty = Double.MAX_VALUE;
- for (int step = 0; step <= 4; step++) {
- Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double(
- x, top + step * (height + 4.0), width, height), dataArea);
- if (candidate == null) continue;
- double penalty = overlapArea(candidate, placedLabels) * 1000.0
- + overlapArea(candidate, markers);
- if (penalty < bestPenalty) {
- bestPenalty = penalty;
- chosen = candidate;
- }
- if (penalty <= 0.0) break;
+ List<LineLabelCandidate> candidates = lineLabelCandidates(
+ label, metrics, dataArea);
+ if (candidates.isEmpty()) {
+ candidateSets.clear();
+ break;
}
- if (chosen == null) continue;
+ 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() - 5.0, chosen.getY() - 5.0,
- chosen.getWidth() + 10.0, chosen.getHeight() + 10.0));
- previousBottom = chosen.getMaxY();
+ 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 {
@@ -937,10 +997,99 @@
return new Point2D.Double(x, y);
}
- private void addLabelCandidate(List<Rectangle2D> result, double x, double y,
- int width, int height, double dx, double dy) {
- result.add(new Rectangle2D.Double(x + dx - width / 2.0,
- y + dy - height / 2.0, width, height));
+ 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) {
@@ -966,27 +1115,39 @@
return total;
}
- private String formatLineLabel(Number value, boolean percentItemLabels) {
+ 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());
}
- return new DecimalFormat("#,##0.##").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);
- for (int dx = -1; dx <= 1; dx++) {
- for (int dy = -1; dy <= 1; dy++) {
- if (dx != 0 || dy != 0) graphics.drawString(text, x + dx, y + dy);
- }
- }
+ graphics.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH,
+ BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ graphics.draw(outline);
graphics.setColor(color);
- graphics.drawString(text, x, y);
+ graphics.fill(outline);
}
public static final List<String> DAY_LABELS = Arrays.asList(
--
Gitblit v1.9.1