xyc
10 小时以前 c21fbdd3687824f79c543e20114b40c27e294008
fix(holiday): 统一每日客流构成外置标签布局
2个文件已修改
107 ■■■■■ 已修改文件
HANDOFF.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -6,6 +6,16 @@
## 最新(2026-10-07 上午,新开会话交接)
### 10-07:每日客流构成图右侧标签布局修复(验收通过)
- **问题**:`image13`「每日客流构成变化」右侧外置百分比标签在相邻日期之间重叠 4 处;改为两列后引线交错,个别引线压住绿色块内数值。
- **最终规则**:只有色块内放不下的标签进入右侧外置区;外置标签按原始图例序号排序,即道路→铁路→民航→水路;最多 2 列,按“先行后列、每行从左到右”填充;1 个标签整体居中,2 个标签两列各居中,3 个标签下排单标签跨两列居中,4 个标签为 2×2 格居中;两列布局不画引线,颜色对应色块。
- **实现**:`HolidayTrendChartService` 将右侧标签带从 74 加宽到 140;`HorizontalStackCallout` 记录原始 `seriesIndex` 并按其排序;仅对每日客流构成图启用两列居中布局;公路出行结构仍走原单列带引线逻辑。
- **验证**:真实库只读生成 2026-10-07 走势图;与原版相比 15 张图中仅 `image13` 变化,其余 14 张 SHA-256 完全一致;右侧标签区 OCR 重叠从 4 降为 0;无引线。后端全量 `mvn -q test -f traffic-audit-server\pom.xml` 通过。
- **验收**:用户 2026-10-07 验收 `(12).docx` 通过。
- **后续**:10-07 数据齐全后,按系统配置的假期天数(7 天)生成最终版走势图,用于分析报告;本轮不调整假日天数来源。
- **数据边界**:未导入、覆盖或重导任何业务数据。
### 10-07:普通国省道 2025-10-05 调整值更正(已核对)
- **背景**:10-05 剔除 3 个环比降幅超过 50% 的站点(郑店、樊家庄、上津站)和 3 个 10-05 缺失站点(龙泉、华鲁交调站、大典河)。
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -74,7 +74,7 @@
    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;
@@ -615,7 +615,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 {
@@ -645,7 +645,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,
@@ -657,7 +658,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) {
@@ -673,36 +675,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));
            }
        }
    }
@@ -796,14 +844,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;
@@ -811,6 +861,7 @@
            this.color = color;
        }
        private int getSeriesIndex() { return seriesIndex; }
        private double getStartX() { return startX; }
        private double getEndX() { return endX; }
        private double getY() { return y; }