PostGIS动态矢量切片(原理+实现)

矢量数据是包含空间几何字段的数据,矢量数据可视化就是将数据库中存储的矢量数据请求到前端,渲染成电子地图的过程。

为了满足用户对不同比例尺的地图的浏览需求,通常需要绘制多个比例尺级别的地图,根据用户需求加载对应的级别。但是显示器屏幕有限,当比例尺很大时,只能显示地图的局部,所以会对每个级别的地图进行分片,形成一个个小的正方形像素范围,称为瓦片,每张瓦片都有一个唯一的坐标(z, x, y),z表示瓦片所在的层级,x表示瓦片的列号,y表示瓦片的行号。所有层级的瓦片形成了如图1所示的金字塔结构。


图1 瓦片金字塔

渲染时,前端会根据地图当前的缩放级别,确定z值,然后根据设定的显示窗口大小,确定需要加载z级的哪些瓦片,即确定多个(x, y),形成成多个瓦片坐标(z,x,y),然后根据瓦片坐标向后端请求瓦片中的数据,并渲染在窗口中。

1. 矢量数据存储

PostGIS是基于关系型数据库PostgreSQL开发的插件,用于在关系型数据库中支持空间数据的存储管理。文章MyBatisPlus+PostGIS实现Geometry数据的读写介绍了SpringBoot项目中整合矢量数据的方法。
本文中,我们以创建一张兴趣点(POI)的矢量表为例,SQL建表语句如下:

CREATE TABLE public.t_poi
(
    id      varchar(36) primary key, # ID
    name    varchar(255) not null, # 兴趣点名称
    geom    geometry(Point, 4326) null # 兴趣点空间位置
);

兴趣点数据存储在t_poi表里面。

2. 动态矢量切片原理

矢量瓦片是一份位于瓦片中的矢量数据的集合,只是在矢量瓦片中,这些矢量数据的空间几何字段不再是空间坐标,而是以瓦片左上角为原点的像素坐标。将原始的空间坐标系中的矢量数据映射到瓦片中,并将其空间坐标转换成瓦片中的像素坐标的过程称为矢量切片。

有两种典型的矢量切片策略:静态矢量切片和动态矢量切片。
静态矢量切片是预先将所有级别的矢量瓦片都切好,存储在文件系统中,前端请求的时候直接从文件系统中读取并返回,但是这种策略无法渲染实时更新的矢量数据,所以有了动态矢量切片策略,动态矢量切片在前端发起请求时触发的,如图2所示,根据前端传入的瓦片坐标(z, x, y)生成PostGIS的SQL语句,用PostGIS的矢量瓦片生成功能将存储的矢量数据进行坐标转换并编码成矢量瓦片。


图2 动态矢量瓦片请求示意图

接下来我们详细了解这个SQL语句,如下所示:

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语句中涉及到两个坐标系4326和3857,其中4326是POI表中存储的矢量数据的空间坐标系(和建表语句对应),而3857是用于地图可视化的Web墨卡托平面投影坐标系,如图3所示。


图3 地理坐标系、Web墨卡托投影坐标系、与像素坐标系

SQL语句中,通过一系列的函数调用,将一张瓦片对应的矢量数据查询出来、将地理坐标转投影坐标、将投影坐标转像素坐标,然后将像素坐标表示的矢量数据编码便得到一张矢量瓦片,下面是每个函数的功能:

ST_Transform:该函数用于坐标转换,此处主要用于在基于球面的地理坐标系(EPSG代码为4326)和基于平面的投影坐标系(EPSG代码为3857)之间做转换。
ST_TileEnvelope(z, x, y):该函数用于生成瓦片在Web墨卡托平面上的正方形范围。
ST_AsMVTGeom:该函数用于将Web墨卡托坐标系下的矢量数据投影到瓦片的像素坐标系中,即将矢量数据从平面坐标系换到像素坐标系中,并且只保留与落在瓦片中的部分(如图4中长方形矢量数据的阴影部分),extent参数是瓦片的像素宽高,buffer参数是瓦片向外扩展的像素数,如图4所示。向外扩展的原因是为了避免相邻瓦片拼在一起时,交界处的矢量数据在可视化效果上出现裂痕,如果两个相邻瓦片有buffer的重叠,则会消除裂痕。
ST_AsMVT:该函数将查询出来的要素编码成二进制格式的矢量瓦片,编码规则请参考MapBox定义的矢量瓦片标准

图4 矢量瓦片的参数示意图

3. 动态矢量切片服务开发

下面我们给出基于SpringBoot和Mybatis开发的动态矢量切片的服务端代码:

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;

/**
 * 兴趣点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表数据库访问层
 *
 * @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 = "动态矢量切片请求")
    @ApiImplicitParams(value = {
            @ApiImplicitParam(name = "z", value = "缩放等级", required = true),
            @ApiImplicitParam(name = "y", value = "瓦片行号", required = true),
            @ApiImplicitParam(name = "x", value = "瓦片列号", 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演示

在本地启动服务,端口设为8080。QGIS提供了利用矢量瓦片来渲染电子地图的功能,如图5所示,配置url为:http://localhost:8080/vector-tile/tile/{z}/{y}/{x}.pbf

图5 QGIS请求矢量瓦片渲染电子地图的配置窗口

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容