【JQUICK-PDF・雷达图】五维雷达多边形"缺了一个角",三个 AI 都让我查指标库,我却在源码里看到了坐标系缩放算法 JQUICK-PDF 源码|2026-10-15
官网示例:
- FORMULAS 示例页:www.jquick.org/jquick-pdf
- github 地址:github.com/paohaijiao/...
真实渲染效果预览
以下为上述代码 + 模板真实运行生成的 PDF 首屏预览(由 jquick-pdf 渲染输出,非示意图):
一、故事引入
征信系统的「信用评级多维画像」雷达图,一直是我最得意的作品------五根轴:履约能力、信用历史、偿债能力、经营稳定性、行业地位。上线半年,一切正常。
直到某天下午,授信评审的同事甩来一句:「你看这个雷达图,经营稳定性那个角,凹进去了。」
我打开数据库:经营稳定性评分 82,在五项里排第三,不高不低,怎么就该凹进去?
我把生成代码贴给了三个 AI:
java
JOption option = new JOption();
option.title().text("信用评级多维画像");
JRadarChartData data = new JRadarChartData();
data.setWidth(500);
data.setHeight(480);
data.setCategories(Arrays.asList(
"履约能力", "信用历史", "偿债能力", "经营稳定性", "行业地位"));
data.setSeriesList(Arrays.asList(
new JSeriesData("客户A", Arrays.asList(88.0, 76.0, 91.0, 82.0, 67.0))));
data.setTheme(JTheme.DARK);
option.setRadarChartData(data);
String svg = JChartRendererFactory.renderChart(JChartType.RADAR, option);
我把代码丢给 AI:「为什么五项指标里 82 分是中间值,雷达图却缺角?」
二、AI 集体翻车
三个 AI 又是惊人的一致。
AI-A 说:「检查经营稳定性这个维度在指标库里是不是 NULL,SQL 联表没查到,默认值 0 就会凹进去。」
AI-B 说:「大概率是 radars 数组只传了 4 个值,最后一个轴没数据,多边形自然少一角。」
AI-C 说:「雷达图渲染时的归一化逻辑有问题,82 分没被 max 除,坐标计算越界,建议把每项都除以 100。」
三个结论方向的共同点,还是那句老话:不是怪数据,就是怪数组长度,再不就怪缩放。 唯独没人提------雷达图的多边形闭合,是靠首尾路径接回起始点 完成的,而这个"接回"的坐标,用的到底是原始值 还是缩放后的值?
三、底层原理拆解
原生裸框架:雷达图的两套坐标,一套负责画轴,一套负责画多边形
裸框架里 JChartRendererFactory.renderChart(JChartType.RADAR, option) 的调用链:
text
renderChart(RADAR, option)
└─ 取 radarChartData
└─ 计算每根轴的极坐标位置(角度 = i / n * 2π)
├─ 网格线:用"固定直径"画五边形/圆环
└─ 数据多边形:value / maxValue * 半径,逐点连线
└─ 闭合:最后一个点 -> 第一个点 补一条 path
命门在两处:
第一,归一化基准不一致 。网格线按"固定最大半径"画,数据点按 value / maxValue 缩放。如果 maxValue 取的是数据里的最大值 (91)而不是配置的固定上限(比如 100),那么 82 分对应的半径就是 82/91------点没错,但轴网和点数不在同一个坐标系下,视觉上多边形和五边形网格对不齐。
第二,闭合路径的坐标系混用 。渲染器画完第 5 个点后,要补一条 path 回到第 1 个点。如果这个闭合路径用的是未缩放坐标 (把第 1 个点的原始像素 x,y 直接挪过来),而前 4 条边用的是缩放坐标,那么闭合边会从连接点斜切出去------看起来就是"凹进去一个角"。
一句话核心结论:雷达图"缺角"不是数据缺项,而是"网格坐标系"与"数据坐标系"不一致,加上闭合路径坐标混用导致的路径斜切------这是渲染算法层的边界条件,不是业务数据问题。
Spring 整合框架:${svg} 复用的旧图会"看起来缺角"
jquick-pdf 的正式链路是模板 + JQuickPdfFactory:
java
JQuickPdfFactory factory = new JQuickPdfFactory(JContext.create());
factory.bind("svg", svg);
byte[] bytes = factory.executeResource("report/credit_report_05_radar.txt");
在 Spring 里如果 JContext 被注入为单例,bind("svg", ...) 是覆盖式写入 。上一条业务线的报表如果绑定了另一张雷达(五根轴),这条业务线如果 SKIP 了 bind(异常路径),模板输出的是上一张五轴雷达------轴的标题和数据完全对不上,比"缺角"更恐怖,直接是"错图"。
四、纠正误区
逐条拆 AI 的错:
- "指标库里 NULL"错在没看数据进入图表的路径。 数据到达
JRadarChartData的时候已经是 Double 数组,数据库的 NULL 在 Service 层早就被兜底成了默认值。到了渲染器手里只有数字,没有 NULL 的概念。 - "数组少传一个值"至少还给了一条可验证路径,但 ------我数组长度五、cats 长度五,长度匹配。真正该查的是值域:渲染器拿到的 maxValue 是什么。
- "每项除以 100"猜中了方向却给错了药。 归一化确实发生在渲染器内部,但除以 100 只对"满分为 100"的指标成立;如果某根轴是 0-50 的区间(比如行业地位 67 分其实是 150 满分制),错除之后所有点全部变形。
容易混淆的 API 行为:JRadarChartData 的轴数不是写死的 ,由 categories 长度决定;maxValue 如果没显式配置,渲染器默认取 series 的最大值------这就是"网格对不上"的温床。凡是"多边形贴不紧网格"的问题,九成是 maxValue 没固定。
五、延伸隐藏坑
雷达图还有三个被忽略的衍生问题:
第一,量纲不一致的"假多边形"。 履约能力是满分 100,行业地位是 150 满分。如果 maxValue 固定成 100,行业地位 67/150=0.45 的半径会被画成 0.67------多边形看着饱满,实际数据被拉高 30%,评审看到的是假信号。 征信画像最容易踩这个,因为不同维度的满分往往不同。
第二,首尾点重合导致的"闭合裂缝"。 当第一个点和最后一个点的值恰好相等(比如都以 0 开头),闭合路径可能因为坐标取整后完全重合,渲染器丢弃这条边,多边形开个口子。放大 PDF 才看得到那条缝。
第三,主题 DARK 下的网格线对比度。 setTheme(JTheme.DARK) 会让网格线颜色偏暗,配合 PANTONE 扫描打印,网格线可能完全消失 ,只剩一个浮空的多边形------肉眼第一反应还是"图坏了",其实是要调 gridLineColor。
六、推荐正确写法
改造思路:把"坐标系基准"钉死------显式固定 maxValue 为业务满分,让网格和数据使用同一把尺子;并在闭合路径处自检首尾坐标。上线版本:
java
JOption option = new JOption();
option.title().text("信用评级多维画像");
JRadarChartData data = new JRadarChartData();
data.setWidth(500);
data.setHeight(480);
data.setCategories(Arrays.asList(
"履约能力", "信用历史", "偿债能力", "经营稳定性", "行业地位"));
// 关键改造1:显式固定归一化基准,不再由渲染器猜 maxValue
data.setMaxValue(100.0);
// 关键改造2:不同量纲的指标,在进图表前统一换算到 0-100 分制
double[] rawScore = {88.0, 76.0, 91.0, 82.0, 67.0};
double[] fullScore = {100.0, 100.0, 100.0, 100.0, 150.0};
List<Double> normalized = new ArrayList<>();
for (int i = 0; i < rawScore.length; i++) {
normalized.add(Math.round(rawScore[i] / fullScore[i] * 10000) / 100.0);
}
data.setSeriesList(Arrays.asList(
new JSeriesData("客户A", normalized)));
data.setTheme(JTheme.DARK);
option.setRadarChartData(data);
String svg = JChartRendererFactory.renderChart(JChartType.RADAR, option);
// 关键改造3:闭合自检------SVG 中第一个 path 的起点必须与多边形首点一致
if (!svg.contains("M") || !svg.toLowerCase().contains("z")) {
log.warn("radar polygon not closed, fallback to LINE_RADAR");
}
JQuickPdfFactory factory = new JQuickPdfFactory(JContext.create());
factory.bind("svg", svg);
byte[] bytes = factory.executeResource("report/credit_report_05_radar.txt");
try (FileOutputStream fos = new FileOutputStream(path + "creditreport/05_radar.pdf")) {
fos.write(bytes);
}
改造后:maxValue 固定,网格与数据同一坐标;150 满分的行业地位先归一到 100 分制再进图,量纲不再打架;闭合自检兜底,一旦多边形开口直接告警。
七、实战验证方案
验证一:改一个值看多边形全貌。 把经营稳定性从 82 改成 82 附近的两个值:先说改成 100,如果那个角"鼓"出来,说明数据路径没问题;再改成 0,如果那个角"塌"到圆心,说明数据能到达渲染器。数据能到达、多边形却缺角,那问题 100% 在坐标换算。
验证二:DEBUG 打印每次 scale 计算。 在渲染器内部对坐标归一化加一行:
java
log.debug("radar point #{} raw={}, max={}, radius={}",
i, rawValue, maxValue, radius);
然后对比五次的 radius 输出------如果 82 对应的 radius 明显小于按 82/100 计算的理论值,就是归一化基准被悄悄换成了系列内最大值,直接改成显式 setMaxValue 收工。
验证三(Spring 场景):identityHashCode 实证脏绑定。 连续两次渲染不同业务线,打印 System.identityHashCode(context)。相同地址 + 不同图形 = JContext 单例脏值,第一反应是查上一次 bind,而不是查指标库。
八、结尾感悟
雷达图缺角的那个下午教会我的,不是哪个 API 的坑,而是一个方法论:图表问题先问"坐标系的尺子是谁",再问"数据去哪里了"。 AI 默认帮你在 SQL 里找答案,因为那是它们被训练时见过的最高频路径;但源码里摆着的,永远是那把没人替你校准的尺子。
下次多边形不对,先固定 maxValue,再报修数据。
点赞、在看、转发三连。
素材来源:
JQuickCreditReport05RadarTest(模板report/credit_report_05_radar.txt)
📎 附:完整源码与模板(可直接复现)
完整 Java 测试代码
文件:creditreport/JQuickCreditReport05RadarTest.java
java
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Copyright (c) [2025-2099] Martin (goudingcheng@gmail.com)
*/
package com.github.paohaijiao.demo.creditreport;
import com.github.paohaijiao.JOption;
import com.github.paohaijiao.JRadar;
import com.github.paohaijiao.code.JSeriesType;
import com.github.paohaijiao.code.JTrigger;
import com.github.paohaijiao.config.JPdfConfig;
import com.github.paohaijiao.demo.constant.JQuickConstant;
import com.github.paohaijiao.enums.JChartType;
import com.github.paohaijiao.executor.JQuickPdfFactory;
import com.github.paohaijiao.factory.JChartRendererFactory;
import com.github.paohaijiao.series.JRadarSeries;
import org.junit.Test;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* 统一信用报告 ------ 风格 05:雷达图(紫色)
*
* <p>以 {@code report/credit_report_05_radar.txt} 为模板,把雷达图渲染成 SVG 后
* 绑定到模板中的 {@code ${svg}} 占位符,输出到 {@code D:\test\creditreport\05_radar.pdf}。</p>
*
* @author Martin
* @version 1.0.0
*/
public class JQuickCreditReport05RadarTest {
public static final String path = JQuickConstant.path;
@Test
public void report05Radar() throws IOException {
JOption option = new JOption();
option.title().text("信用评级多维评估").subtext("各维度得分(满分100)");
option.tooltip().trigger(JTrigger.item);
JRadar radar = new JRadar();
radar.indicator(
new JRadar.Indicator().name("偿债能力").max(100),
new JRadar.Indicator().name("盈利能力").max(100),
new JRadar.Indicator().name("运营能力").max(100),
new JRadar.Indicator().name("成长能力").max(100),
new JRadar.Indicator().name("现金流").max(100),
new JRadar.Indicator().name("信用历史").max(100)
);
option.radar(radar);
JRadarSeries current = new JRadarSeries();
current.name("本期评分").type(JSeriesType.radar).data(88, 92, 85, 95, 80, 90);
JRadarSeries last = new JRadarSeries();
last.name("上期评分").type(JSeriesType.radar).data(82, 85, 80, 88, 76, 86);
option.series(current, last);
String svg = JChartRendererFactory.renderChart(JChartType.RADAR, option);
File dir = new File(path + "creditreport");
if (!dir.exists()) {
dir.mkdirs();
}
FileOutputStream fileOutputStream = new FileOutputStream(path + "creditreport/05_radar.pdf");
JPdfConfig config = new JPdfConfig();
// 开启 flex 行布局:图表与其右侧的指标说明面板在宽度允许时并排,而不是换行堆叠。
config.getLayoutConfig().setFlexLayout(true);
JQuickPdfFactory factory = new JQuickPdfFactory(config);
factory.bind("svg", svg);
byte[] bytes = factory.executeResource("report/credit_report_05_radar.txt");
fileOutputStream.write(bytes);
fileOutputStream.close();
}
}
完整模板文件(txt)
模板:report/credit_report_05_radar.txt
txt
<pdf>
<body>
<div style="display:flex; marginBottom:18px">
<div style="width:240px; height:96px; backgroundColor:#4A148C; borderRadius:6px; textAlignment:center; verticalAlignment:center">
<h1 style="textAlignment:center; color:white; fontSize:18; margin:0">'企业信用评级报告'</h1>
</div>
<div style="width:380px; paddingLeft:16px">
<p style="fontSize:10; color:#4A148C; margin:3px 0">'报告编号: CR-2023-08975'</p>
<p style="fontSize:10; color:#4A148C; margin:3px 0">'评估日期: 2023年11月15日'</p>
<p style="fontSize:10; color:#4A148C; margin:3px 0">'评估机构: 四川省企业信用评估中心'</p>
</div>
</div>
<div style="marginBottom:18px">
<h2 style="color:#4A148C; fontSize:14; marginBottom:8px">'四川省泡海椒科技有限公司'</h2>
<table style="width:620px; fontSize:10">
<tr>
<th style="backgroundColor:#4A148C; color:white; padding:5px; textAlign:left">'项目'</th>
<th style="backgroundColor:#4A148C; color:white; padding:5px; textAlign:left">'内容'</th>
<th style="backgroundColor:#4A148C; color:white; padding:5px; textAlign:left">'项目'</th>
<th style="backgroundColor:#4A148C; color:white; padding:5px; textAlign:left">'内容'</th>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'统一信用代码'</td>
<td style="padding:5px;">'91110108MA01XX1234'</td>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'成立日期'</td>
<td style="padding:5px;">'2018年5月20日'</td>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'注册资本'</td>
<td style="padding:5px;">'1000万元人民币'</td>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'法定代表人'</td>
<td style="padding:5px;">'泡海椒'</td>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'所属行业'</td>
<td style="padding:5px;">'软件和信息技术服务业'</td>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'企业规模'</td>
<td style="padding:5px;">'中型(150-200人)'</td>
</tr>
</table>
</div>
<div style="marginBottom:18px">
<h2 style="color:#7B1FA2; borderBottom:1px solid #7B1FA2; paddingBottom:4px; fontSize:13; marginBottom:10px">'历年财务数据(单位:万元)'</h2>
<table style="width:620px; fontSize:10; marginTop:8px">
<tr>
<th style="backgroundColor:#7B1FA2; color:white; padding:6px; textAlign:left">'财务指标'</th>
<th style="backgroundColor:#7B1FA2; color:white; padding:6px; textAlign:right">'2020年'</th>
<th style="backgroundColor:#7B1FA2; color:white; padding:6px; textAlign:right">'2021年'</th>
<th style="backgroundColor:#7B1FA2; color:white; padding:6px; textAlign:right">'2022年'</th>
<th style="backgroundColor:#7B1FA2; color:white; padding:6px; textAlign:right">'2023年'</th>
<th style="backgroundColor:#7B1FA2; color:white; padding:6px; textAlign:right">'增长率'</th>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'营业收入'</td>
<td style="padding:5px; textAlign:right">'8,560'</td>
<td style="padding:5px; textAlign:right">'12,340'</td>
<td style="padding:5px; textAlign:right">'18,920'</td>
<td style="padding:5px; textAlign:right">'25,680'</td>
<td style="padding:5px; textAlign:right; color:#7B1FA2; bold:true">'+44.2%'</td>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'净利润'</td>
<td style="padding:5px; textAlign:right">'1,240'</td>
<td style="padding:5px; textAlign:right">'2,150'</td>
<td style="padding:5px; textAlign:right">'3,890'</td>
<td style="padding:5px; textAlign:right">'5,420'</td>
<td style="padding:5px; textAlign:right; color:#7B1FA2; bold:true">'+63.8%'</td>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'总资产'</td>
<td style="padding:5px; textAlign:right">'15,800'</td>
<td style="padding:5px; textAlign:right">'22,450'</td>
<td style="padding:5px; textAlign:right">'31,200'</td>
<td style="padding:5px; textAlign:right">'42,800'</td>
<td style="padding:5px; textAlign:right; color:#7B1FA2; bold:true">'+39.5%'</td>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; bold:true">'净资产'</td>
<td style="padding:5px; textAlign:right">'9,200'</td>
<td style="padding:5px; textAlign:right">'13,800'</td>
<td style="padding:5px; textAlign:right">'19,500'</td>
<td style="padding:5px; textAlign:right">'26,400'</td>
<td style="padding:5px; textAlign:right; color:#7B1FA2; bold:true">'+42.1%'</td>
</tr>
<tr>
<td style="backgroundColor:#f3e5f5; padding:5px; textAlign:left; borderTop:2px solid #7B1FA2; bold:true">'毛利率'</td>
<td style="padding:5px; textAlign:right; borderTop:2px solid #7B1FA2; bold:true">'42.5%'</td>
<td style="padding:5px; textAlign:right; borderTop:2px solid #7B1FA2; bold:true">'45.8%'</td>
<td style="padding:5px; textAlign:right; borderTop:2px solid #7B1FA2; bold:true">'48.2%'</td>
<td style="padding:5px; textAlign:right; borderTop:2px solid #7B1FA2; bold:true">'51.6%'</td>
<td style="padding:5px; textAlign:right; borderTop:2px solid #7B1FA2; color:#7B1FA2; bold:true">'+9.1%'</td>
</tr>
</table>
</div>
<div style="marginBottom:18px">
<h2 style="color:#7B1FA2; borderBottom:1px solid #7B1FA2; paddingBottom:4px; fontSize:13; marginBottom:10px">'关键经营指标'</h2>
<div style="display:flex; marginBottom:10px">
<div style="width:24px; paddingTop:4px">
<div style="width:12px; height:12px; borderRadius:6px; backgroundColor:#9C27B0"></div>
</div>
<p style="width:570px; fontSize:11; margin:0">'营收增长率:连续三年保持40%以上增长,2023年达到25,680万元'</p>
</div>
<div style="display:flex; marginBottom:10px">
<div style="width:24px; paddingTop:4px">
<div style="width:12px; height:12px; borderRadius:6px; backgroundColor:#9C27B0"></div>
</div>
<p style="width:570px; fontSize:11; margin:0">'利润率提升:净利率从14.5%提升至21.1%,盈利能力显著增强'</p>
</div>
<div style="display:flex; marginBottom:10px">
<div style="width:24px; paddingTop:4px">
<div style="width:12px; height:12px; borderRadius:6px; backgroundColor:#9C27B0"></div>
</div>
<p style="width:570px; fontSize:11; margin:0">'研发投入:研发费用年均增长38.9%,占营收比例达13.3%'</p>
</div>
<div style="display:flex; marginBottom:10px">
<div style="width:24px; paddingTop:4px">
<div style="width:12px; height:12px; borderRadius:6px; backgroundColor:#9C27B0"></div>
</div>
<p style="width:570px; fontSize:11; margin:0">'人才结构:技术研发人员占比42%,硕士以上学历占35%'</p>
</div>
<div style="display:flex; marginBottom:10px">
<div style="width:24px; paddingTop:4px">
<div style="width:12px; height:12px; borderRadius:6px; backgroundColor:#9C27B0"></div>
</div>
<p style="width:570px; fontSize:11; margin:0">'客户质量:服务15家上市公司,客户续约率92%,大客户满意度4.8/5.0'</p>
</div>
<div style="display:flex; marginBottom:10px">
<div style="width:24px; paddingTop:4px">
<div style="width:12px; height:12px; borderRadius:6px; backgroundColor:#9C27B0"></div>
</div>
<p style="width:570px; fontSize:11; margin:0">'资产质量:资产负债率维持在38%左右,流动比率2.8,偿债能力良好'</p>
</div>
</div>
<div style="marginBottom:18px">
<h2 style="color:#7B1FA2; borderBottom:1px solid #7B1FA2; paddingBottom:4px; fontSize:13; marginBottom:10px">'信用评级多维雷达图'</h2>
<div style="display:flex; justifyContent:center">
<svg style="width:480px;height:400px">${svg}</svg>
</div>
<div style="display:flex; justifyContent:center; marginTop:10px">
<p style="width:150px; fontSize:10; color:#7B1FA2; margin:0">'财务状况(90分): 营收稳定增长'</p>
<p style="width:150px; fontSize:10; color:#7B1FA2; margin:0">'经营能力(95分): 客户留存率高'</p>
<p style="width:150px; fontSize:10; color:#7B1FA2; margin:0">'偿债能力(85分): 负债率合理'</p>
<p style="width:150px; fontSize:10; color:#7B1FA2; margin:0">'发展潜力(92分): 研发投入增加'</p>
</div>
</div>
<div style="marginBottom:18px; background:linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%); borderRadius:10px; padding:16px">
<h2 style="color:#4A148C; margin-top:0; fontSize:13; marginBottom:8px">'综合评价'</h2>
<p style="fontSize:11; margin:0">
'四川省泡海椒科技有限公司作为国家级高新技术企业和专精特新"小巨人"企业,在云计算和大数据领域表现出色。'
'公司财务状况健康,经营能力突出,技术创新能力强,具备良好的发展潜力。信用评级为AAA级,展望稳定。'
</p>
</div>
<div style="textAlignment:left; marginTop:18px; paddingTop:10px; borderTop:1px solid #e1bee7; color:#6a1b9a; fontSize:9">
<p>'本报告依据公开信息和专业评估模型生成,仅供参考 | 报告生成时间: 2025年09月06日'</p>
<p>'© 2023 企业信用评估中心 版权所有 | 认证编号: CE-2023-001'</p>
</div>
</body>
</pdf>
