大概每个做气象、环保或农业条线的后端同学都接过这种需求:手里只有几百个甚至几千个监测站点的离散点数据,比如温度、雨量、AQI、土壤墒情,产品经理丢过来一句“我要一张色斑图”。所谓色斑图,就是把连续的空间数值用一块一块的颜色表示出来,地图上看起来像拼色地毯,直观又唬人。
但真正动手做的时候才会发现,从离散点到色斑图,中间隔着插值、格点化、多边形合并、GEOJSON 组装一整套链路。这篇文章就是我落地这个需求的全过程记录,核心是“Java后端基于离散点/格点生成GEOJSON以渲染色斑图”的完整方案:包括插值算法怎么选、格点分辨率怎么定、GEOJSON 怎么组织、性能和坑怎么处理。适合刚接触空间数据可视化的后端开发,也适合被产品经理一句话逼到墙角、急需一套可落地方案的兄弟。
1. 方案设计与整体思路
1.1 色斑图的业务场景到底是什么
先理清业务。色斑图最常见的场景有三类:
- 气象要素图:温度、降水量、风速、能见度的空间分布;
- 环境监测图:PM2.5、臭氧、AQI 指数区域分布;
- 农业/水文图:土壤墒情、干旱等级、水质指标分布。
这些场景有一个共同特征:数据来源是零散分布的“站点”,而展示目标是“整个面的连续分布”。站点之间没有数据,只能靠插值把空间“填满”,再用颜色表达数值高低。产品经理嘴上说“色斑图”,本质上要的是一张带空间连续性的专题图。
1.2 后端处理链路的全貌
我的落地方案分成八个步骤,每一步都可以单独测试:
- 收集离散点数据,每一条记录至少包含经度、纬度、观测值;
- 划定插值范围,通常是一个矩形包围盒(也可以后续叠加行政区边界做裁剪);
- 按分辨率生成规则格点,把连续空间离散成二维网格;
- 对每个格点做插值,算出该点的估计值;
- 分级设色,把连续数值映射到有限个色级;
- 合并相邻同色格点,生成多边形,避免产生成千上万个碎小方块;
- 把多边形组装成 GEOJSON FeatureCollection,写入数值、颜色、级别等属性;
- 前端加载 GEOJSON,按属性渲染成色斑图层。
这条链路里,最容易翻车的是第4步和第6步:插值算法选错了图很假,合并写得不好 JSON 能上百兆。
1.3 为什么放在 Java 后端,而不是前端算
很多前端同学会说,Leaflet 或者 ECharts 也能做插值渲染。确实能做,但我最终选择在后端做,主要基于三点:
- 数据量和性能:几十万格点在前端用 JS 算插值,低端手机会直接卡死;后端算完下发静态 JSON,前端只负责画。
- 多端复用:同一个 GEOJSON 可以同时服务 Web、H5、小程序、大屏,换端不换算法。
- 可缓存可测试:插值结果按小时或按天缓存到 Redis 或对象存储,后端可以用单元测试验证算法正确性,前端没法这么干。
说白了,色斑图是“计算结果”,不是“页面效果”,结果就应该在后端产生。
2. 核心算法选型与参数设计
2.1 插值算法:多数场景下 IDW 够用
从离散点插值到格点的算法常见有三种:反距离加权(IDW)、克里金(Kriging)、最近邻(Nearest)。
- IDW:每个格点的值是周围站点值的加权平均,权重是距离的倒数幂次。计算简单、效果平滑、解释性强。
- 克里金:考虑了空间自相关,理论上最严谨,但要拟合半变异函数,参数多、计算重,不适合每次请求都实时算。
- 最近邻:取最近的站点值,速度快但是结果呈“块状”,看着不像自然分布。
我的建议是:如果不是专业气象建模要求,一律先用 IDW。实地做下来,温度、降水、AQI 这些要素用 IDW 加幂指数 2,视觉上已经很自然。
IDW 公式:
Z = Σ(wi * Zi) / Σ(wi) wi = 1 / d^pp 是幂指数,通常取 2。p 越大,离站点越远的格点受影响越小,图上有“以站点为中心向外扩散”的感觉;p 越小,空间越平滑。实际项目中 p=2 是默认值,站点密集的城区可以试 p=3。
2.2 格点分辨率怎么定才合理
格点分辨率直接决定色斑图的细腻程度和文件大小。这里的核心矛盾是:分辨率越高图越细,但格点数按平方增长,JSON 也会膨胀。
我常用的经验公式:
格点总数 = 列数 * 行数 列数 = 包围盒宽度 / 格距 行数 = 包围盒高度 / 格距举例:目标区域东西向约 300 公里,南北向约 200 公里,如果按 0.01 度格距(约 1 公里),就是 30000 列 * 20000 行,那是 6 亿个格点,直接爆掉。所以必须结合展示尺度来定:
- 全省级展示:0.05 度格距,约 5 公里网格,够用;
- 地市级展示:0.01 度格距,约 1 公里网格,细腻;
- 区县级展示:0.005 度格距,约 500 米网格。
另外一个重要约束是内存和耗时。IDW 插值每个格点都要遍历站点列表,复杂度是 O(格点数 × 站点数)。如果站点有 2000 个,格点有 10 万个,就是 2 亿次距离计算,单线程可能要十几秒。后面我会讲优化方案,但分辨率一定要最开始就控制住。
2.3 分级设色:连续值到颜色的映射
色斑图不是每个格点一个颜色,那样图例没法做。常见的做法是把连续值分成 5~10 个级别,每级一个颜色。分级方法有三种:
- 等距分级:把值域均分,简单直观,但数据集中时大部分格点会落在同一级,图很单调;
- 分位数分级:按数据量等分,每个级别的格点数量差不多,图很饱满,但阈值不是整数,解释性差;
- 手动分级:根据业务含义定阈值,比如 AQI 的优、良、轻度、中度、重度、严重,就是手动分级。
我实际做的时候,会用“手动优先,自动兜底”的策略:先看业务上有没有规定的等级阈值,如果有就用手动;没有就用分位数分级让颜色分布均匀。
颜色映射方面,给每个等级定义一个颜色值和透明度。比如温度从蓝到红渐变,AQI 从绿到紫渐变。关键点在于:颜色表必须在后端定义并直接写进 GEOJSON 的 properties 里,前端渲染时直接读,避免前后端各搞一套颜色表导致对不上。
3. Java 后端实现:从离散点到 GEOJSON
3.1 数据模型先定义清楚
第一步先定义站点数据和格点数据。站点数据最简单的结构:
public class StationPoint { // 站点经度 private double lon; // 站点纬度 private double lat; // 观测值,比如温度、AQI private double value; }格点数据不需要定义成类,直接用二维数组表示就行。但为了合并多边形方便,我会再定义一个网格单元对象:
public class GridCell { private int col; private int row; private double lon; private double lat; private int level; // 分级编号,比如 0~9 private String color; // 该级别对应的颜色 }分开定义的好处是:插值阶段只关心数值,合并阶段只关心 level,职责清楚。
3.2 IDW 插值核心代码
写 IDW 时要注意两点:一是距离要用球面距离,不能直接用经纬度差;二是如果插值点和某个站点坐标重合,要直接返回站点值,避免除以零。
具体代码:
public class IDWInterpolator { /** * 计算两个经纬度点之间的球面距离,单位:公里 */ public static double haversine(double lon1, double lat1, double lon2, double lat2) { double R = 6371.0; double dLon = Math.toRadians(lon2 - lon1); double dLat = Math.toRadians(lat2 - lat1); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } /** * IDW 插值 * @param stations 站点列表 * @param lon 目标格点经度 * @param lat 目标格点纬度 * @param power 幂指数,一般取 2 */ public static double interpolate(List<StationPoint> stations, double lon, double lat, int power) { double sumWeight = 0.0; double sumValue = 0.0; for (StationPoint p : stations) { double d = haversine(lon, lat, p.getLon(), p.getLat()); // 格点和站点重合 if (d < 0.001) { return p.getValue(); } double w = 1.0 / Math.pow(d, power); sumWeight += w; sumValue += w * p.getValue(); } return sumValue / sumWeight; } }这里我踩过一个坑:一开始图省事直接用经纬度差的欧氏距离当权重,结果高纬度地区格点间距被压缩,色斑形状失真。换成球面距离后效果立刻正常。
3.3 生成规则格点并遍历插值
插值范围我一般取站点分布的最小包围盒,然后向外扩 0.5 个格距,避免边界上出现“半边空白”。
double minLon = ...; // 站点最小经度 double maxLon = ...; // 站点最大经度 double minLat = ...; // 站点最小纬度 double maxLat = ...; // 站点最大纬度 // 格距,单位是度数 double step = 0.05; // 外扩 minLon -= step; maxLon += step; minLat -= step; maxLat += step; int cols = (int) Math.ceil((maxLon - minLon) / step); int rows = (int) Math.ceil((maxLat - minLat) / step); // 存储每个格点的插值结果 double[][] values = new double[cols][rows]; // 存储每个格点的分级编号 int[][] levels = new int[cols][rows]; for (int i = 0; i < cols; i++) { for (int j = 0; j < rows; j++) { double lon = minLon + i * step; double lat = minLat + j * step; double value = IDWInterpolator.interpolate(stations, lon, lat, 2); values[i][j] = value; levels[i][j] = classify(value); } }格点坐标我建议统一用 (lon, lat) 的顺序,因为 GEOJSON 的规定就是“经度在前,纬度在后”,写反了会直接导致图形飞到大西洋去。
3.4 分级设色:把数值映射成等级和颜色
分级这一步,我封装了一个类:
public class ColorClassifier { private final double[] thresholds; // 分级阈值,左开右闭 private final String[] colors; // 每个等级的颜色 public ColorClassifier(double[] thresholds, String[] colors) { this.thresholds = thresholds; this.colors = colors; } public int classify(double value) { for (int i = 0; i < thresholds.length; i++) { if (value <= thresholds[i]) { return i; } } return thresholds.length; } public String colorOf(int level) { return colors[level]; } }等距分级时,thresholds 可以这样生成:
double minValue = ...; double maxValue = ...; int levelCount = 7; double span = (maxValue - minValue) / levelCount; double[] thresholds = new double[levelCount - 1]; for (int i = 0; i < thresholds.length; i++) { thresholds[i] = minValue + span * (i + 1); }如果是分位数分级,就先对所有格点值排序,取对应分位点做阈值。这里有个细节:分位数计算要基于“格点插值结果”而不是“站点原始值”,否则分级结果和最终渲染对不上。
3.5 合并同色相邻格点,生成多边形
这是整个实现里最重要的部分。最粗暴的做法是每个格点输出一个小方块多边形,一万个格点就一万个 Feature,前端直接卡死。必须把相邻且同色的格点合并成大块。
合并思路是:对二维 level 数组做“连通域标记”,四方向相邻且 level 相同的格点属于同一个连通域,然后为每个连通域生成一个多边形。
连通域用 BFS 实现即可:
public static List<List<int[]>> findConnectedRegions( int[][] levels, int cols, int rows) { boolean[][] visited = new boolean[cols][rows]; List<List<int[]>> regions = new ArrayList<>(); int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}}; for (int i = 0; i < cols; i++) { for (int j = 0; j < rows; j++) { if (visited[i][j]) continue; int level = levels[i][j]; List<int[]> cells = new ArrayList<>(); Deque<int[]> queue = new ArrayDeque<>(); queue.add(new int[]{i, j}); visited[i][j] = true; while (!queue.isEmpty()) { int[] cell = queue.poll(); cells.add(cell); for (int[] dir : dirs) { int ni = cell[0] + dir[0]; int nj = cell[1] + dir[1]; if (ni >= 0 && ni < cols && nj >= 0 && nj < rows && !visited[ni][nj] && levels[ni][nj] == level) { visited[ni][nj] = true; queue.add(new int[]{ni, nj}); } } } regions.add(cells); } } return regions; }拿到连通域的所有格子后,再生成多边形。实际项目里,我推荐引入 JTS(Java Topology Suite)来做几何运算,否则手写轮廓追踪太容易出 bug。
<dependency> <groupId>org.locationtech.jts</groupId> <artifactId>jts-core</artifactId> <version>1.19.0</version> </dependency>先用每个格子的中心点生成一个小矩形,再把同一个连通域里的所有矩形做 union,就得到完整多边形:
import org.locationtech.jts.geom.*; // 每个格子的矩形 Envelope env = new Envelope( minLon + i * step, minLon + (i + 1) * step, minLat + j * step, minLat + (j + 1) * step ); Geometry cellPoly = geometryFactory.toGeometry(env); // 同一个连通域的所有格子做合并 Geometry union = geometryFactory.buildGeometry(cellPolyList).union();这里要提醒:JTS 的 union 是很重的操作,如果连通域特别多,建议对每个连通域单独处理,而不是把全网格一次性 union。另外 JTS 默认输出的坐标是 (x, y) 即 (lon, lat),正好符合 GEOJSON 要求。
合并完后,还需要对多边形做简化,去掉过多顶点:
Geometry simplified = union.simplify(step / 2);simplify 的容差我取半个格距。太小没效果,太大边缘会失真。这一步能把 JSON 体积再压缩 30% 以上。
3.6 组装 GEOJSON 并输出
GEOJSON 的标准结构是 FeatureCollection,每个 Feature 包含 geometry 和 properties。
Map<String, Object> featureCollection = new LinkedHashMap<>(); featureCollection.put("type", "FeatureCollection"); List<Map<String, Object>> features = new ArrayList<>(); for (ConnectRegion region : regions) { Map<String, Object> feature = new LinkedHashMap<>(); feature.put("type", "Feature"); Map<String, Object> geometry = new LinkedHashMap<>(); geometry.put("type", "Polygon"); geometry.put("coordinates", buildCoordinates(region.getBoundary())); feature.put("geometry", geometry); Map<String, Object> properties = new LinkedHashMap<>(); properties.put("level", region.getLevel()); properties.put("color", region.getColor()); properties.put("value", region.getAvgValue()); feature.put("properties", properties); features.add(feature); } featureCollection.put("features", features); ObjectMapper mapper = new ObjectMapper(); String geoJson = mapper.writeValueAsString(featureCollection);最终 properties 里一定要带 color 字段,前端拿到就能直接渲染,不用再维护颜色映射逻辑。可以把 minValue、maxValue、unit(单位)也放进去,前端做图例用。
3.7 和前端怎么对接
GEOJSON 生成后,前端渲染用 Leaflet 最省事:
L.geoJSON(geoJsonData, { style: function (feature) { return { fillColor: feature.properties.color, fillOpacity: 0.7, color: '#ffffff', weight: 0.5 }; } }).addTo(map);如果是大屏项目,也可以直接用 ECharts 的地图系列加载 GEOJSON。另外,GEOJSON 是标准格式,ArcGIS Pro 和 QGIS 都可以直接拖拽打开,方便产品和业务方拿去做线下核对,不需要单独写导出工具。
4. 常见问题与性能优化实录
4.1 常见问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 色斑图边缘有大量锯齿 | 合并后没有做简化 | 用 JTS simplify,容差取半个格距 |
| JSON 太大,前端加载慢 | 每格一个 Feature,没有合并 | 连通域合并 + 多边形简化 |
| 图形上北下南颠倒 | 纬度方向和数组行方向搞反 | 确认数组行索引与纬度递增方向对应 |
| 颜色值越界或不对 | 分级阈值和颜色表数量不一致 | 统一用 ColorClassifier 管理 |
| 图上有莫名其妙的细缝隙 | 相邻多边形坐标有微小误差 | 合并前统一四舍五入到 6 位小数 |
| ArcGIS 打开报错 | 坐标精度过短或环未闭合 | 坐标保留 6 位小数,保证首尾坐标一致 |
| 插值结果出现极端值 | 站点数据里有脏值/异常值 | 插值前过滤 NaN 和超过物理范围的值 |
| 跨 180 度经线时图形断裂 | 格点跨越东西半球 | 对跨线区域做坐标平移或分区处理 |
4.2 性能优化:从十几秒压到两秒内
我接手的第一个版本,2000 个站点、10 万格点,单线程 IDW 跑了差不多 15 秒。压到两秒内做了四件事:
第一,搜索半径限制。对每个格点,只取半径 R 公里内的站点参与插值,而不是遍历所有站点。R 取 100 公里,对省级色斑图足够。实现上可以先按经纬度粗筛,比如取格点周围经纬度差 ±1 度的站点,再做精确距离过滤,减少无效计算。
第二,并行计算。Java 8 的 Parallel Stream 直接上:
IntStream.range(0, cols).parallel().forEach(i -> { for (int j = 0; j < rows; j++) { // 插值计算 } });要注意线程安全,插值结果写入二维数组的不同位置,没有竞争,放行。机器是四核八线程,计算时间直接降到原来的四分之一。
第三,缓存。同一个时间段、同一个区域的数据,插值结果完全一样。把 GEOJSON 按“区域+时间+分辨率”作为 key 缓存到 Redis,有效期设成和业务数据的更新周期一致。这样大部分请求走缓存,后端几乎没压力。
第四,降低输出密度。如果只是大屏看整体趋势,0.05 度格距完全够,没必要上 0.005 度。这个一定要和产品经理对齐。
4.3 和产品经理对齐需求时的四个关键问题
这个需求最容易翻车的地方不在技术,而在需求本身没对齐。我和产品经理沟通时,固定确认四件事:
- 展示范围:是整个行政区,还是站点包围盒?如果要用行政区边界裁剪,后端要提前准备边界数据做 Clip,不能直接输出矩形包围盒。
- 分级口径:业务上有没有硬性等级标准?比如空气质量指数“优/良/轻度”是国家标准,必须走手动分级;没有标准才用分位数分级。
- 刷新频率:数据是分钟级还是小时级?决定要不要做缓存,也决定插值计算能不能放在请求链路里。实时性要求高的,建议异步计算后推送结果。
- 无数据区域表现:站点覆盖不到的格子是留白、显示“无数据”还是用灰色填充?这个产品经理一般不会主动说,但视觉影响很大。
这四点不确定,开发完大概率返工。我第一版就是没确认范围,直接输出矩形包围盒的色斑图,结果图上出现了大片的“越界”色块,被要求重做。
5. 一些实操体会
最后分享一点个人经验。色斑图这个需求,技术本身不算难,难的是“看起来专业”。同样一套插值代码,格距选 0.05 和选 0.01 渲染出来的气质完全不一样;同样一个分级,用等距还是分位数,图的层次感也完全不同。
我现在的做法是:把插值算法、分级配置、格距参数全部做成可配置项,配置存到数据库或者配置文件里,产品经理想调哪块就调哪块,不用改代码。GEOJSON 文件统一放到对象存储,前端直接走 CDN 加载,性能和维护性都好了很多。
另外,调试的时候一定要准备一份可视化调试工具。我习惯把中间结果——插值格点、合并前的小格子、合并后的多边形——分别输出成 GEOJSON,用 QGIS 或者在线查看器一层一层叠加看。哪一步出了问题,一眼就能定位。比口算坐标快多了。
这套链路跑通之后,基本覆盖了色斑图 90% 以上的业务场景。后面如果再遇到类似需求,不管是换数据源还是换展示区域,只需要调参数,核心代码一行都不用动。