xyc
23 小时以前 adee580f186d7f1cc5d5405792d6b0c13076f0a2
fix(holiday): 铁路客运量走势图10月3日标签上移避免遮挡
4个文件已修改
172 ■■■■■ 已修改文件
HANDOFF.md 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,7 +4,23 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
## 最新(2026-10-08 走势图附录二观测站图天数修正)
## 最新(2026-10-08 铁路客运量走势图10月3日标签避让)
### 10-08:铁路客运量走势图 10月3日 `72.4` 标签上移,避免被 `70.6` 遮挡
- **需求(用户)**:走势图其余部分一律不动,只把 `“十一”假期铁路客运量情况` 图中 10月3日 `72.4` 的标签从点的右边移到点正上方;该位置原本空白,放右边会被 2024 年绿色 `70.6` 标签压住一部分导致看不全。
- **数据背景(2026 十一铁路客运量,万人次)**:2026 年 10月3日 = 72.4,2025 年同日 = 72.3,2024 年同日 = 70.6。三条线同日数值非常接近,自动布局把 `72.4` 放到了点右侧。
- **根因**:标签自动布局(`HolidayTrendChartService.drawXYItemLabels` 动态规划)只对“按数值排序后相邻的两个标签”做重叠惩罚;`72.4` 与 `70.6` 之间隔着 `72.3`,二者不相邻,重叠未被惩罚,因此允许互相覆盖。
- **改动**:`HolidayTrendChartService.lineChart` 新增可选 `List<LineLabelOverride> labelOverrides` 入参(配合 `LineLabelAnchor{ABOVE,BELOW,LEFT,RIGHT}`),可把指定 `(seriesIndex,itemIndex)` 的标签固定到某个方位;不传参时完全走原自动布局,渲染逐字节不变。`HolidayTrendService` 仅对铁路图传一条 override:`seriesIndex=0`(2026 年)、`itemIndex=2`(10月3日)、`ABOVE`。
- **验证**:`mvn -o test` 17/17 通过(新增标签锚点单测:空/不匹配 override 不改变渲染、ABOVE 高于 BELOW);真实库端到端重新生成走势图,15 张图中仅 `image5.png`(铁路图)SHA-256 变化,其余 14 张逐字节一致;铁路图前后像素差包围盒 `x 313-436, y 395-438`,正是该标签区域;本地 OCR 确认 `72.4` 完整可读、位于点(y≈427)上方,不再与 `70.6`(y431-450)重叠。
- **边界**:只改了铁路这一张图的这一处标签;快报、数据导入、其它统计逻辑均未触碰;未导入/覆盖/重导任何业务数据。
- **重启**:为让按钮下载用上新代码,已用 `start-dev.ps1` 重启后端(前端未动);可直接在系统里重新下载走势图验收。
- **待办/风险**:这条 override 绑定 `seriesIndex=0`(2026 年)与 `itemIndex=2`(10月3日);若后续铁路图系列顺序或天数口径调整,需要同步核对。其余年份/日期如再出现标签遮挡,可复用该 override 机制,但需逐图人工确认后再加。
- **验收**:用户 2026-10-08 已看图确认效果可以。
## 历史(2026-10-08 走势图附录二观测站图天数修正)
### 10-08:观测站速度/拥挤度走势图改用系统配置假期天数(已验收,commit e958585)
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -108,6 +108,17 @@
    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++) {
@@ -181,7 +192,7 @@
        }
        plot.setRenderer(renderer);
        return render(chart, width, scaleLineChartHeight(height),
                yAxisLabel, true, percentAxis, valueDecimals);
                yAxisLabel, true, percentAxis, valueDecimals, labelOverrides);
    }
    public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
@@ -461,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();
@@ -480,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 {
@@ -896,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;
@@ -938,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;
@@ -1057,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();
@@ -1067,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);
@@ -1110,6 +1151,43 @@
        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,
                                   int width, int height, double dx, double dy,
                                   Rectangle2D dataArea) {
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
@@ -85,7 +85,13 @@
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期铁路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.rail), false, 1100, 445),
                        // Pin the 2026 rail label for day 3 above its point so the
                        // 2024 label (70.6) no longer covers it; other charts keep
                        // automatic placement.
                        series(years, yearData, item -> item.rail), false, 2,
                        HolidayTrendChartService.DAY_LABELS, 1100, 445,
                        Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                                0, 2, HolidayTrendChartService.LineLabelAnchor.ABOVE))),
                lineImageSize(5264785L, 2129155L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期水路客运量情况",
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
@@ -138,6 +138,64 @@
                "custom day labels should change the rendered axis");
    }
    @Test
    void pinsRequestedLineLabelToFixedAnchor() throws Exception {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries series = new HolidayTrendChartService.ChartSeries();
        series.setName("2026\u5e74");
        series.setValues(java.util.Collections.singletonList(new BigDecimal("72.4")));
        java.util.List<HolidayTrendChartService.ChartSeries> single =
                java.util.Collections.singletonList(series);
        byte[] plain = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2, 1100, 590);
        byte[] emptyOverrides = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.emptyList());
        assertArrayEquals(plain, emptyOverrides,
                "an empty override list must keep the automatic placement");
        byte[] unmatched = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                        0, 7, HolidayTrendChartService.LineLabelAnchor.ABOVE)));
        assertArrayEquals(plain, unmatched,
                "an override for a label that does not exist must not change rendering");
        byte[] above = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                        0, 0, HolidayTrendChartService.LineLabelAnchor.ABOVE)));
        byte[] below = service.lineChart("\u4e07\u4eba\u6b21", single, false, 2,
                HolidayTrendChartService.DAY_LABELS, 1100, 590,
                java.util.Collections.singletonList(new HolidayTrendChartService.LineLabelOverride(
                        0, 0, HolidayTrendChartService.LineLabelAnchor.BELOW)));
        assertPng(above);
        assertPng(below);
        assertFalse(java.util.Arrays.equals(above, below),
                "ABOVE and BELOW anchors must render differently");
        assertTrue(meanSeriesColorY(above) < meanSeriesColorY(below),
                "the ABOVE anchor must place the label higher than the BELOW anchor");
    }
    private double meanSeriesColorY(byte[] data) throws Exception {
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
        assertNotNull(image);
        int target = 0xC0504D;
        long count = 0;
        long sum = 0;
        for (int y = 0; y < image.getHeight() - 60; y++) {
            for (int x = 0; x < image.getWidth(); x++) {
                if ((image.getRGB(x, y) & 0xFFFFFF) == target) {
                    count++;
                    sum += y;
                }
            }
        }
        assertTrue(count > 0, "expected series-coloured pixels for the item label");
        return (double) sum / count;
    }
    private void assertNoLargeLightGrayBackground(byte[] data) throws Exception {
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
        int matches = 0;