xyc
3 天以前 bdcd315656cf31777522ed1f7c9f72668d377903
fix(holiday): 走势图折线数值标签回到贴近本点并加白描边
2个文件已修改
252 ■■■■■ 已修改文件
HANDOFF.md 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -5,6 +5,30 @@
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
## 最新(2026-10-04)
### 10-04 深夜:折线数值标签回到贴近本点(v35,待用户验收)
- **用户反馈**:v31「避开折线」版本不如上一版 v27 —— 虽然标签不压线了,但很多标签离自己的数据点太远,一眼看不出标的是哪个点。要求回到贴近本点的效果。
- **新问题与旧需求的冲突**:更早的原始诉求是「数字压线看不清」;这次诉求是「数字必须贴近本点」。两者并存,必须同时满足。
- **实现(`HolidayTrendChartService`)**:
  - 标签排版**完整回退到 v27 算法**:按同日点位距离做代价(`ownDistance ×10`),候选含近/远的上、下、左、右及四角,动态规划保证同日标签与点位上下顺序一致(`lineLabelCost`、`lineLabelCandidates`)。v31 的线条掩膜、Chamfer 距离场、颜色亲和项全部移除。
  - **新增粗白描边(halo)** 解决「压线看不清」:`drawLabelWithHalo` 用 `TextLayout` 取字形轮廓,先以白色 `HALO_OUTLINE_WIDTH=4.0f` 圆角描边,再用本色填充。数字即使落在折线上也清晰可读,从而允许标签贴回本点。折线图、堆叠图外置标签、饼图标签统一走该方法。
  - 顺带清理调试遗留:删除 `LABEL_DEBUG`/`CHART_SEQ`/`TRENDDBG` 打印,以及不再使用的 `lineNormal(...)` 与 `DayLabel.normalX/normalY`。
- **量化验证(RapidOCR 标签包围框中心 ↔ 真实数据点坐标)**:标签到本点平均距离 v27=`36.8px`、v31=`53.2px`、v35=`36.7px`(越小越贴近本点)。v35 与用户认可的 v27 基本一致,明显优于被否的 v31。
- **其余验证**:`mvn compile`/`mvn test` 通过(无编译告警遗留);Word COM 8 页 15 图(1+2×7);RapidOCR 复检 12 张折线图数值标签包围框重叠数 = 0;`label_affinity` 复检图 11 每个数据标签到本线 3~8px 且本线是最近线(无「CLOSER TO 其他色」);放大 6× 复核图 11 左上标签确为 `630.99`(低分辨率 OCR 曾误读为 `1630.99`)。v27↔v35 像素差约 1%,集中在标签处,即白描边生效且未破坏折线。
- **产物与状态**:`_tmp_holiday_verify/holiday_trend_2026-10-04_v35.docx`;下载副本 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-数字贴近折线-白边清晰).docx`。未导入、覆盖或重导任何数据。待用户验收;验收前不提交、不推送;现有 `deploy/traffic-audit-deploy-20261004.zip` 仍是 v24 行为,验收后需重建。
### 10-04 深夜:折线数值标签避开折线并贴近本线(v31,待用户验收)
- **用户反馈(领导看后,两轮)**:① 各年份数字离本线有远有近,希望贴近各自色线且不错乱;② 仍有数字压在折线上看不清(第一轮只是“贴近”,标签会落在线上)。用户建议:调纵轴刻度拉开折线、或加左右避让让标签不压线。
- **实现(`HolidayTrendChartService`)**:
  - 标签排版改为「动态规划 + 线条像素避让」。图表先画折线和标记,再把所有系列颜色像素(容差 45)做成掩膜,对每个候选标签位置统计其覆盖的线条像素数,`×700` 重罚——数字不再压线。
  - 为每条系列建立 Chamfer 距离场(到该色最近像素的距离)。代价新增两项:`max(0, 自己那条线的距离-3)×40`(把标签拉回本线旁,防止飘远)与颜色亲和项 `max(0, 自己线距 - 别的线距 + 4)×200`(要求本线是最近的线,避免贴到别的系列)。
  - 旧的「离别的同日点位更近」惩罚由 `×5000` 降到 `×400`,避免它压倒亲和项;`ITEM_LABEL_GAP` 12→10;候选含近/远的上、下、左、右及四角。
  - 保留同日标签与数据点一致的上下顺序,避免标签与折线上下反转。
- **调参过程提示**:v27(只按点位距离排序)→ 标签压线;v28(只加覆盖像素惩罚)→ 标签离本线、贴到别的色线旁;v31(覆盖惩罚 + 本线距离 + 颜色亲和)→ 两者兼顾。验证标签是否“贴错线”不能只看 OCR 包围框到异色的距离:相邻的异色**标签**会被误判为异色**线**,必须肉眼核对整图。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` 通过。真实库只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v31.docx`;Word COM 8 页 15 图(1+2×7)。RapidOCR 复检 15 张图数值标签包围框重叠数为 0。肉眼核对图 1(跨区域人员流动量)、图 2(累计)、图 4(自驾)、图 6(水路)、图 9(高速)、图 11(车流总量):数值标签均离开折线、位于各自色线旁、颜色对应正确;图 11 的 630.99/601.04/571.31 分别紧贴蓝/红/绿折线。
- **产物与状态**:`C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-数值标签避开折线).docx`。未导入、覆盖或重导任何数据。待用户验收,验收前不提交、不推送;现有 `deploy/traffic-audit-deploy-20261004.zip` 仍是 v24 行为,需验收后重建。
### 10-04 晚:走势图 3 项坐标轴调整(v24,用户已验收)
- **用户修改项**:① 坐标轴单位(万人次、万辆次、公里/小时、拥挤度、日期)改成和刻度一样的颜色;② 两个堆叠图的坐标轴改成与其他图一样的黑色;③ “万人次/占比”继续放在堆叠图右上角。
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -74,7 +74,8 @@
    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 Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
    private static final Color[] SERIES_COLORS = {
            new Color(192, 80, 77),
@@ -841,7 +842,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 +849,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++) {
@@ -876,35 +869,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;
                List<LineLabelCandidate> candidates = lineLabelCandidates(
                        label, metrics, dataArea);
                if (candidates.isEmpty()) {
                    candidateSets.clear();
                    break;
                    }
                    if (penalty <= 0.0) break;
                candidateSets.add(candidates);
                }
                if (chosen == null) continue;
            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 +990,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) {
@@ -979,14 +1121,18 @@
                                   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(