fix(holiday): 铁路客运量走势图10月3日标签上移避免遮挡
| | |
| | | > **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。 |
| | | > **协作约定(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) |
| | | |
| | |
| | | |
| | | 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++) { |
| | |
| | | } |
| | | 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, |
| | |
| | | |
| | | 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(); |
| | |
| | | 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 { |
| | |
| | | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | 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(); |
| | |
| | | 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); |
| | |
| | | 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) { |
| | |
| | | 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 + "”假期水路客运量情况", |
| | |
| | | "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; |