基于 PostGIS 生成MVT矢量瓦片

1,218 阅读9分钟

(1)本文暂不涉及矢量瓦片的生成原理,因为笔者也不是非常理解其底层实现转换原理,属于应用层级的矢量瓦片生成。

(2)本文并未过多涉及瓦片的优化,如需优化:可以考虑创建空间索引、减少瓦片属性字段、缓存瓦片、多子域(多个域名)等手段进行优化。

(3)本文的“矢量瓦片”特指:基于Mapbox标准的矢量瓦片的 Mapbox Vector Tiles(MVT)。与矢量生成的栅格瓦片无关(有些人也称之为矢量瓦片)。

基于 PostGIS 生成MVT矢量瓦片

基于java(Springboot+MyBaits实现)+ PostgreSQL 13.x(PostGIS 3.4.2)实现。其他编程语言主要是进行对Web开发后端的模块进行改造即可。

1 PostGIS基础函数

1.1 矢量瓦片函数

(1)ST_AsMvtGeom:将几何图形从地理坐标系或投影坐标系转换为Mapbox矢量切片瓦片坐标空间的数据(目前尝试地理坐标或投影坐标系【4326、3857、4490】都可以转化成功并且成功在前端加载)。简单描述为:将地理坐标或投影坐标系下的数据转换为MVT矢量瓦片坐标系下的数据

​ 参考:postgis.net/docs/manual…

(2)ST_AsMVT:将Mapbox矢量瓦片下的几何图形(通常是ST_AsMvtGeom的转换结果)转换为二进制 Mapbox 矢量瓦片表示。简单描述为:将MVT矢量瓦片坐标系下的数据转换为二进制表示,用于传输到前端

​ 参考:postgis.net/docs/manual…

1.2 辅助函数

(1)ST_Transform:进行空间坐标系的转换,可以将地理数据转换到指定的SRID下。

(2)ST_TileEnvelope:生成一个范围。根据3857的XYZ切片方案中的 缩放级别 Z 和该级别的网格中图块的 XY 索引生成一个矩形多边形范围。这个主要是用于计算每一张瓦片的范围。

SELECT ST_AsText( ST_TileEnvelope(2, 1, 1) );

image-20240923232402185.png

2 使用PostGIS生成MVT基础

2.1 准备导入数据到PostGIS

2.1.1 通过PostGIS导入数据到PostGIS
1、连接到PostGIS

image-20240924214221599.png

2、填写数据库连接信息

image-20240924214538178.png

3、进入数据库管理器

image-20240924214621757.png

4、选择导入图层

image-20240924214729226.png

5、选择并导入数据

image-20240924215004119.png

2.2 PostGIS 基础SQL

1、将几何字段转换为WKT字符串形式
-- 1、将geom转为wkt字符串显示
SELECT
	ST_ASTEXT (GEOM)
FROM
	PUBLIC."China_province";
2、查询的范围:ST_TILEENVELOPE
-- 2、查看查询的范围
SELECT
	ST_ASTEXT (ST_TILEENVELOPE (1, 1, 0));
3、将查询范围(3857坐标系)转换为4326:ST_TRANSFORM
-- 3、将查询范围(3857坐标系)转换为4326
SELECT
	ST_ASTEXT (ST_TRANSFORM (ST_TILEENVELOPE (1, 1, 0), 4326));
4、将数据从空间坐标系转为MVT瓦片坐标系:ST_TRANSFORM
-- 4、将查询范围的几何结果从地理坐标系转为像素坐标系中
SELECT
	ST_ASTEXT (
		ST_ASMVTGEOM (
			GEOM,
			ST_TRANSFORM (ST_TILEENVELOPE (1, 1, 0), 4326)
		)
	) AS GEOM
FROM
	PUBLIC."China_province";
5、将查询范围的几何转为MVT瓦片:ST_ASMVT、ST_ASMVTGEOM
-- 5、将查询范围的几何转为MVT瓦片
SELECT
	ST_ASMVT (MVTGEOM.*) AS MVT
FROM
	(
		SELECT
			ST_ASMVTGEOM (
				GEOM,
				ST_TRANSFORM (ST_TILEENVELOPE (1, 1, 0), 4326)
			)
		FROM
			PUBLIC."China_province"
	) MVTGEOM;
6、使用范围查询矢量瓦片:ST_MAKEENVELOPE
-- 6、查询矢量瓦片:使用范围查询
WITH
	MVTGEOM AS (
		SELECT
			ID,
			ST_ASMVTGEOM (
				GEOM,
				ST_MAKEENVELOPE (106.875, -67.5, 112.5, -61.875, 4490),
				4096,
				64,
				FALSE
			) AS GEOM
		FROM
			PUBLIC."China_province"
		WHERE
        	-- 判断几何是否与范围相交,或者使用:ST_INTERSECTS()函数
			GEOM && ST_MAKEENVELOPE (106.875, -67.5, 112.5, -61.875, 4490)
	)
SELECT
	ST_ASMVT (MVTGEOM.*, 'China_province') AS MVT
FROM
	MVTGEOM;

3 基于Springboot+MyBaits构建矢量瓦片服务

3.1 搭建Java工程

3.1.1 创建Maven工程

image-20240924204646723.png

3.1.2 添加Maven依赖

需要添加Springboot、MyBatis、PostgreSQL连接依赖。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <groupId>org.xyzgis</groupId>
    <artifactId>opengis-postgis</artifactId>
    <version>1.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>9</source>
                    <target>9</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    
    <!--Springboot版本信息-->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
        <!--设定一个空值将始终从仓库中获取,不从本地路径获取-->
        <relativePath/>
    </parent>
    
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!--mybatis-plus数据对接依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>
        
        <!--postgresql数据库依赖-->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <version>42.2.22</version>
        </dependency>
        
        <!--Knife4接口文档-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
            <version>2.0.9</version>
        </dependency>
        
        <!--测试依赖-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>
3.1.3 实体类

VectorTile.java

package org.xyzgis.dto;

/**
 * @ClassName VectorTile
 * @Description 矢量瓦片数据类
 * @Author xuyizhuo
 * @Date 2024/8/31 13:08
 */
public class VectorTile {
    byte[] mvt; // Mapbox标注矢量瓦片数据

    public byte[] getMvt() {
        return mvt;
    }

    public void setMvt(byte[] mvt) {
        this.mvt = mvt;
    }
}
3.1.4 添加Mapper层(数据访问层)
Mapper.java
package org.xyzgis.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.xyzgis.dto.VectorTile;

@Mapper
public interface VectorTileMapper extends BaseMapper<VectorTile> {
}

Mapper.xml

位置:resources/mapper/Mapper.java

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzgis.mapper.VectorTileMapper">
</mapper>
3.1.5 添加Service层(业务逻辑层)
package org.xyzgis.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.xyzgis.dto.VectorTile;
import org.xyzgis.mapper.VectorTileMapper;
import org.xyzgis.utils.SimplifyUtil;
import org.xyzgis.utils.Tile4326Util;

/**
 * @ClassName VectorTileService
 * @Description 矢量瓦片服务
 * @Author xuyizhuo
 * @Date 2024/8/31 13:09
 */
@Service
public class VectorTileService {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private VectorTileMapper vectorTileMapper;
}
3.1.6 添加Controller(控制层)
package org.xyzgis.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
import org.xyzgis.dto.VectorTile;
import org.xyzgis.service.VectorTileService;
import org.xyzgis.utils.Tile4326Util;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

/**
 * @ClassName VectorTilePostGISController
 * @Description 获取矢量瓦片接口类
 * @Author xuyizhuo
 * @Date 2024/8/31 13:04
 */
@CrossOrigin
@RestController
@Api(tags = "获取矢量瓦片服务")
@RequestMapping("/services/map")
public class VectorTilePostGISController {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    @Autowired
    private VectorTileService vectorTileService;
}
3.1.7 配置启动端口、访问根路径(按需配置)
server:
    port: 9761 # 服务端口
    servlet:
        context-path: /xyzgis # 服务根路径
spring:
    datasource:
        driver-class-name: org.postgresql.Driver
        username: postgres # PostgreSQL数据库用户
        password: XyzGIS520 # PostgreSQL数据库用户密码
        url: jdbc:postgresql://127.0.0.1:5432/chinavector # PostgreSQL数据库地址,chinavector为数据库名称
logging:
    level:
        org:
            xyzgis: debug # 开发阶段设置日志级别debug
3.1.8 启动类
package org.xyzgis;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @ClassName XyzgisServerStarter
 * @Description 启动器
 * @Author xuyizhuo
 * @Date 2024/08/31 13:02
 */
@SpringBootApplication
@MapperScan("org.xyzgis.mapper") // Mybaits mapper配置文件的扫描目录
public class XyzgisServerStarter {
    public static void main(String[] args) {
        SpringApplication.run(XyzgisServerStarter.class, args);
    }
}

3.2 生成3857坐标系下的矢量瓦片

3.2.1 Mapper层实现
Mapper.java
package org.xyzgis.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.xyzgis.dto.VectorTile;

@Mapper
public interface VectorTileMapper extends BaseMapper<VectorTile> {

    /**
     * 获取指定行列号的矢量瓦片
     * @param z 缩放等级
     * @param x 瓦片行号
     * @param y 瓦片列号
     * @return 矢量瓦片
     */
    VectorTile selectTile(String dataSourceName, Integer z, Integer x, Integer y);

    /***
     * 根据边界获取矢量瓦片
     * @param dataSourceName
     * @param bound
     * @return
     */
    VectorTile selectTileByBound(String dataSourceName, String bound);
}

Mapper.xml
	<!--查询矢量瓦片-->
    <select id="selectTile" resultType="org.xyzgis.dto.VectorTile">
        -- 动态获取图层的坐标系
        WITH
            -- 计算数据的坐标系
            T_SRID AS (SELECT ST_SRID(GEOM)                     AS SRID,
                              ST_TILEENVELOPE(#{z}, #{x}, #{y}) as BOUNDS
                       FROM "${dataSourceName}"
                       WHERE GEOM IS NOT NULL
                       LIMIT 1),
            -- 计算瓦片的范围
            T_BOUNDS AS (SELECT T_SRID.BOUNDS                            as BOUNDS,
                                -- 在bound计算中,使用前面计算得到的坐标系代码
                                ST_TRANSFORM(T_SRID.BOUNDS, T_SRID.SRID) as BOUNDS_geom
                         FROM T_SRID),
            -- 查询矢量瓦片
            MVTGEOM AS (SELECT ID,
                               ST_ASMVTGEOM(
                                       ST_TRANSFORM(GEOM, 3857),
                                   -- ST_TRANSFORM(ST_Simplify(geom, 0.2), 3857),
                                       T_BOUNDS.BOUNDS,
                                       4096
                                   ) AS GEOM
                        FROM "${dataSourceName}",
                             T_BOUNDS
                        WHERE ST_INTERSECTS(
                                      GEOM,
                                      T_BOUNDS.BOUNDS_geom
                                  ))
        SELECT ST_ASMVT(MVTGEOM.*, #{dataSourceName}) AS MVT
        FROM MVTGEOM;
    </select>

    <!-- 根据范围获取矢量瓦片:3857 -->
    <select id="selectTileByBound" resultType="org.xyzgis.dto.VectorTile">
        WITH MVTGEOM AS (SELECT ID,
                                ST_ASMVTGEOM(
                                        geom,
                                        ST_MakeEnvelope(${bound}, 4490),
                                        4096
                                    ) AS GEOM
                         FROM "${dataSourceName}"
                         WHERE GEOM &amp;&amp; ST_MakeEnvelope(${bound}, 4490))
        SELECT ST_ASMVT(MVTGEOM.*, #{dataSourceName}) AS MVT
        FROM MVTGEOM;
    </select>
3.2.2 Service层实现
@Service
public class VectorTileService {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private VectorTileMapper vectorTileMapper;

    /**
     * 获取指定行列号的矢量瓦片
     *
     * @param z 缩放等级
     * @param x 瓦片行号
     * @param y 瓦片列号
     * @return 矢量瓦片
     */
    public VectorTile getTile(String dataSourceName, Integer z, Integer x, Integer y) {
        return this.vectorTileMapper.selectTile(dataSourceName, z, x, y);
    }
}
3.2.3 Controller层实现
package org.xyzgis.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
import org.xyzgis.dto.VectorTile;
import org.xyzgis.service.VectorTileService;
import org.xyzgis.utils.Tile4326Util;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

/**
 * @ClassName VectorTilePostGISController
 * @Description 获取矢量瓦片接口类
 * @Author xuyizhuo
 * @Date 2024/8/31 13:04
 */
@CrossOrigin
@RestController
@Api(tags = "获取矢量瓦片服务")
@RequestMapping("/services/map")
public class VectorTilePostGISController {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    @Autowired
    private VectorTileService vectorTileService;

    @ApiOperation(value = "动态矢量切片")
    @ApiImplicitParams(value = {
            @ApiImplicitParam(name = "dataSourceName", value = "数据源名称(对应数据库的表名)", required = true),
            @ApiImplicitParam(name = "z", value = "缩放等级", required = true),
            @ApiImplicitParam(name = "y", value = "瓦片行号", required = true),
            @ApiImplicitParam(name = "x", value = "瓦片列号", required = true)
    })
    @GetMapping("{dataSourceName}/vector/tile/{z}/{x}/{y}.pbf")
    public void getTile(@PathVariable String dataSourceName, @PathVariable Integer z,
                        @PathVariable Integer x,
                        @PathVariable Integer y,
                        HttpServletResponse response) {
        if (dataSourceName == null || dataSourceName.isEmpty()) {
            throw new RuntimeException("数据源名称不能为空");
        }
        try {

            VectorTile vectorTile = vectorTileService.getTile(dataSourceName, z, x, y);
            logger.debug("{}\t,瓦片:{}/{}/{}.pbf,length:{}", dataSourceName, z, x, y, vectorTile.getMvt().length);

            // 设置响应数据
            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");
            ServletOutputStream outputStream = response.getOutputStream();
            try {
                outputStream.write(vectorTile.getMvt());
            } catch (IOException e) {
                logger.debug("请求取消:" + e.getMessage());
                // e.printStackTrace();
            }

        } catch (Exception e) {
            // 重置response
            logger.error("获取矢量瓦片失败:" + e.getMessage());
            throw new RuntimeException("获取矢量瓦片失败", e);
        }
    }
}
3.2.4 Maplibre(Mapbox)加载示例

Mapbox1.x是开源的,开源协议比较宽松,高版本会有一定的商业限制。而Maplibre是基于Mapbox1.x的分支版本,目前暂不存在商业限制问题,在使用上两者相差不大。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>加载PostGIS矢量瓦片服务</title>
    <!-- Maplibre依赖 -->
    <link href="../../lib/maplibre-gl-js-4.5.0/maplibre-gl.css" rel="stylesheet" />
    <script src="../../lib/maplibre-gl-js-4.5.0/maplibre-gl.js"></script>

    <style>
        * {
            margin: 0;
            padding: 0;
        }

        html,
        body,
        #map {
            width: 100%;
            height: 100%;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script>
        // 初始化MapBox地图
        const map = new maplibregl.Map({
            container: "map",
            // 初始化的地图Style
            style: {
                version: 8,
                sources: {
                    "tiandtu-raster": {
                        type: "raster",
                        tileSize: 256,
                        // 加载xyz瓦片
                        tiles: [
"https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png"
                        ],
                    },
                    "China_county": {
                        type: 'vector',
                        tiles: [`http://localhost:9761/xyzgis/services/map/China_county/vector/tile/{z}/{x}/{y}.pbf`],
                        minzoom: 0,
                        maxzoom: 18,
                    },
                },
                layers: [
                    {
                        id: "tiandtu",
                        type: "raster",
                        source: "tiandtu-raster",
                        // minzoom: 0,
                        // maxzoom: 22,
                    },
                    {
                        id: "China_county",
                        "source-layer": "China_county",
                        type: "fill",
                        source: "China_county",
                        paint: {
                            "fill-antialias": true, // 填充时是否反锯齿
                            "fill-color": "#f5f4ee", // 填充的颜色
                            "fill-outline-color": "#b7b7a1", // 描边的颜色
                            // "fill-opacity": 0.9, // 填充的不透明度
                        },
                    }
                ],
            },
            minzoom: 0,
            center: [92.91032639969171, 57.18390324160433],
            zoom: 2,
        });
        console.log("初始化地图成功", map);

        map.on("load", function () {
            addClickEvent();
        });
    </script>
</body>

</html>

3.3 生成4326、4490坐标系下的矢量瓦片

PostGIS使用范围的方式获取矢量瓦片,需要将XYZ瓦片矩阵转换为瓦片的范围。

3.3.1 XYZ瓦片转范围工具

Tile4326Util.java

package org.xyzgis.utils;

/**
 * @ClassName Tile4326Util
 * @Description 4326坐标系xyz行列号转换工具类
 * @Author xuyizhuo
 * @Date 2024/9/1 21:13
 */
public class Tile4326Util {
    /**
     * 将xyz行列号转换为经纬度范围
     * @param z
     * @param x
     * @param y
     * @return
     */
    public static String xyz2prjBound(int z, int x, int y) {

        double xTileCount = Math.pow(2, z + 1); // 0级别2张瓦片,1级别4张瓦片
        double yTileCount = Math.pow(2, z);

        double xMin = (x / xTileCount) * 360 - 180;
        double yMin = 90 - ((y + 1) / yTileCount) * 180;
        double xMax = ((x + 1) / xTileCount) * 360 - 180;
        double yMax = 90 - (y / yTileCount) * 180;
        return String.format("%s,%s,%s,%s", xMin, yMin, xMax, yMax);
    }
}
3.3.2 Mapper层实现
Mapper.java
package org.xyzgis.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.xyzgis.dto.VectorTile;

@Mapper
public interface VectorTileMapper extends BaseMapper<VectorTile> {
    /**
     * 获取地理坐标系矢量瓦片
     * @param dataSourceName PostGIS表名
     * @param bound 瓦片范围
     * @return
     */
    VectorTile selectGeographyTile(String dataSourceName, String bound);
}

Mapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xyzgis.mapper.VectorTileMapper">
    <!--根据范围获取矢量瓦片:支持以数据坐标返回,如4326、4490的地理坐标系-->
    <select id="selectGeographyTile" resultType="org.xyzgis.dto.VectorTile">
        WITH
            -- 计算数据的坐标系
            T_SRID AS (
                SELECT
                    -- ST_SRID (GEOM) AS SRID,
                    ST_MAKEENVELOPE (${bound}, ST_SRID (GEOM)) AS BOUNDS_GEOM
                FROM
                    "${dataSourceName}"
                WHERE
                    GEOM IS NOT NULL
                LIMIT
                    1
            ),
            -- 查询矢量瓦片
            MVTGEOM AS (
                SELECT
                    ID,
                    ST_ASMVTGEOM (
                            GEOM,
                            BOUNDS_GEOM,
                            4096
                        ) AS GEOM
                FROM
                    "${dataSourceName}", T_SRID
                WHERE
                    ST_INTERSECTS (GEOM, BOUNDS_GEOM)
            )
        SELECT
            ST_ASMVT (MVTGEOM.*, #{dataSourceName}) AS MVT
        FROM
            MVTGEOM;
    </select>
</mapper>

3.3.3 Service层实现
package org.xyzgis.service;

import org.xyzgis.utils.Tile4326Util;

/**
 * @ClassName VectorTileService
 * @Description 矢量瓦片服务
 * @Author xuyizhuo
 * @Date 2024/8/31 13:09
 */
@Service
public class VectorTileService {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private VectorTileMapper vectorTileMapper;

    /**
     * 以地理坐标的形式获取指定行列号的矢量瓦片
     *
     * @param dataSourceName
     * @param z
     * @param x
     * @param y
     * @return
     */
    public VectorTile getGeographyTile(String dataSourceName, Integer z, Integer x, Integer y) {
        logger.debug("获取地理坐标的瓦片 = {}, {}, {}, 范围==> {}", z, x, y, Tile4326Util.xyz2prjBound(z, x, y));
        return this.vectorTileMapper.selectGeographyTile(dataSourceName, Tile4326Util.xyz2prjBound(z, x, y));
    }
}
3.3.4 Controller层实现
package org.xyzgis.controller;
/**
 * @ClassName VectorTilePostGISController
 * @Description 获取矢量瓦片接口类
 * @Author xuyizhuo
 * @Date 2024/8/31 13:04
 */
@CrossOrigin
@RestController
@Api(tags = "获取矢量瓦片服务")
@RequestMapping("/services/map")
public class VectorTilePostGISController {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    @Autowired
    private VectorTileService vectorTileService;

    /**
     * 获取地理坐标系下的矢量瓦片,使用EPSG:4326投影
     */
    @GetMapping("{dataSourceName}/vector/tile/geography/{z}/{x}/{y}.pbf")
    public void getGeographyTile(@PathVariable String dataSourceName, @PathVariable Integer z,
                                 @PathVariable Integer x,
                                 @PathVariable Integer y,
                                 Integer zoomOffset,
                                 HttpServletResponse response) {
        if (dataSourceName == null || dataSourceName.isEmpty()) {
            throw new RuntimeException("数据源名称不能为空");
        }
        if (zoomOffset != null) {
            z += zoomOffset;
        }
        try {

            VectorTile vectorTile = vectorTileService.getGeographyTile(dataSourceName, z, x, y);
            logger.debug("{}\t,zoomOffset = {}, 瓦片:{}/{}/{}.pbf,length:{}", dataSourceName, zoomOffset, z, x, y,
                    vectorTile.getMvt().length);
            logger.debug("获取指定行列号的范围 = {}", Tile4326Util.xyz2prjBound(z, x, y));

            // 设置响应数据
            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");
            ServletOutputStream outputStream = response.getOutputStream();
            try {
                outputStream.write(vectorTile.getMvt());
            } catch (IOException e) {
                logger.debug("请求取消:" + e.getMessage());
            }

        } catch (Exception e) {
            // 重置response
            logger.error("获取矢量瓦片失败:" + e.getMessage());
            throw new RuntimeException("获取矢量瓦片失败", e);
        }
    }
}
3.3.5 Maplibre(Mapbox)加载示例(基于超图)

目前市场上的Maplibre(Mapbox)官方版本仅仅支持3857投影的矢量瓦片加载,如需加载4490的坐标系,需要经过改造后的。目前市场上主要是有两款改造好的:(1)cgcs2000 (2)超图定制后的Maplibre(Mapbox),超图从底层去改造的,不是插件模式,也不支持npm的安装模式。以下示例是基于超图定制后的Maplibre加载示例(可以在超图的iClient包中下载获取到):

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>加载PostGIS矢量瓦片_4326坐标系_SuperMap</title>

    <!-- 超图定制的Maplibre,下载路径:https://iclient.supermap.io/download/download.html -->
    <link href="../../lib/maplibre-gl-js-enhance/4.3.0-1/maplibre-gl-enhance.css" rel="stylesheet" />
    <script src="../../lib/maplibre-gl-js-enhance/4.3.0-1/maplibre-gl-enhance.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
        }

        html,
        body,
        #map {
            width: 100%;
            height: 100%;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script>
        // 初始化MapBox地图
        const map = new maplibregl.Map({
            container: "map",
            // 一定要设置style对象,sources取值为对象,layers取值为数组
            style: {
                version: 8,
                // 自定义字体,如果要动态设置标注,必须设置glyphs,否则无法显示文本(包含数字英文)
                // glyphs: "../lib/font/{fontstack}/{range}.pbf",
                sources: {
                    "tiandtu-raster": {
                        type: "raster",
                        tileSize: 256,
                        // 加载xyz瓦片,加载天地图经纬度瓦片
                        tiles: [
                            "https://t0.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t1.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t2.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t3.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t4.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t5.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t6.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                            "https://t7.tianditu.gov.cn/DataServer?T=img_c&x={x}&y={y}&l={z}&tk=0b018552994f71a9467d24461a8f8238",
                        ],
                    },
                    "China_province": {
                        type: 'vector',
                        minzoom: 0,
                        maxzoom: 5,
                        tiles: ["http://localhost:9761/xyzgis/services/map/China_province/vector/tile/geography/{z}/{x}/{y}.pbf?zoomOffset=-1"],
                    },
                },
                layers: [
                    {
                        id: "tiandtu",
                        type: "raster",
                        source: "tiandtu-raster",
                        minzoom: 0,
                        maxzoom: 22,
                    },
                    {
                        id: "China_province",
                        "source-layer": "China_province",
                        type: "fill",
                        source: "China_province",
                        paint: {
                            "fill-antialias": true, // 填充时是否反锯齿
                            "fill-color": "#f5f4ee", // 填充的颜色
                            "fill-outline-color": "#b7b7a1", // 描边的颜色
                            // "fill-opacity": 0.9, // 填充的不透明度
                        },
                    },
                ],
            },
            center: [103.57418476087491, 28.00749934867109],
            zoom: 3.5,
            crs: "EPSG:4326", // crs: "EPSG:4490", // 这里暂不考虑4326与4490的差别。在某一个精度下可以认为是一致的。
            // crs: maplibregl.CRS.EPSG4326
        });

        // 地图加载完成
        map.on("load", function () {
            console.log("初始化地图成功", map);
        });
    </script>
</body>

</html>

image-20240924223034174.png