zhizhijie
5 天以前 f2056aa286179f5d29f3fb6fce1479035cca856a
Merge remote-tracking branch 'origin/main'

# Conflicts:
# HANDOFF.md
4个文件已修改
418 ■■■■■ 已修改文件
HANDOFF.md 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -238,6 +238,106 @@
- **卡点未变**:`resolveSummaryMother`(`ReportExportService.java:4576`)只精确匹配《{year}年{month}月道路运输量汇总表.xlsx》,目标月无母版即报错;9 月母版仍为用户 09-29 手工扩列产物(本机未入库)。
- **待确认**:① 扩列蓝本用 9 月手工版还是 8 月版;② 是否一次建成 12 月满版式(方案 v1 建议满版式)。
- **状态**:本段与 M0 规格均为【待验收】;本轮未改动任何代码。工作区未跟踪的 `docs/生成汇总大表/2026年9月道路运输量汇总表*.xlsx` 与 `docs/确认.docx` 按约定保留。
## 最新(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)
- **需求(用户)**:走势图最后两张图“普通国省道观测站平均机动车速度”“…平均拥挤度”只含今年数据,横轴天数不应是固定 8 天,而应为系统配置的假期天数。
- **改动**:`HolidayTrendChartService.lineChart` 新增可传 `dayLabels` 的重载(默认仍为 `DAY_LABELS` 8 天,其余 13 张图零改动);`HolidayTrendService.observationData` 改为按 `configService.findHolidayDays(year, holidayType)`(`day_index>=1`)的天数与日期生成数据点和横轴标签,最后两张图传入该标签。
- **配置依据**:`holiday_calendar` 2026 十一 `day_index 1..7`(7 天,`day_index 0` = 09-30 环比基期);2025 = 8 天、2024 = 7 天。
- **验证**:`mvn -o test` 16/16 通过(新增自定义横轴标签单元测试);真实云只读生成走势图,与用户已下载版逐图对比:image1~13 的 SHA-256 完全一致,仅 image14(速度)、image15(拥挤度)变化;OCR 确认新图横轴为 10月1日~10月7日、无 10月8日。
- **边界**:其余图仍固定 8 天口径,未改;未导入/覆盖/重导任何业务数据。
- **重启**:为让按钮下载用上新代码,已用 `start-dev.ps1` 重启后端(前端未动),重启时间 2026-10-08 10:38:53;可直接在系统里重新下载走势图验收。
- **验收**:用户 2026-10-08 在系统里重新下载走势图后审核,验收通过。
## 历史(2026-10-08 上班前关机快照)
### 10-08:走势图 `(14)` 与已验收 `(12)` 比对(内容一致)
- **用户任务**:比对系统重新下载的 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图 (14).docx` 与此前验收通过的 `(12)`。
- **比对方法**:按 DOCX 包逐项比对。`(12)`、`(13)`、`(14)` 的 31 个内部文件(含正文 XML、样式、关系文件和全部图表图片)逐个 SHA-256 完全相同。
- **比对结果**:三个文件整体 SHA-256 不同,仅因 ZIP 容器时间戳等打包元数据不同;文件长度相同,差异为 234 个字节,集中在 3 字节时间戳段,文档内容没有任何差异。
- **结论**:`(14)` 就是已验收的 `(12)` 同一版本,图表数值、标签、版式均未变化,不需要重新验收。
- **边界**:只读比对,未修改下载文件、代码、系统数据或业务库。
- **当前运行**:2026-10-08 重启后,已用 `start-dev.ps1` 恢复 dev 环境;前端 `8080`、后端 `8090` 均在监听并访问正常。Git 在本次交接提交前为 `HEAD = origin/main = c7b6553`;工作区无已跟踪改动,只剩约定不入库的 `_tmp_*`、`_tmp_pack_smoke_trend.docx` 和观测站原始 XLS。
- **数据与下一步**:当前业务库仍只覆盖到 `2026-10-06`,`2026-10-07` 未导入。新对话先读 `AGENTS.md` + `HANDOFF.md`;等 10-07 数据齐全后,先只读核对源 Excel 和异常项,得到用户明确指示后再导入或改动,再生成最终走势图和分析报告。折线图横轴仍按已确认的固定 8 天口径,不能改成按年度截断。
### 10-08:同事分析报告趋势核对(只读,未改动文档)
- **任务**:核对 `D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\2026年十一.docx` 的走势结论是否有数据支撑。用户明确先只看趋势,不纠结假期未结束时的具体数值;文中去年的图不作为判断依据。
- **当前数据边界**:只读查询确认 `holiday_passenger_flow`、`holiday_vehicle_flow`、`observation_station_flow` 均只覆盖到 `2026-10-06`;`2026-10-07` 尚未导入。本轮未执行任何导入、覆盖或重导。
- **总体结论**:报告主线基本成立。10 月 1 日至 6 日跨区域总量为首日高开、2 日至 3 日下降、4 日至 5 日回到高位、6 日小幅回落;道路客运稳定、铁路按同日期比较唯一增长且潮汐性明显、水路同比下降且三年峰值均在 10 月 5 日前后、自驾主体地位和国省道日流量高于 300 万辆次,均有数据支撑。
- **需要收紧的结论**:阴雨导致下降目前只能说明时间吻合,不能单独证明因果;“前期积压需求释放”证据不足;道路车流总量只有 10 月 4 日明显回升,5 日基本持平、6 日又回落,不能写成 4 日至 7 日持续高位;“短途游、周边游、乡村微度假”只能证明短途通行活跃,需文旅、路线或 OD 数据;“县域及城乡短途客运韧性/兜底”需结构数据;2025 与 2026 只能说总体形态相近,不是日间节奏一致。
- **待 10-07 数据齐全后定稿**:7 天总量、日均、各方式占比、峰值谷值、返程高于出行、铁路和民航尾日、全周期同比。重要口径:2025 年为 8 天、2026 年目前为 7 天,同比必须按相同日期范围比较,不能直接用 7 天对 8 天。
- **边界**:未修改同事的 Word,未改动系统数据,未生成新报告或走势图。发现问题先报告,得到用户明确指示后再改。
- **Git/运行**:`HEAD = origin/main = ca0bbe1`;无已跟踪文件未提交改动。dev 后端 8090、前端 8080 在关机前保持运行,随关机自动停止;重启后按 `AGENTS.md` 用 `start-dev.ps1` 启动。
## 历史(2026-10-07 上午,新开会话交接)
### 10-07 关机快照(当前交接)
- **Git 状态**:`HEAD` 与 `origin/main` 均为 `179544a`;已跟踪文件无未提交改动。工作区仅保留按约定不入库的 `_tmp_*` 临时目录、`_tmp_pack_smoke_trend.docx` 和一份观测站原始 XLS。
- **本轮更正**:我误将折线图固定 8 天判断为缺陷,提交 `8ad75d5` 擅自改成了按年度配置截取;用户明确纠正后,已用 `179544a` 整体回退并推送。当前代码状态等同错误修改前的 `c21fbdd`。
- **已确认口径(不得再改)**:折线图横轴固定 8 天;2025 年有 10 月 8 日的数据点和横轴位置,2024、2026 年该日无点但横轴保留 10 月 8 日;累计曲线只连接实际有数据的日期,缺日不延续;观测站附录查询覆盖 10 月 1 日至 10 月 8 日。
- **错误产物提醒**:`C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(7天版-待验收).docx` 是错误改动时生成的文件,不得使用;未经用户指示不删除。
- **运行状态**:关机前后端 `8090`、前端 `8080` 仍监听,随关机自动停止;下机后按 `AGENTS.md` 用 `start-dev.ps1` 启动。
- **数据边界**:本轮未导入、覆盖或重导任何业务数据。
- **下次接手**:先读根目录 `AGENTS.md` + 本文件;若用户提供了 10 月 7 日数据,先只读核验,发现异常先报告,得到明确指示后再执行任何修改、导入或提交。
### 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 缺失站点(龙泉、华鲁交调站、大典河)。
- **站点编号**:郑店 `S101L110420115`、樊家庄 `S120L105420702`、上津站 `G242L104420322`、龙泉 `S216L103420506`、华鲁交调站 `S223L117421024`、大典河 `S464L105422828`。
- **各日剔除合计(中小客流量,辆次)**:2026-10-04 `16,484`;2026-10-05 `4,398`;2025-10-05 `14,239`;2024-10-05 `11,907`。调整时缺失站点按 0 处理。
- **系统更正**:`holiday_vehicle_adjustment` 的 2025-10-05 调整值已由 `322.7801` 改为 `332.7801`(更新时间 2026-10-07 08:45:52);2024-10-05 `289.5557`、2025-10-04 `337.5009`、2024-10-04 `322.5277` 不变。
- **工作簿更正**:`C:\Users\jcxiong\Downloads\2026年十一数据 (4).xlsx`(修改时间 08:46:52)中 `普通国省道!F7=332.7801`。联动结果已正确重算:`普通国省道!G7=-4.8%`;`人员流动量汇总` 的 `E7=1460.97`、`F7=-0.3%`、`K7=1372.85`、`Q7=1290.04`、`R7=-1.0%`、`V7=-4.28%`。
- **公式更正已核实**:`普通国省道!E8=(D8-D7)/D7`、`G8=D8/F8-1`、`I8=D8/H8-1`、`E9=(D9-D8)/D8`。
- **10-06 边界(重要)**:工作簿目前只有铁路 `C9=96.2`、水路 `C8=5.4932`、高速公路 `C8=206.1206`;民航 `C9`、道路 `C8`、普通国省道 `D8` 仍为空。因此 `人员流动量汇总!C8=596.38264` 只是部分合计,`D8=-59.06%` 以及各 `-100%/#DIV/0!` 都不可用于快报;补齐三项后重算。
- **数据边界**:本轮只读核对,未导入、覆盖或重导任何业务数据。
### 10-07:Codex 输入卡顿排查与交接建议
- 当前线程从 10-05 连续使用至今,rollout 约 `32.17 MB`,含 `3,751` 条 `response_item`、`3,469` 条 `event_msg`、`10` 次 compaction;用户已决定新开对话继续。
- 已排除 `codex-plus-plus-manager --show-update`(未运行);重试风暴 guard 已安装,`dropped=859` 且连续 10 秒不增长,当前没有持续重试风暴。
- 观测到 Codex 主进程累计约 `4.34 h` CPU、渲染进程约 `1.28 h`;短时峰值主进程约 `116%` 单核、渲染约 `44%`,属于间歇高负载。建议新对话继续;若仍卡,完全退出并重启 Codex。
- **新对话接手**:先读根目录 `AGENTS.md` + 本文件。当前 git `HEAD` 与 `origin/main` 均为 `9b56330`,已跟踪文件无改动;dev 前端 `8080`、后端 `8090` 正在运行。
## 历史(2026-10-07 早间关机快照)
### 10-07 关机快照
- **当前状态**:本轮仅做关机交接,无代码、测试口径或业务数据变更。上一任务“折线图纵向间距加高 20%”已于 10-06 验收,提交为 `54e10ae fix(holiday): 加大折线图纵向间距并同步 Word 尺寸`。
- **Git**:`main` 的 `HEAD` 与 `origin/main` 均为 `54e10aee11aa4a10926df02782ca07d41eeb9b98`;无未提交的已跟踪文件,工作区仅有按约定不入库的 `_tmp_*` 临时目录/文件、`_tmp_pack_smoke_trend.docx` 和一份观测站原始 XLS。
- **运行状态**:关机前本机 dev 前后端仍可访问(前端 8080、后端 8090 均返回 200);随计算机关机会自动停止,重启后按 `AGENTS.md` 用 `start-dev.ps1` 启动。
- **本地留存**:折线图纵向间距的测试报告和工作日结已写入 `docs/功能测试报告/`、`docs/工作日结/`,均按约定不入库。
- **下次接手**:先读根目录 `AGENTS.md` + 本文件;仓库级待办仍见第七节,本轮无新增待办。
- **数据边界**:未导入、覆盖或重导任何业务数据。
## 最新(2026-10-06)
@@ -1045,3 +1145,13 @@
- 2026-08-31(下班前):数据导入页重构验收通过(commit 791e70f 已推送,详见第 7 节)。改造全用 Python 替换写文件(apply_patch 本机必败);关键坑:Vue 新方法误放 methods 外 → render TypeError、el-table 展开行不渲染,[Vue warn] 只在 console 不在 pageerror;验证用 puppeteer 拦截 /api/import/batches 返回 mock 失败数据直接验明细表,真实无失败数据验「暂无导入失败记录」空态。
- 2026-10-09:按春节版「人员流动量情况」表制作 **2026 十一假期**版,输出 `D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\2026年十一假期人员流动量情况.xlsx`(原春节模板备份在 `_tmp_flow_table\template_backup.xlsx`,未删用户文件)。结构:标题行 + 三级合并表头 + `合计` 行 + 10月1日~10月7日逐日行;7 个指标(全社会跨区域 / 铁路 / 公路人员流动量〔下分流动量合计、高速公路及普通国省道非营业性小客车人员出行量、公路营业性客运量〕/ 水路 / 民航)每个都给「数量 / 同比2025年 / 同比2024年」,即把原表的「同比2019年」改为「同比2024年」并给每个指标补齐第二个同比列(A~V 共 22 列)。数据取自系统 `GET /api/holiday/overview?holidayType=NATIONAL_DAY`(2024/2025/2026),并逐项对齐 10月7日源文件 `2026年十一数据10月7日.xlsx` 的 `人员流动量汇总` 与 铁路/水路/民航/道路 各页,全部一致(7 天合计总量 10115.56 万人次、同比2025 -4.46%、同比2024 +4.25%)。口径恒等式校验通过:公路人员流动量 = 非营业性小客车 + 公路营业性;全社会 = 公路 + 铁路 + 水路 + 民航。生成脚本 `_tmp_flow_table/nodework/build_xlsx.mjs`(artifact-tool,临时目录不入库)。
- 2026-10-09(补):上表按 A4 打印做适配——列宽收窄(日期列 7、数量列 7.5、同比列 6.8)+ 工作表打印设置(A4、横向、缩放「1 页宽×1 页高」、水平居中、页边距左右 0.35″/上下 0.4″)。用 Excel 实际导出 PDF 实测 **正好 1 页**(A4 横向 841.92×595.32 pt),数值/百分比无 `####`、无截断。注:artifact-tool 无打印设置 API,pageSetup/printOptions/pageMargins 是导出后注入 XLSX 的 `xl/worksheets/sheet1.xml`(脚本 `_tmp_flow_table/print_setup.py`,临时不入库)。
- 2026-10-09(续):按反馈改出十一人员流动量表第 2 版,输出 `D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\2026年十一假期人员流动量情况2.xlsx`(单表 A1:S13)。改动:①表头顺序调整为 全社会 / 公路 / 铁路 / 水路 / 民航(公路提前);②去掉「公路人员流动量·流动量合计」子列,只留 高速公路及普通国省道非营业性小客车人员出行量 与 公路营业性客运量;③新增「日均」行(行序:合计、日均、10月1日~7日);④「合计」行同比2025 改为 2026年7天合计 ÷ 2025年8天合计(2025年国庆假期为10月1-8日共8天),同比2024 不变;日均的同比按各年天数折算(2026、2024为7天,2025为8天)。数值:合计同比2025 全社会 -14.37%、非营业性小客车 -14.82%、公路营业性 -17.45%、铁路 -3.33%、水路 -23.76%、民航 -17.12%;日均同比2025 全社会 -2.13%。标题沿用用户版本「2026年“十一”假期全社会跨区域人员流动量情况」。仍为 A4 横向 1 页(导出 PDF 实测)。数据源同前(系统 overview + 10月7日源文件核对;2025 第8日取系统 dayIndex=8)。
- 2026-10-12(交接快照,用户准备新开对话):电脑重启后已用 `start-dev.ps1` 拉起前后端,脚本输出后端 OK、前端 OK;前端访问 http://localhost:8080,后端 8090,日志分别见 `backend.log` / `traffic-audit-web/frontend.log`。当前 git 分支 `main`,无已跟踪文件改动;工作区有 10-09 生成的未入库临时目录/文件(`_tmp_doc_compare/`、`_tmp_flow_table/`、`_tmp_holiday_verify/`、`_tmp_rail_label/`、`_tmp_pack_smoke_trend.docx`)及用户数据 `docs/专项统计/重要节假日/观测站分日调查数据 (2026年10月1日).xls`,不要删除或误提交。本轮未改代码、未跑测试、未提交。
- 2026-10-12(业务疑点核查,只读):企业上报的「厚坪-远安」「后坪到远安」两个地名已初核。可确认「后坪」在兴山县水月寺镇后坪一带(后坪林场、后坪磷矿附近),后坪磷矿公开资料还涉及榛子乡石柱村;「远安」指宜昌市远安县;「厚坪」在宜昌/兴山公开地名中未查到有效地点,判断大概率是「后坪」的错字或简称(另有「兴山厚坪物流有限公司」注册在峡口镇泗湘溪村,但公司名不能等同地名)。按水月寺镇后坪到远安县城的公开道路测算,单程约 106.7 km;企业均填 201 km,不能直接认定错误,需企业补充具体装货点、卸货点、单程/往返及绕行路线后再核。系统 `h2032_enterprise_monthly` 的审核说明中未留下这两处地点原文。
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;
@@ -103,6 +103,23 @@
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int valueDecimals, int width, int height) {
        return lineChart(yAxisLabel, series, percentAxis, valueDecimals, DAY_LABELS, width, height);
    }
    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++) {
            ChartSeries item = series.get(seriesIndex);
@@ -132,7 +149,7 @@
        plot.setRangeGridlinesVisible(false);
        plot.setDomainTickBandPaint(null);
        plot.setRangeTickBandPaint(null);
        SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
        SymbolAxis domainAxis = new SymbolAxis(null, axisLabels.toArray(new String[0]));
        domainAxis.setGridBandPaint(new Color(0, 0, 0, 0));
        domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0));
        domainAxis.setLowerMargin(0.04);
@@ -175,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,
@@ -455,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();
@@ -474,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 {
@@ -615,7 +640,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 +670,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 +683,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 +700,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 +869,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 +886,7 @@
            this.color = color;
        }
        private int getSeriesIndex() { return seriesIndex; }
        private double getStartX() { return startX; }
        private double getEndX() { return endX; }
        private double getY() { return y; }
@@ -839,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;
@@ -881,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;
@@ -1000,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();
@@ -1010,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);
@@ -1053,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
@@ -15,6 +15,7 @@
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -84,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 + "”假期水路客运量情况",
@@ -138,12 +145,14 @@
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均机动车速度",
                chartService.lineChart("公里/小时",
                        Collections.singletonList(series("平均速度", observation.speed)), false, 1100, 590),
                        Collections.singletonList(series("平均速度", observation.speed)), false, 2,
                        observation.labels, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均拥挤度",
                chartService.lineChart("拥挤度",
                        Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 1100, 590),
                        Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 2,
                        observation.labels, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        File template = resolveTemplateFile();
@@ -263,23 +272,27 @@
    private ObservationData observationData(int year, String holidayType) {
        List<HolidayCalendar> days = configService.findHolidayDays(year, holidayType);
        if (days.isEmpty()) {
            return new ObservationData(nulls(), nulls());
            return new ObservationData(nulls(), nulls(), HolidayTrendChartService.DAY_LABELS);
        }
        LocalDate start = days.get(0).getHolidayDate();
        LocalDate end = start.plusDays(HolidayTrendChartService.DAY_LABELS.size() - 1L);
        LocalDate end = days.get(days.size() - 1).getHolidayDate();
        List<ObservationDailyOperation> rows = observationStationFlowMapper.selectDailyOperations(start, end);
        Map<LocalDate, ObservationDailyOperation> byDate = new LinkedHashMap<>();
        for (ObservationDailyOperation row : rows) {
            byDate.put(row.getObsDate(), row);
        }
        DateTimeFormatter labelFormat = DateTimeFormatter.ofPattern("M月d日");
        List<BigDecimal> speed = new ArrayList<>();
        List<BigDecimal> congestion = new ArrayList<>();
        for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
            ObservationDailyOperation row = byDate.get(start.plusDays(i));
        List<String> labels = new ArrayList<>();
        for (HolidayCalendar day : days) {
            LocalDate date = day.getHolidayDate();
            ObservationDailyOperation row = byDate.get(date);
            speed.add(row == null ? null : row.getAverageSpeed());
            congestion.add(row == null ? null : row.getAverageCongestion());
            labels.add(date.format(labelFormat));
        }
        return new ObservationData(speed, congestion);
        return new ObservationData(speed, congestion, labels);
    }
    private HolidayTrendChartService.ChartSeries series(String name, List<BigDecimal> values) {
@@ -441,10 +454,12 @@
    private static class ObservationData {
        private final List<BigDecimal> speed;
        private final List<BigDecimal> congestion;
        private final List<String> labels;
        private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion) {
        private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion, List<String> labels) {
            this.speed = speed;
            this.congestion = congestion;
            this.labels = labels;
        }
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
@@ -114,6 +114,88 @@
        assertFalse(java.util.Arrays.equals(twoDecimals, threeDecimals));
    }
    @Test
    void rendersLineChartWithConfiguredDayLabels() {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries congestion = new HolidayTrendChartService.ChartSeries();
        congestion.setName("\u5e73\u5747\u62e5\u6324\u5ea6");
        congestion.setValues(java.util.Arrays.asList(
                new BigDecimal("0.50"), new BigDecimal("0.48"), new BigDecimal("0.47"),
                new BigDecimal("0.49"), new BigDecimal("0.49"), new BigDecimal("0.49"),
                new BigDecimal("0.47")));
        java.util.List<String> sevenDays = java.util.Arrays.asList(
                "10\u67081\u65e5", "10\u67082\u65e5", "10\u67083\u65e5", "10\u67084\u65e5",
                "10\u67085\u65e5", "10\u67086\u65e5", "10\u67087\u65e5");
        byte[] custom = service.lineChart("\u62e5\u6324\u5ea6",
                java.util.Collections.singletonList(congestion), false, 2, sevenDays, 1100, 590);
        byte[] defaults = service.lineChart("\u62e5\u6324\u5ea6",
                java.util.Collections.singletonList(congestion), false, 2, 1100, 590);
        assertPng(custom);
        assertPng(defaults);
        assertFalse(java.util.Arrays.equals(custom, defaults),
                "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;