| | |
| | | |
| | | ## 最新(2026-10-06) |
| | | |
| | | ### 10-06:折线图纵向间距加高(验收通过) |
| | | |
| | | - **用户背景**:客运量图线间距较好、标签摆放合理;其他多序列折线图因线距偏近,标签仍可能压线。用户同意适当增加图高,Word 只是取图来源,不要求当前分页为最终版,但图不能过高。 |
| | | - **实现**: |
| | | - `HolidayTrendChartService`:折线纵轴留白收紧为 `lowerMargin=0.04 / upperMargin=0.07`;`LINE_CHART_HEIGHT_SCALE=1.20`,所有折线 PNG 高度放大约 20%。 |
| | | - `HolidayTrendService`:12 张折线图的 Word 内嵌图片高度同步放大约 20%,保持 PNG 与 Word 图片框比例一致;饼图、堆叠图未改。 |
| | | - `HolidayTrendChartServiceTest`:新增折线图高度缩放断言。 |
| | | - **验收**:用户于 2026-10-06 查看 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(折线加高20实验).docx` 后验收通过。 |
| | | - **验证结果**: |
| | | 1. 15% 加高版在铁路客运量图 `image5` 检出 OCR 标签框重叠;20% 版 12 张折线图(`image1/2/4/5/6/7/8/9/10/11/14/15`)未检出标签框重叠。 |
| | | 2. PNG 宽高比与 Word `wp:extent` 宽高比最大误差约 `0.162%`,未发生明显拉伸。 |
| | | 3. Word 仍为 8 页、每页 2 张图(首页 1 张),与 15% 加高实验相同。 |
| | | 4. 后端全量 `mvn -q test -f traffic-audit-server\pom.xml` 通过;`HolidayTrendChartServiceTest` 聚焦测试通过。 |
| | | 5. 标签亲和度检查记录到 3 处标签框更靠近邻近线(`image1` 的 `1,518.02`、`image4` 的 `1,346.17`、`image9` 的 `248.65`);白描边保证可读,用户整体验收通过并保留该排版策略。 |
| | | 6. `image13` 堆叠图有 4 处 OCR 标签重叠,但该图未参与本实验且代码未改。 |
| | | - **数据边界**:未导入、覆盖或重导任何业务数据;仅本地只读生成走势图做实验。 |
| | | |
| | | ### 10-06:平均拥挤度标签恢复为 2 位小数 |
| | | |
| | | - **调整**:按用户要求,“普通国省道观测站平均拥挤度”不再单独使用 3 位小数,恢复为折线图默认 2 位。 |
| | | - **一致性**:由于折线图现已统一按显示标签精度绘制,10-04、10-05 的拥挤度都会显示并绘制为 `0.49`,不会再出现同标签不同高度。 |
| | | - **验证**:后端全量 `mvn -q test -f traffic-audit-server\pom.xml` 通过。 |
| | | |
| | | ### 10-06:折线图统一按显示标签精度绘制 |
| | | |
| | | - **口径(用户 2026-10-06 确认)**:同一张折线图中,同一个显示标签必须对应同一个点位高度;分析文字不得引用比该图显示精度更细的差异。 |
| | | - **实现**:`HolidayTrendChartService.lineChart` 在所有原始统计计算完成后,于最终入图边界调用 `displayValue`;普通图按该图 `valueDecimals` 做 `HALF_UP` 舍入,百分比点标签按 1 位百分比精度(即原始小数 3 位)舍入。折线坐标和点标签共用同一舍入值,纵轴格式沿用该图精度。 |
| | | - **边界**:只在最终图表显示层舍入,不改数据库、不改累计、快报、同比、环比或调整口径;该规则用于静态分析报告图,不能让文字引用图上无法分辨的微小差异。 |
| | | - **验证**:`HolidayTrendChartServiceTest` 聚焦测试通过;后端全量 `mvn -q test -f traffic-audit-server\pom.xml` 通过。新增测试覆盖“两个不同原值按 2 位标签舍入后生成相同几何”和百分比精度换算。测试报告:`docs/功能测试报告/功能测试报告_折线图按标签精度绘制_2026-10-06.md`(本地留存,不入库)。 |
| | | |
| | | ### 10-06:观测站平均拥挤度走势图改为 3 位小数 |
| | | |
| | | - **问题**:用户导入 10-05 观测站数据后反馈,最后一张拥挤度图中 10-04、10-05 标签都显示 `0.49`,但点位高度不同。 |
| | |
| | | import java.lang.reflect.Field; |
| | | import java.lang.reflect.Modifier; |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.text.DecimalFormat; |
| | | import java.text.NumberFormat; |
| | | import java.util.ArrayList; |
| | |
| | | private static final double RIGHT_LABEL_BAND = 74.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; |
| | | private static final double LINE_CHART_HEIGHT_SCALE = 1.20; |
| | | private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55); |
| | | private static final Color[] SERIES_COLORS = { |
| | | new Color(192, 80, 77), |
| | |
| | | for (int i = 0; i < item.getValues().size(); i++) { |
| | | BigDecimal value = item.getValues().get(i); |
| | | if (value != null) { |
| | | xySeries.add(i, value.doubleValue()); |
| | | xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue()); |
| | | } |
| | | } |
| | | dataset.addSeries(xySeries); |
| | |
| | | |
| | | NumberAxis rangeAxis = new NumberAxis(); |
| | | rangeAxis.setAutoRangeIncludesZero(false); |
| | | rangeAxis.setLowerMargin(0.12); |
| | | rangeAxis.setUpperMargin(0.16); |
| | | rangeAxis.setLowerMargin(0.04); |
| | | rangeAxis.setUpperMargin(0.07); |
| | | rangeAxis.setTickLabelFont(TICK_FONT); |
| | | rangeAxis.setLabelFont(LABEL_FONT); |
| | | rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR); |
| | |
| | | BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); |
| | | } |
| | | plot.setRenderer(renderer); |
| | | return render(chart, width, height, yAxisLabel, true, percentAxis, valueDecimals); |
| | | return render(chart, width, scaleLineChartHeight(height), |
| | | yAxisLabel, true, percentAxis, valueDecimals); |
| | | } |
| | | |
| | | public byte[] pieChart(String valueSuffix, List<ChartCategory> categories, |
| | |
| | | return total; |
| | | } |
| | | |
| | | static int scaleLineChartHeight(int height) { |
| | | return (int) Math.round(height * LINE_CHART_HEIGHT_SCALE); |
| | | } |
| | | |
| | | static long scaleLineChartHeight(long height) { |
| | | return Math.round(height * LINE_CHART_HEIGHT_SCALE); |
| | | } |
| | | |
| | | static BigDecimal displayValue(BigDecimal value, boolean percentItemLabels, int valueDecimals) { |
| | | if (value == null) return null; |
| | | int scale = percentItemLabels ? PERCENT_ITEM_LABEL_DECIMALS + 2 : valueDecimals; |
| | | return value.setScale(scale, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | private String formatLineLabel(Number value, boolean percentItemLabels, int valueDecimals) { |
| | | if (percentItemLabels) { |
| | | NumberFormat formatter = NumberFormat.getPercentInstance(); |
| | | formatter.setMaximumFractionDigits(1); |
| | | formatter.setMaximumFractionDigits(PERCENT_ITEM_LABEL_DECIMALS); |
| | | return formatter.format(value.doubleValue()); |
| | | } |
| | | DecimalFormat formatter = new DecimalFormat("#,##0"); |
| | |
| | | prefix + "全省跨区域人员流动量情况", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.totalDaily), false, 1100, 715), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 3424555L))); |
| | | lineImageSize(5268595L, 3424555L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期累计跨区域人员流动量走势", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.totalCumulative), false, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | lineImageSize(5268595L, 2821305L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期各交通运输方式客运量占比情况", |
| | | chartService.pieChart("人次", selected.modalShare(), 1000, 607), true, |
| | |
| | | "“" + holidayLabel + "”假期自驾出行量情况", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.selfDrivingPersonFlow), false, 1100, 522), |
| | | new HolidayTrendWordService.ImageSize(5232400L, 2484120L))); |
| | | lineImageSize(5232400L, 2484120L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期铁路客运量情况", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.rail), false, 1100, 445), |
| | | new HolidayTrendWordService.ImageSize(5264785L, 2129155L))); |
| | | lineImageSize(5264785L, 2129155L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期水路客运量情况", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.water), false, 1100, 433), |
| | | new HolidayTrendWordService.ImageSize(5274310L, 2075815L))); |
| | | lineImageSize(5274310L, 2075815L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期民航客运量情况", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.civilAviation), false, 1100, 521), |
| | | new HolidayTrendWordService.ImageSize(5271135L, 2496185L))); |
| | | lineImageSize(5271135L, 2496185L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期道路客运量情况", |
| | | chartService.lineChart("万人次", |
| | | series(years, yearData, item -> item.road), false, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | lineImageSize(5268595L, 2821305L))); |
| | | |
| | | blocks.add(new HolidayTrendWordService.SectionBlock("三、车流量走势")); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期高速公路车流量情况", |
| | | chartService.lineChart("万辆次", |
| | | series(years, yearData, item -> item.expressway), false, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | lineImageSize(5268595L, 2821305L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期普通国省道车流量情况", |
| | | chartService.lineChart("万辆次", |
| | | series(years, yearData, item -> item.nationalProvincial), false, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | lineImageSize(5268595L, 2821305L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期全省车流总量情况", |
| | | chartService.lineChart("万辆次", |
| | | series(years, yearData, item -> item.totalVehicleFlow), false, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | lineImageSize(5268595L, 2821305L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | "“" + holidayLabel + "”假期公路出行结构", |
| | | chartService.stackedBarChart("万人次", |
| | |
| | | prefix + "普通国省道观测站平均机动车速度", |
| | | chartService.lineChart("公里/小时", |
| | | Collections.singletonList(series("平均速度", observation.speed)), false, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | lineImageSize(5268595L, 2821305L))); |
| | | blocks.add(new HolidayTrendWordService.ChartBlock( |
| | | prefix + "普通国省道观测站平均拥挤度", |
| | | chartService.lineChart("拥挤度", |
| | | Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 3, 1100, 590), |
| | | new HolidayTrendWordService.ImageSize(5268595L, 2821305L))); |
| | | Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 1100, 590), |
| | | lineImageSize(5268595L, 2821305L))); |
| | | |
| | | File template = resolveTemplateFile(); |
| | | if (!template.exists() || !template.isFile()) { |
| | |
| | | return result; |
| | | } |
| | | |
| | | private HolidayTrendWordService.ImageSize lineImageSize(long width, long height) { |
| | | return new HolidayTrendWordService.ImageSize( |
| | | width, HolidayTrendChartService.scaleLineChartHeight(height)); |
| | | } |
| | | |
| | | private File resolveTemplateFile() { |
| | | List<File> candidates = new ArrayList<>(); |
| | | File configured = new File(trendTemplatePath); |
| | |
| | | import javax.imageio.ImageIO; |
| | | import java.awt.image.BufferedImage; |
| | | import java.io.ByteArrayInputStream; |
| | | import java.math.BigDecimal; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertArrayEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | |
| | | } |
| | | |
| | | @Test |
| | | void roundsLineDataToTheSamePrecisionAsLabels() { |
| | | assertEquals(new BigDecimal("0.49"), |
| | | HolidayTrendChartService.displayValue(new BigDecimal("0.4904"), false, 2)); |
| | | assertEquals(new BigDecimal("0.49"), |
| | | HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 2)); |
| | | assertEquals(new BigDecimal("0.491"), |
| | | HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 3)); |
| | | assertEquals(new BigDecimal("0.123"), |
| | | HolidayTrendChartService.displayValue(new BigDecimal("0.1234"), true, 2)); |
| | | } |
| | | |
| | | @Test |
| | | void rendersSameGeometryWhenValuesRoundToSameLabel() { |
| | | HolidayTrendChartService service = new HolidayTrendChartService(); |
| | | HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries(); |
| | | first.setName("平均拥挤度"); |
| | | first.setValues(java.util.Arrays.asList( |
| | | new BigDecimal("0.4904"), new BigDecimal("0.4923"), new BigDecimal("0.5100"))); |
| | | HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries(); |
| | | second.setName("平均拥挤度"); |
| | | second.setValues(java.util.Arrays.asList( |
| | | new BigDecimal("0.4911"), new BigDecimal("0.4934"), new BigDecimal("0.5100"))); |
| | | |
| | | byte[] firstChart = service.lineChart("拥挤度", |
| | | java.util.Collections.singletonList(first), false, 2, 1100, 590); |
| | | byte[] secondChart = service.lineChart("拥挤度", |
| | | java.util.Collections.singletonList(second), false, 2, 1100, 590); |
| | | |
| | | assertPng(firstChart); |
| | | assertPng(secondChart); |
| | | assertArrayEquals(firstChart, secondChart); |
| | | } |
| | | |
| | | @Test |
| | | void scalesLineChartHeightForBetterVerticalSeparation() throws Exception { |
| | | HolidayTrendChartService service = new HolidayTrendChartService(); |
| | | HolidayTrendChartService.ChartSeries series = new HolidayTrendChartService.ChartSeries(); |
| | | series.setName("测试"); |
| | | series.setValues(java.util.Arrays.asList( |
| | | new BigDecimal("1.00"), new BigDecimal("2.00"), new BigDecimal("3.00"))); |
| | | |
| | | byte[] png = service.lineChart("万人次", |
| | | java.util.Collections.singletonList(series), false, 1100, 590); |
| | | BufferedImage image = ImageIO.read(new ByteArrayInputStream(png)); |
| | | |
| | | assertNotNull(image); |
| | | assertEquals(HolidayTrendChartService.scaleLineChartHeight(590), image.getHeight()); |
| | | } |
| | | |
| | | @Test |
| | | void rendersHigherPrecisionLineLabelsWhenRequested() { |
| | | HolidayTrendChartService service = new HolidayTrendChartService(); |
| | | HolidayTrendChartService.ChartSeries congestion = new HolidayTrendChartService.ChartSeries(); |