强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 16 章:前端地图集成

第 16 章:前端地图集成

16.1 Web GIS 架构

典型的 PostGIS + Web 地图架构:

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│   前端地图    │────▶│   后端 API    │────▶│   PostGIS    │
│  (Leaflet)   │◀────│  (FastAPI等)  │◀────│ (PostgreSQL) │
└─────────────┘     └──────────────┘     └──────────────┘
       │                    │
       │              GeoJSON/MVT
       │
┌──────────────┐
│  底图瓦片服务  │
│ (OSM/高德/Mapbox)
└──────────────┘

数据流

阶段数据格式说明
数据库 → APISQL → JSONBPostGIS 查询并转换
API → 前端GeoJSON / MVTREST 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/[email protected]/dist/leaflet.css" />
    <script src="https://unpkg.com/[email protected]/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特点
OpenStreetMaphttps://{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国家标准
CartoDBhttps://{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 JS3D 支持、矢量瓦片、美观
GeoJSONWeb API 的标准数据格式
矢量瓦片 (MVT)大数据量场景的最佳方案
后端 APIFastAPI + psycopg2 + PostGIS
实时更新WebSocket 推送位置数据

扩展阅读