From adee580f186d7f1cc5d5405792d6b0c13076f0a2 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期四, 08 十月 2026 14:29:41 +0800
Subject: [PATCH] fix(holiday): 铁路客运量走势图10月3日标签上移避免遮挡

---
 HANDOFF.md                                                                                            |   18 +++++
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java     |   88 +++++++++++++++++++++++++++-
 traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java          |    8 ++
 traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java |   58 +++++++++++++++++++
 4 files changed, 165 insertions(+), 7 deletions(-)

diff --git a/HANDOFF.md b/HANDOFF.md
index ecd2c44..6c1a88f 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -4,7 +4,23 @@
 > **姣忓ぉ缁撴潫鏃�**锛氭妸褰撳ぉ杩涘睍銆佽俯杩囩殑鍧戙�佹柊闇�姹傘�佹湭瀹屾垚浜嬮」鏇存柊鍒版湰鏂囦欢锛岀劧鍚庡彲浠ユ斁蹇冨紑鏂板璇濄��
 > **鍗忎綔绾﹀畾锛�2026-08-31锛�**锛氳銆屾彁浜ゃ��= 鏈湴 commit + 鎺ㄩ�佸埌杩滅 git锛坥rigin main锛孏itblit锛夛紝涓嶅啀鍙仛鏈湴 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 涓嶆敼鍙樻覆鏌撱�丄BOVE 楂樹簬 BELOW锛夛紱鐪熷疄搴撶鍒扮閲嶆柊鐢熸垚璧板娍鍥撅紝15 寮犲浘涓粎 `image5.png`锛堥搧璺浘锛塖HA-256 鍙樺寲锛屽叾浣� 14 寮犻�愬瓧鑺備竴鑷达紱閾佽矾鍥惧墠鍚庡儚绱犲樊鍖呭洿鐩� `x 313-436, y 395-438`锛屾鏄鏍囩鍖哄煙锛涙湰鍦� OCR 纭 `72.4` 瀹屾暣鍙銆佷綅浜庣偣锛坹鈮�427锛変笂鏂癸紝涓嶅啀涓� `70.6`锛坹431-450锛夐噸鍙犮��
+- **杈圭晫**锛氬彧鏀逛簡閾佽矾杩欎竴寮犲浘鐨勮繖涓�澶勬爣绛撅紱蹇姤銆佹暟鎹鍏ャ�佸叾瀹冪粺璁¢�昏緫鍧囨湭瑙︾锛涙湭瀵煎叆/瑕嗙洊/閲嶅浠讳綍涓氬姟鏁版嵁銆�
+- **閲嶅惎**锛氫负璁╂寜閽笅杞界敤涓婃柊浠g爜锛屽凡鐢� `start-dev.ps1` 閲嶅惎鍚庣锛堝墠绔湭鍔級锛涘彲鐩存帴鍦ㄧ郴缁熼噷閲嶆柊涓嬭浇璧板娍鍥鹃獙鏀躲��
+- **寰呭姙/椋庨櫓**锛氳繖鏉� override 缁戝畾 `seriesIndex=0`锛�2026 骞达級涓� `itemIndex=2`锛�10鏈�3鏃ワ級锛涜嫢鍚庣画閾佽矾鍥剧郴鍒楅『搴忔垨澶╂暟鍙e緞璋冩暣锛岄渶瑕佸悓姝ユ牳瀵广�傚叾浣欏勾浠�/鏃ユ湡濡傚啀鍑虹幇鏍囩閬尅锛屽彲澶嶇敤璇� override 鏈哄埗锛屼絾闇�閫愬浘浜哄伐纭鍚庡啀鍔犮��
+- **楠屾敹**锛氱敤鎴� 2026-10-08 宸茬湅鍥剧‘璁ゆ晥鏋滃彲浠ャ��
+
+## 鍘嗗彶锛�2026-10-08 璧板娍鍥鹃檮褰曚簩瑙傛祴绔欏浘澶╂暟淇锛�
+
+
 
 ### 10-08锛氳娴嬬珯閫熷害/鎷ユ尋搴﹁蛋鍔垮浘鏀圭敤绯荤粺閰嶇疆鍋囨湡澶╂暟锛堝凡楠屾敹锛宑ommit e958585锛�
 
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 eb09955..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
@@ -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) {
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
index 11793f8..08ef648 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
+++ b/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 + "鈥濆亣鏈熸按璺杩愰噺鎯呭喌",
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
index 24bffda..90db24d 100644
--- a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
+++ b/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;

--
Gitblit v1.9.1