跳转至

使用 PostGIS 生成矢量瓦片

文章背景与核心概要

本指南详细介绍了如何利用 PostGIS 从 Postgres 数据库中直接编程生成 Mapbox 矢量瓦片(Mvt),并结合 MapLibre GLSupabase JS 在网页端实现高效渲染。通过引入 Overture Maps Foundation 的开放地图数据,你将学会如何下载地理数据集、将其导入 Supabase、进行坐标转换,并在按需获取丰富元数据的同时,实时提供矢量瓦片服务。

这种方法充分发挥了空间数据库的强大计算能力,显著减少了前端的网络传输负载,为构建高性能、可交互的地理空间应用开辟了新途径。


目录


[!NOTE] - 喜欢视听学习?观看视频指南! - 或者直接通过 GitHub 查看代码。

Overture Maps Foundation 是由亚马逊、Meta、微软和 TomTom 联合发起的项目,旨在创建可靠、易于使用且可互操作的开放地图数据。

借助 Overture Maps,我们可以将开放地图数据(例如兴趣点)下载为 GeoJSON 格式,将其转换为 SQL,并导入到 Supabase 上的 Postgres 数据库中。然后,通过 PostGIS,我们可以编程生成矢量瓦片,并通过 supabase-js 将其提供给 MapLibre GL 客户端。

[!NOTE] 什么是矢量瓦片? 矢量瓦片是将地理数据打包成预定义、近似正方形的“瓦片”数据包,以便在网络上传输。客户端请求地图数据时,只需请求对应特定大小和位置的正方形区域的一组瓦片。

特别是对于大型数据集,这极大地减少了数据传输量,因为只需获取当前视口和当前缩放级别内的数据。

你将学到什么

  • 使用 Overture Maps 下载 GeoJSON 格式的开放地图位置数据。
  • 使用 GDAL (ogr2ogr) 将 GeoJSON 转换为 SQL 语句。
  • 使用 psql 将位置数据和 JSON 元数据导入 Supabase Postgres 数据库。
  • 使用 PostGIS 的 ST_AsMVT 将对应瓦片图层的一组行聚合成二进制矢量瓦片表示。
  • 使用 MapLibre 的 addProtocol 通过 supabase-js 发起远程过程调用(RPC),可视化大型 PostGIS 表。
  • 使用 supabase-js 按需获取额外的 JSON 元数据。

使用 Overture Maps 下载开放地图数据

Overture Maps 提供了一个 Python 命令行工具,用于下载感兴趣区域内的数据并将其转换为几种常见的地理空间文件格式。

运行以下命令将新加坡的地点下载到 GeoJSON 文件中:

overturemaps download --bbox=103.570233,1.125077,104.115855,1.490957 -f geojson --type=place -o places.geojson

(注意:根据边界框的大小,此下载可能需要一些时间。)


将 GeoJSON 转换为 SQL

接下来,使用 GDAL ogr2ogr 将 GeoJSON 文件转换为与 PostGIS 兼容的 SQL 文件。

你可以通过 Homebrew 安装 GDALbrew install gdal)或按照官方下载说明进行安装。

PG_USE_COPY=true ogr2ogr -f pgdump places.sql places.geojson

将位置数据导入 Supabase

在 Supabase 数据库中专门的 gis 架构(schema)内启用 PostGIS 扩展。你可以通过导航到 SQL 编辑器 并运行以下脚本来完成此操作,也可以从 数据库扩展设置 中管理扩展。

建议: 由于 PostGIS 的计算开销较大,我们建议在专用的架构(例如命名为 gis)中启用它。

CREATE SCHEMA IF NOT EXISTS "gis";
CREATE EXTENSION IF NOT EXISTS "postgis" WITH SCHEMA "gis";

通过命令行将开放地图数据导入 Supabase 中的 places 表:

psql -h aws-0-us-west-1.pooler.supabase.com -p 5432 -d postgres -U postgres.project-ref < places.sql

(你可以在 Supabase 仪表板的项目连接页面上找到你的连接凭据。)

启用 RLS 并创建公共读取策略

为了使地点数据可供公开查询,请配置行级安全性(RLS)策略以启用公共读取访问权限。在 Supabase SQL 编辑器 中运行以下代码:

ALTER TABLE "public"."places" ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Enable read access for all users" 
ON "public"."places" 
FOR SELECT 
USING (true);

使用 PostGIS 生成矢量瓦片

为了在客户端请求时编程生成矢量瓦片,需要创建一个可以通过远程过程调用 (RPC) 调用的 Postgres 函数。

在你的 SQL 编辑器中运行此脚本:

CREATE OR REPLACE FUNCTION mvt(z integer, x integer, y integer)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
    mvt_output text;
BEGIN
    WITH
    -- Define the bounds of the tile using the provided Z, X, Y coordinates
    bounds AS (
        SELECT ST_TileEnvelope(z, x, y) AS geom
    ),
    -- Transform geometries from EPSG:4326 to EPSG:3857 and clip to bounds
    mvtgeom AS (
        SELECT
            -- Include name and ID only at zoom > 13 to keep low-zoom tiles lightweight
            CASE
            WHEN z > 13 THEN id
            ELSE NULL
            END AS id,
            CASE
            WHEN z > 13 THEN names::json->>'primary'
            ELSE NULL
            END AS primary_name,
            categories::json->>'main' as main_category,
            ST_AsMVTGeom(
                ST_Transform(wkb_geometry, 3857), -- Transform to Web Mercator
                bounds.geom,
                4096, -- Tile extent in pixels (commonly 256 or 4096)
                0,    -- Buffer around the tile in pixels
                true  -- Clip geometries to the tile extent
            ) AS geom
        FROM
            places, bounds
        WHERE
            ST_Intersects(ST_Transform(wkb_geometry, 3857), bounds.geom)
    )
    -- Generate the MVT from the clipped geometries and encode as base64
    SELECT INTO mvt_output encode(ST_AsMVT(mvtgeom, 'places', 4096, 'geom'), 'base64')
    FROM mvtgeom;

    RETURN mvt_output;
END;
$$;

使用 supabase-js 从 MapLibre GL 客户端获取矢量瓦片

你可以在 GitHub 上找到 index.html 的完整源代码。以下是如何在 MapLibre GL 中注册自定义协议,以便通过 supabase-js 获取 base64 编码的二进制矢量瓦片:

const client = supabase.createClient('your-supabase-api-url', 'your-supabase-anon-key')

function base64ToArrayBuffer(base64) {
  var binaryString = atob(base64)
  var bytes = new Uint8Array(binaryString.length)
  for (var i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i)
  }
  return bytes
}

maplibregl.addProtocol('supabase', async (params, abortController) => {
  const re = new RegExp(/supabase:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/)
  const result = params.url.match(re)
  const { data, error } = await client.rpc('mvt', {
    z: result[2],
    x: result[3],
    y: result[4],
  })
  const encoded = base64ToArrayBuffer(data)
  if (!error) {
    return { data: encoded }
  } else {
    throw new Error(`Tile fetch error`)
  }
})

注册了自定义协议后,将其实际添加到 MapLibre GL 的数据源(sources)中——例如,叠加在来自 Protomaps 的底图之上:

const map = new maplibregl.Map({
  hash: true,
  container: 'map',
  style: {
    version: 8,
    glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
    sources: {
      supabase: {
        type: 'vector',
        tiles: ['supabase://boston/{z}/{x}/{y}'],
        attribution: '© <a href="https://overturemaps.org">Overture Maps Foundation</a>',
      },
      protomaps: {
        type: 'vector',
        url: 'https://api.protomaps.com/tiles/v3.json?key=your-protomaps-api-key',
        attribution: 'Basemap © <a href="https://openstreetmap.org">OpenStreetMap</a>',
      },
    },
  },
})

按需获取额外的 JSON 元数据

为了减小网络负载大小,我们避免将繁重的元数据直接嵌入到矢量瓦片中。相反,我们配置了一个 onclick 处理程序,当用户打开 MapLibre GL 弹出窗口(popup)时异步获取额外的元数据:

const popup = new maplibregl.Popup({
  closeButton: true,
  closeOnClick: false,
  maxWidth: 'none',
})

function loadDetails(element, id) {
  element.innerHTML = 'loading...'
  client
    .from('places')
    .select(`
          websites,
          socials,
          phones,
          addresses,
          source: sources->0->dataset
        `)
    .eq('id', id)
    .single()
    .then(({ data, error }) => {
      if (error) return console.error(error)
      element.parentElement.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`
    })
}

map.on('click', 'overture-pois-text', async (e) => {
  if (e.features.length > 0) {
    const feature = e.features[0]
    popup.setHTML(`
      <table style="font-size:12px">
          <tr>
              <td>id:</td>
              <td>${feature.properties.id}</td>
          </tr>
          <tr>
              <td>name:</td>
              <td>${feature.properties.primary_name}</td>
          </tr>
          <tr>
              <td>main_category:</td>
              <td>${feature.properties.main_category}</td>
          </tr>
          <tr>
              <td>details:</td>
              <td>
                <span onclick="loadDetails(this, '${feature.properties.id}')">
                  load details
                </span>
              </td>
          </tr>
      </table>
    `)
    popup.setLngLat(e.lngLat)
    popup.addTo(map)
  }
})

总结

PostGIS 异常强大,它使你能够直接从存储在 Postgres 中的表行编程生成矢量瓦片。结合 Supabase 自动生成的 API 以及 supabase-js 客户端库,构建高性能、可交互的地理空间应用程序从未如此简单。


更多 Supabase 资源