矢量數(shù)據(jù)是包含空間幾何字段的數(shù)據(jù),矢量數(shù)據(jù)可視化就是將數(shù)據(jù)庫(kù)中存儲(chǔ)的矢量數(shù)據(jù)請(qǐng)求到前端,渲染成電子地圖的過(guò)程。
為了滿足用戶對(duì)不同比例尺的地圖的瀏覽需求,通常需要繪制多個(gè)比例尺級(jí)別的地圖,根據(jù)用戶需求加載對(duì)應(yīng)的級(jí)別。但是顯示器屏幕有限,當(dāng)比例尺很大時(shí),只能顯示地圖的局部,所以會(huì)對(duì)每個(gè)級(jí)別的地圖進(jìn)行分片,形成一個(gè)個(gè)小的正方形像素范圍,稱為瓦片,每張瓦片都有一個(gè)唯一的坐標(biāo)(z, x, y),z表示瓦片所在的層級(jí),x表示瓦片的列號(hào),y表示瓦片的行號(hào)。所有層級(jí)的瓦片形成了如圖1所示的金字塔結(jié)構(gòu)。

渲染時(shí),前端會(huì)根據(jù)地圖當(dāng)前的縮放級(jí)別,確定z值,然后根據(jù)設(shè)定的顯示窗口大小,確定需要加載z級(jí)的哪些瓦片,即確定多個(gè)(x, y),形成成多個(gè)瓦片坐標(biāo)(z,x,y),然后根據(jù)瓦片坐標(biāo)向后端請(qǐng)求瓦片中的數(shù)據(jù),并渲染在窗口中。
1. 矢量數(shù)據(jù)存儲(chǔ)
PostGIS是基于關(guān)系型數(shù)據(jù)庫(kù)PostgreSQL開(kāi)發(fā)的插件,用于在關(guān)系型數(shù)據(jù)庫(kù)中支持空間數(shù)據(jù)的存儲(chǔ)管理。文章MyBatisPlus+PostGIS實(shí)現(xiàn)Geometry數(shù)據(jù)的讀寫介紹了SpringBoot項(xiàng)目中整合矢量數(shù)據(jù)的方法。
本文中,我們以創(chuàng)建一張興趣點(diǎn)(POI)的矢量表為例,SQL建表語(yǔ)句如下:
CREATE TABLE public.t_poi
(
id varchar(36) primary key, # ID
name varchar(255) not null, # 興趣點(diǎn)名稱
geom geometry(Point, 4326) null # 興趣點(diǎn)空間位置
);
興趣點(diǎn)數(shù)據(jù)存儲(chǔ)在t_poi表里面。
2. 動(dòng)態(tài)矢量切片原理
矢量瓦片是一份位于瓦片中的矢量數(shù)據(jù)的集合,只是在矢量瓦片中,這些矢量數(shù)據(jù)的空間幾何字段不再是空間坐標(biāo),而是以瓦片左上角為原點(diǎn)的像素坐標(biāo)。將原始的空間坐標(biāo)系中的矢量數(shù)據(jù)映射到瓦片中,并將其空間坐標(biāo)轉(zhuǎn)換成瓦片中的像素坐標(biāo)的過(guò)程稱為矢量切片。
有兩種典型的矢量切片策略:靜態(tài)矢量切片和動(dòng)態(tài)矢量切片。
靜態(tài)矢量切片是預(yù)先將所有級(jí)別的矢量瓦片都切好,存儲(chǔ)在文件系統(tǒng)中,前端請(qǐng)求的時(shí)候直接從文件系統(tǒng)中讀取并返回,但是這種策略無(wú)法渲染實(shí)時(shí)更新的矢量數(shù)據(jù),所以有了動(dòng)態(tài)矢量切片策略,動(dòng)態(tài)矢量切片在前端發(fā)起請(qǐng)求時(shí)觸發(fā)的,如圖2所示,根據(jù)前端傳入的瓦片坐標(biāo)(z, x, y)生成PostGIS的SQL語(yǔ)句,用PostGIS的矢量瓦片生成功能將存儲(chǔ)的矢量數(shù)據(jù)進(jìn)行坐標(biāo)轉(zhuǎn)換并編碼成矢量瓦片。

接下來(lái)我們?cè)敿?xì)了解這個(gè)SQL語(yǔ)句,如下所示:
WITH mvtgeom AS (
SELECT id,
name,
ST_AsMVTGeom(
ST_Transform(position, 3857),
ST_TileEnvelope(z, x, y), extent => 512, buffer => 8) AS geom
FROM t_gas_station
WHERE position && ST_Transform(ST_TileEnvelope(z, x, y), 4326)
)
SELECT ST_AsMVT(mvtgeom.*) as mvt
FROM mvtgeom
SQL語(yǔ)句中涉及到兩個(gè)坐標(biāo)系4326和3857,其中4326是POI表中存儲(chǔ)的矢量數(shù)據(jù)的空間坐標(biāo)系(和建表語(yǔ)句對(duì)應(yīng)),而3857是用于地圖可視化的Web墨卡托平面投影坐標(biāo)系,如圖3所示。

SQL語(yǔ)句中,通過(guò)一系列的函數(shù)調(diào)用,將一張瓦片對(duì)應(yīng)的矢量數(shù)據(jù)查詢出來(lái)、將地理坐標(biāo)轉(zhuǎn)投影坐標(biāo)、將投影坐標(biāo)轉(zhuǎn)像素坐標(biāo),然后將像素坐標(biāo)表示的矢量數(shù)據(jù)編碼便得到一張矢量瓦片,下面是每個(gè)函數(shù)的功能:
ST_Transform:該函數(shù)用于坐標(biāo)轉(zhuǎn)換,此處主要用于在基于球面的地理坐標(biāo)系(EPSG代碼為4326)和基于平面的投影坐標(biāo)系(EPSG代碼為3857)之間做轉(zhuǎn)換。
ST_TileEnvelope(z, x, y):該函數(shù)用于生成瓦片在Web墨卡托平面上的正方形范圍。
ST_AsMVTGeom:該函數(shù)用于將Web墨卡托坐標(biāo)系下的矢量數(shù)據(jù)投影到瓦片的像素坐標(biāo)系中,即將矢量數(shù)據(jù)從平面坐標(biāo)系換到像素坐標(biāo)系中,并且只保留與落在瓦片中的部分(如圖4中長(zhǎng)方形矢量數(shù)據(jù)的陰影部分),extent參數(shù)是瓦片的像素寬高,buffer參數(shù)是瓦片向外擴(kuò)展的像素?cái)?shù),如圖4所示。向外擴(kuò)展的原因是為了避免相鄰?fù)咂丛谝黄饡r(shí),交界處的矢量數(shù)據(jù)在可視化效果上出現(xiàn)裂痕,如果兩個(gè)相鄰?fù)咂衎uffer的重疊,則會(huì)消除裂痕。
ST_AsMVT:該函數(shù)將查詢出來(lái)的要素編碼成二進(jìn)制格式的矢量瓦片,編碼規(guī)則請(qǐng)參考MapBox定義的矢量瓦片標(biāo)準(zhǔn)。

3. 動(dòng)態(tài)矢量切片服務(wù)開(kāi)發(fā)
下面我們給出基于SpringBoot和Mybatis開(kāi)發(fā)的動(dòng)態(tài)矢量切片的服務(wù)端代碼:
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.spring.accumulator.entity.handler.PointTypeHandler;
import lombok.Data;
import org.locationtech.jts.geom.Point;
/**
* 興趣點(diǎn)PO
*
* @author wangrubin
*/
@Data
@TableName(value = "t_poi", autoResultMap = true)
public class PoiPO {
/**
* ID
*/
private Integer id;
/**
* POI名稱
*/
private String name;
/**
* POI空間位置
*/
@JsonIgnore
@TableField(typeHandler = PointTypeHandler.class)
private Point geom;
}
import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.spring.accumulator.entity.PoiPO;
import com.spring.accumulator.model.vo.VectorTile;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
/**
* POI表數(shù)據(jù)庫(kù)訪問(wèn)層
*
* @author wangrubin
*/
@Mapper
public interface PoiMapper extends BaseMapper<PoiPO> {
@Select({"WITH mvtgeom AS (\n" +
" SELECT id, name, ST_AsMVTGeom(\n" +
" ST_Transform(geom, 3857),\n" +
" ST_TileEnvelope(#{z}, #{x}, #{y}), extent => 4096, buffer => 8) AS geom\n" +
" FROM t_region_poi\n" +
" WHERE geom && ST_Transform(ST_TileEnvelope(#{z}, #{x}, #{y}), 4326)\n" +
")\n" +
"SELECT ST_AsMVT(mvtgeom.*) as mvt\n" +
"FROM mvtgeom"})
VectorTile selectVectorTile(Integer z, Integer x, Integer y);
}
import com.spring.accumulator.dao.PoiMapper;
import com.spring.accumulator.model.vo.VectorTile;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/vector-tile")
public class VectorTileController {
@ApiOperation(value = "動(dòng)態(tài)矢量切片請(qǐng)求")
@ApiImplicitParams(value = {
@ApiImplicitParam(name = "z", value = "縮放等級(jí)", required = true),
@ApiImplicitParam(name = "y", value = "瓦片行號(hào)", required = true),
@ApiImplicitParam(name = "x", value = "瓦片列號(hào)", required = true)
})
@GetMapping("/tile/{z}/{y}/{x}.pbf")
public void listPerson(@PathVariable Integer z,
@PathVariable Integer y,
@PathVariable Integer x,
HttpServletResponse response) {
try {
response.setContentType("application/x-protobuf");
response.setCharacterEncoding("utf-8");
// 這里URLEncoder.encode可以防止中文亂碼
String encodedFileName = URLEncoder.encode(x.toString(), StandardCharsets.UTF_8.name()).replaceAll("\\+", "%20");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename*=utf-8''" + encodedFileName + ".pbf");
VectorTile vectorTile = poiMapper.selectVectorTile(z, x, y);
response.getOutputStream().write(vectorTile.getMvt());
System.out.println(z + "-" + y + "-" + x + ":" + vectorTile.getMvt().length);
} catch (Exception e) {
// 重置response
log.error("文件下載失敗" + e.getMessage());
throw new RuntimeException("下載文件失敗", e);
}
}
@Resource
private PoiMapper poiMapper;
}
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class VectorTile {
byte[] mvt;
}
4. QGIS演示
在本地啟動(dòng)服務(wù),端口設(shè)為8080。QGIS提供了利用矢量瓦片來(lái)渲染電子地圖的功能,如圖5所示,配置url為:http://localhost:8080/vector-tile/tile/{z}/{y}/{x}.pbf
