PostGIS 完全指南 / 第 16 章:前端地图集成
第 16 章:前端地图集成
16.1 Web GIS 架构
典型的 PostGIS + Web 地图架构:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ 前端地图 │────▶│ 后端 API │────▶│ PostGIS │
│ (Leaflet) │◀────│ (FastAPI等) │◀────│ (PostgreSQL) │
└─────────────┘ └──────────────┘ └──────────────┘
│ │
│ GeoJSON/MVT
│
┌──────────────┐
│ 底图瓦片服务 │
│ (OSM/高德/Mapbox)
└──────────────┘
数据流
| 阶段 | 数据格式 | 说明 |
|---|
| 数据库 → API | SQL → JSONB | PostGIS 查询并转换 |
| API → 前端 | GeoJSON / MVT | REST API 返回 |
| 前端渲染 | GeoJSON / 矢量瓦片 | Leaflet/Mapbox 渲染 |
16.2 Leaflet 基础集成
最小化示例
<!DOCTYPE html>
<html>
<head>
<title>PostGIS + Leaflet</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
#map { height: 600px; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
// 初始化地图
const map = L.map('map').setView([39.9042, 116.4074], 12);
// 添加底图(OpenStreetMap)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map);
// 从 API 加载 GeoJSON 数据
fetch('/api/cities')
.then(res => res.json())
.then(data => {
L.geoJSON(data, {
pointToLayer: (feature, latlng) => {
return L.circleMarker(latlng, {
radius: Math.sqrt(feature.properties.population / 1000000),
fillColor: '#ff7800',
color: '#000',
weight: 1,
opacity: 1,
fillOpacity: 0.8
});
},
onEachFeature: (feature, layer) => {
layer.bindPopup(`
<h3>${feature.properties.name}</h3>
<p>省份: ${feature.properties.province}</p>
<p>人口: ${feature.properties.population.toLocaleString()}</p>
`);
}
}).addTo(map);
});
</script>
</body>
</html>
16.3 后端 API (Python FastAPI)
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
import psycopg2
import psycopg2.extras
import json
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def get_db():
return psycopg2.connect(
dbname="gisdb", user="gisadmin",
password="SecurePass123", host="localhost"
)
@app.get("/api/cities")
def get_cities(
lng: float = Query(None),
lat: float = Query(None),
radius: int = Query(50000),
limit: int = Query(100)
):
conn = get_db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if lng and lat:
cur.execute("""
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', jsonb_agg(
jsonb_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(geom)::jsonb,
'properties', jsonb_build_object(
'id', id, 'name', name,
'province', province, 'population', population
)
)
)
) AS geojson
FROM (
SELECT * FROM cities
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography,
%s
)
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(%s, %s), 4326)
LIMIT %s
) sub
""", (lng, lat, radius, lng, lat, limit))
else:
cur.execute("""
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', jsonb_agg(
jsonb_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(geom)::jsonb,
'properties', jsonb_build_object(
'id', id, 'name', name,
'province', province, 'population', population
)
)
)
) AS geojson
FROM cities
""")
result = cur.fetchone()['geojson']
cur.close()
conn.close()
return result
@app.get("/api/nearby")
def nearby(
lng: float,
lat: float,
radius: int = Query(3000),
category: str = Query(None),
limit: int = Query(20)
):
conn = get_db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', jsonb_agg(feature)
)
FROM (
SELECT jsonb_build_object(
'type', 'Feature',
'id', id,
'geometry', ST_AsGeoJSON(geom)::jsonb,
'properties', jsonb_build_object(
'name', name, 'category', category,
'address', address,
'distance_m', ROUND(ST_Distance(
geom::geography,
ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography
))
)
) AS feature
FROM pois
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography,
%s
)
AND (%s IS NULL OR category = %s)
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(%s, %s), 4326)
LIMIT %s
) sub
""", (lng, lat, lng, lat, radius, category, category, lng, lat, limit))
result = cur.fetchone()['geojson']
cur.close()
conn.close()
return result
16.4 矢量瓦片 (MVT) 集成
后端瓦片服务
from fastapi import FastAPI, Response
from fastapi.responses import Response
@app.get("/tiles/{z}/{x}/{y}.pbf")
def mvt_tile(z: int, x: int, y: int):
conn = get_db()
cur = conn.cursor()
cur.execute("""
WITH bounds AS (
SELECT ST_TileEnvelope(%s, %s, %s) AS geom
)
SELECT ST_AsMVT(tile, 'pois', 4096, 'mvt_geom')
FROM (
SELECT
name, category,
ST_AsMVTGeom(
geom, bounds.geom,
4096, 256, true
) AS mvt_geom
FROM pois, bounds
WHERE geom && bounds.geom
) AS tile
""", (z, x, y))
tile_data = cur.fetchone()[0]
cur.close()
conn.close()
return Response(
content=tile_data,
media_type="application/x-protobuf"
)
前端使用矢量瓦片
// 使用 Mapbox GL JS 加载矢量瓦片
const map = new mapboxgl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [116.4074, 39.9042],
zoom: 12
});
map.on('load', () => {
map.addSource('pois', {
type: 'vector',
tiles: ['http://localhost:8000/tiles/{z}/{x}/{y}.pbf'],
maxzoom: 16
});
map.addLayer({
id: 'poi-circles',
type: 'circle',
source: 'pois',
'source-layer': 'pois',
paint: {
'circle-radius': 6,
'circle-color': '#ff7800',
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
map.addLayer({
id: 'poi-labels',
type: 'symbol',
source: 'pois',
'source-layer': 'pois',
layout: {
'text-field': ['get', 'name'],
'text-size': 12,
'text-offset': [0, 1.5]
}
});
});
Leaflet + 矢量瓦片
// 使用 leaflet-vector-tile 插件
import { VectorTile } from '@mapbox/vector-tile';
import Protobuf from 'pbf';
const vectorTileLayer = L.vectorGrid.protobuf(
'http://localhost:8000/tiles/{z}/{x}/{y}.pbf',
{
vectorTileLayerStyles: {
pois: {
radius: 5,
fillColor: '#ff7800',
color: '#000',
weight: 1,
fillOpacity: 0.8
}
},
getFeatureId: (feature) => feature.properties.id,
interactive: true
}
).addTo(map);
vectorTileLayer.on('click', (e) => {
L.popup()
.setLatLng(e.latlng)
.setContent(`<b>${e.layer.properties.name}</b>`)
.openOn(map);
});
16.5 Mapbox GL JS 集成
3D 建筑可视化
// Mapbox GL JS 3D 建筑渲染
map.on('load', () => {
// 添加建筑物数据源
map.addSource('buildings', {
type: 'geojson',
data: '/api/buildings'
});
// 挤出 2D 多边形为 3D 建筑
map.addLayer({
id: 'buildings-3d',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-color': [
'interpolate', ['linear'], ['get', 'height'],
0, '#ffffcc',
100, '#fd8d3c',
300, '#e31a1c'
],
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.8
}
});
});
热力图
map.addSource('events', {
type: 'geojson',
data: '/api/events'
});
map.addLayer({
id: 'events-heatmap',
type: 'heatmap',
source: 'events',
paint: {
'heatmap-weight': ['interpolate', ['linear'], ['get', 'weight'], 0, 0, 1, 1],
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3],
'heatmap-color': [
'interpolate', ['linear'], ['heatmap-density'],
0, 'rgba(33,102,172,0)',
0.2, 'rgb(103,169,207)',
0.4, 'rgb(209,229,240)',
0.6, 'rgb(253,219,199)',
0.8, 'rgb(239,138,98)',
1, 'rgb(178,24,43)'
],
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20],
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 13, 1, 15, 0]
}
});
16.6 实时位置更新
WebSocket 实时推送
# 后端 WebSocket (FastAPI)
from fastapi import FastAPI, WebSocket
import asyncio
@app.websocket("/ws/vehicles")
async def vehicle_tracking(websocket: WebSocket):
await websocket.accept()
try:
while True:
conn = get_db()
cur = conn.cursor()
cur.execute("""
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', jsonb_agg(
jsonb_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(geom)::jsonb,
'properties', jsonb_build_object(
'vehicle_id', vehicle_id,
'speed', speed,
'updated_at', updated_at
)
)
)
)
FROM vehicle_locations
WHERE updated_at > now() - INTERVAL '1 minute'
""")
data = cur.fetchone()[0]
cur.close()
conn.close()
await websocket.send_json(data)
await asyncio.sleep(5) # 每 5 秒更新
except Exception:
pass
// 前端实时更新
const ws = new WebSocket('ws://localhost:8000/ws/vehicles');
const markers = {};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
data.features.forEach(feature => {
const id = feature.properties.vehicle_id;
const coords = feature.geometry.coordinates;
const latlng = [coords[1], coords[0]];
if (markers[id]) {
markers[id].setLatLng(latlng);
} else {
markers[id] = L.marker(latlng)
.bindPopup(`车辆 ${id}`)
.addTo(map);
}
});
};
16.7 地图交互
点击查询
// 点击地图查询最近的 POI
map.on('click', async (e) => {
const { lat, lng } = e.latlng;
const response = await fetch(
`/api/nearby?lng=${lng}&lat=${lat}&radius=1000&limit=5`
);
const data = await response.json();
if (data.features.length > 0) {
L.popup()
.setLatLng(e.latlng)
.setContent(`
<h4>附近 POI</h4>
<ul>
${data.features.map(f =>
`<li>${f.properties.name} (${f.properties.distance_m}m)</li>`
).join('')}
</ul>
`)
.openOn(map);
}
});
框选查询
// 框选区域查询
const drawControl = new L.Draw.Rectangle(map);
drawControl.enable();
map.on(L.Draw.Event.CREATED, async (e) => {
const bounds = e.layer.getBounds();
const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
const response = await fetch(`/api/pois?bbox=${bbox}`);
const data = await response.json();
L.geoJSON(data, {
onEachFeature: (feature, layer) => {
layer.bindPopup(feature.properties.name);
}
}).addTo(map);
});
16.8 底图选择
| 底图 | URL | 特点 |
|---|
| OpenStreetMap | https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png | 免费、全球覆盖 |
| 高德地图 | https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z} | 中文、国内快 |
| 天地图 | 需申请 API Key | 国家标准 |
| CartoDB | https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png | 简洁美观 |
| Mapbox | 需 API Key | 美观、可定制 |
// 多底图切换
const baseMaps = {
"OSM": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'),
"高德": L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {subdomains: '1234'}),
"CartoDB Light": L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png')
};
L.control.layers(baseMaps).addTo(map);
16.9 性能优化
| 优化策略 | 说明 | 适用场景 |
|---|
| 矢量瓦片 | 服务端生成 MVT | 大量要素、频繁交互 |
| 数据简化 | 后端 ST_Simplify | 缩小级别时减少数据量 |
| 分级加载 | 不同缩放级别加载不同数据 | 点位密集区域 |
| 边界框过滤 | 仅加载视口内数据 | 移动端、大数据量 |
| 缓存 | CDN/Redis 缓存瓦片和 API | 高并发场景 |
// 视口过滤
map.on('moveend', async () => {
const bounds = map.getBounds();
const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
const zoom = map.getZoom();
const response = await fetch(`/api/pois?bbox=${bbox}&zoom=${zoom}`);
const data = await response.json();
// 更新图层数据
poiLayer.clearLayers();
poiLayer.addData(data);
});
16.10 本章小结
| 要点 | 说明 |
|---|
| Leaflet | 轻量级、开源、插件丰富 |
| Mapbox GL JS | 3D 支持、矢量瓦片、美观 |
| GeoJSON | Web API 的标准数据格式 |
| 矢量瓦片 (MVT) | 大数据量场景的最佳方案 |
| 后端 API | FastAPI + psycopg2 + PostGIS |
| 实时更新 | WebSocket 推送位置数据 |
扩展阅读