PostGIS動(dòng)態(tài)矢量切片(原理+實(shí)現(xiàn))

矢量數(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)。


圖1 瓦片金字塔

渲染時(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)換并編碼成矢量瓦片。


圖2 動(dòng)態(tài)矢量瓦片請(qǐng)求示意圖

接下來(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所示。


圖3 地理坐標(biāo)系、Web墨卡托投影坐標(biāo)系、與像素坐標(biāo)系

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)

圖4 矢量瓦片的參數(shù)示意圖

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

圖5 QGIS請(qǐng)求矢量瓦片渲染電子地圖的配置窗口

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容