第19章 - 前端地图集成实践
19.1 前端要对接的是两个端点,不是整个服务
TiTiler 是一组 FastAPI 路由,但前端真正用到的只有两类 URL:
| 类型 | 作用 | 典型路径 |
|---|---|---|
| TileJSON | 一次性拿到 bounds、缩放范围、瓦片模板 | /cog/WebMercatorQuad/tilejson.json?url=... |
| XYZ 瓦片 | 按 z/x/y 取一张图片 | /cog/tiles/WebMercatorQuad/{z}/{x}/{y} |
其余端点(/info、/statistics、/point、/assets、/tiles、/tiles/{tileMatrixSetId}、/{tileMatrixSetId}/map.html)用于元数据、取值、调试和内置查看器,前端地图渲染不依赖它们。
TiTiler 演示应用的挂载前缀在 titiler/application/main.py 里写死:
| 前缀 | reader | 用途 |
|---|---|---|
/cog |
rio_tiler.io.Reader |
单个 COG 文件 |
/stac |
rio_tiler.io.STACReader |
一个 STAC Item(多资产) |
/mosaicjson |
cogeo_mosaic |
MosaicJSON 镶嵌 |
/zarr |
xarray | Zarr/NetCDF 多维数据 |
自己用 TilerFactory 组装应用时,前缀由 router_prefix 与 app.include_router(prefix=...) 决定,所以下面示例里的 /cog 要替换成你实际部署的路径。
19.2 先用 TileJSON 打通一次
在写前端代码前,先用 curl 确认服务端的出参:
curl -s "https://titiler.example.com/cog/WebMercatorQuad/tilejson.json?url=s3://bucket/scene.tif&tilesize=256" | jq
返回结构(源码 factory.py 的 tilejson 方法):
{
"bounds": [116.28, 39.82, 116.52, 39.98],
"minzoom": 8,
"maxzoom": 18,
"tiles": [
"https://titiler.example.com/cog/tiles/WebMercatorQuad/{z}/{x}/{y}?url=s3%3A%2F%2Fbucket%2Fscene.tif&tilesize=256"
],
"attribution": null,
"raster_layers": {},
"band_descriptions": [["1", "red"], ["2", "green"], ["3", "blue"]],
"data_type": "uint8",
"minmax": [[0, 255], [0, 255], [0, 255]]
}
四个关键点:
bounds已经是经纬度(get_geographic_bounds按 TMS 的地理 CRS 换算),可以直接喂给 MapLibre。minzoom/maxzoom来自数据源自身的分辨率(src_dst.minzoom/maxzoom),也可以用查询参数覆盖。tiles里已经带上了原始查询参数(url、tilesize等),前端只需要把{z}/{x}/{y}占位符留给 MapLibre。attribution取环境变量TITILER_DEFAULT_ATTRIBUTION,没设就是null。合规信息要在这里给出来。
19.3 MapLibre GL JS 接入:两种方式
19.3.1 让 MapLibre 自己读 TileJSON
MapLibre 的 raster source 支持 url 指向 TileJSON,这是代码最少的方式:
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const TITILER = 'https://titiler.example.com';
const COG_URL = 's3://bucket/scene.tif';
const tilejsonUrl =
`${TITILER}/cog/WebMercatorQuad/tilejson.json?url=${encodeURIComponent(COG_URL)}&tilesize=256`;
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
sources: {
ortho: {
type: 'raster',
url: tilejsonUrl, // 由 MapLibre 拉取 TileJSON 并套用 bounds/minzoom/maxzoom
tileSize: 256 // 必须与 tilesize 查询参数一致
}
},
layers: [
{id: 'bg', type: 'background', paint: {'background-color': '#0b0f14'}},
{id: 'ortho', type: 'raster', source: 'ortho', paint: {'raster-opacity': 1}}
]
},
center: [116.39, 39.9],
zoom: 11
});
tileSize 必须与请求里的 tilesize 一致。写 256 而服务端返回 512 的图,会被压缩成一半显示,地面分辨率对不上;反过来会被放大、发虚。默认值有两处不同:tile 端点的 tilesize 缺省为 None,实际取 TMS 矩阵的 tileWidth × tileHeight(WebMercatorQuad 是 256);而 tilejson.json 端点的 tilesize 默认是 512,并会把这个值写进 tiles 模板。所以走 tilejson 时以返回值为准,自己直接拼瓦片 URL 时按 TMS 的 256 算。
19.3.2 手动映射字段
需要自己控制 minzoom/maxzoom、加鉴权头或做多源切换时,手动拉 TileJSON 再构造 source:
async function addCogLayer(map, {titiler, cogUrl, layerId, tilesize = 256, headers = {}}) {
const tilejsonUrl =
`${titiler}/cog/WebMercatorQuad/tilejson.json?url=${encodeURIComponent(cogUrl)}&tilesize=${tilesize}`;
const res = await fetch(tilejsonUrl, {headers});
if (!res.ok) throw new Error(`TileJSON 请求失败:${res.status}`);
const tj = await res.json();
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(layerId)) map.removeSource(layerId);
map.addSource(layerId, {
type: 'raster',
tiles: tj.tiles,
tileSize: tilesize,
bounds: tj.bounds,
minzoom: tj.minzoom,
maxzoom: Math.min(tj.maxzoom, 20),
attribution: tj.attribution || ''
});
map.addLayer({
id: layerId,
type: 'raster',
source: layerId,
paint: {
'raster-opacity': 1,
'raster-fade-duration': 150
}
});
return tj;
}
两种方式的差别:url 方式把 TileJSON 的拉取交给 MapLibre,省事但难以插入鉴权头;手动方式能带 Authorization、能改 bounds、能在加载前做校验,生产项目用后者。
19.4 时间序列:按时间换瓦片图层
影像业务的核心交互是”时间轴拖动,画面跟着变”。不要为每个时相建 permanent 图层,正确做法是保持一个图层、换掉 source 的 tiles:
const scenes = [
{id: 'S2_20240101', url: 's3://bucket/S2_20240101.tif', time: '2024-01-01'},
{id: 'S2_20240201', url: 's3://bucket/S2_20240201.tif', time: '2024-02-01'},
{id: 'S2_20240301', url: 's3://bucket/S2_20240301.tif', time: '2024-03-01'}
];
let current = null;
async function showScene(scene) {
const tj = await fetch(
`https://titiler.example.com/cog/WebMercatorQuad/tilejson.json?url=${encodeURIComponent(scene.url)}&tilesize=256`
).then((r) => r.json());
const source = map.getSource('scene-layer');
if (!source) {
map.addSource('scene-layer', {
type: 'raster', tiles: tj.tiles, tileSize: 256,
bounds: tj.bounds, minzoom: tj.minzoom, maxzoom: tj.maxzoom
});
map.addLayer({id: 'scene', type: 'raster', source: 'scene-layer'});
} else {
// 直接改 tiles 模板,避免删层重建
source.setTiles(tj.tiles);
source.setBounds(tj.bounds);
}
current = scene;
document.getElementById('scene-time').textContent = scene.time;
}
// 时间轴拖动时加防抖,避免快速拖动触发大量请求
let timer = null;
document.getElementById('timeline').addEventListener('input', (e) => {
const index = Number(e.target.value);
clearTimeout(timer);
timer = setTimeout(() => showScene(scenes[index]), 150);
});
showScene(scenes[0]);
关键在 setTiles 与 setBounds:它们会让 source 重新请求当前视口的瓦片,但不销毁 source、不重建图层,所以 map.on('sourcedata') 之类的监听、图层顺序、paint 属性全部保留。如果换成 removeSource + addSource,每次切换都要重建图层,闪一下白。
时相预加载可以用 fetch 提前拿 TileJSON(很小,几 KB),瓦片留给地图按需请求。不要预加载瓦片本身——一张影像的瓦片数量按缩放级别指数增长。
19.5 渲染参数放在服务端
一个常见误区是在前端叠加一层 CSS 滤镜做”增强”,或者下载像素后再处理。TiTiler 的设计是把渲染参数作为查询参数传给服务端,瓦片回来就是成品:
| 需求 | TiTiler 参数 | 说明 |
|---|---|---|
| 波段拉伸 | rescale=0,3000 |
每波段一组,可多组 |
| 伪彩色 | colormap_name=viridis |
服务端内置色带 |
| 自定义色带 | colormap={"0":[0,0,0,0],"255":[255,0,0,255]} |
URL 编码的 JSON |
| 波段组合 | bidx=4,3,2 |
1-based 波段序号 |
| 波段运算 | expression=b4/b3 |
rio-tiler 的 band math |
| 输出格式 | .png / .jpg / .webp |
路径后缀或 format 参数 |
// 服务端做拉伸与色带,前端只负责显示
const params = new URLSearchParams({
url: 's3://bucket/dem.tif',
rescale: '0,3000',
colormap_name: 'terrain',
tilesize: '256'
});
map.addSource('dem', {
type: 'raster',
tiles: [`https://titiler.example.com/cog/tiles/WebMercatorQuad/{z}/{x}/{y}?${params}`],
tileSize: 256
});
这样做的收益很直接:不同客户端看到的颜色一致、不需要前端引入色彩库、切缓存时缓存键天然包含参数(URL 就是键)。代价是每个参数组合都是一份独立的缓存,参数组合不要发散。
还有一条来自 TiTiler 实现的细节:不指定格式时,服务端按”掩膜是否全为真”自动选格式——全有效用 JPEG(小、无透明),有无效区用 PNG(保留透明)。前端拿到的是 image/jpeg 或 image/png,不要假设固定。
19.6 CORS 与鉴权头
TiTiler 演示应用的 CORS 来自 ApiSettings,默认值写在 settings.py:
| 配置 | 默认值 | 说明 |
|---|---|---|
cors_origins |
"*" |
逗号分隔的允许来源 |
cors_allow_methods |
"GET" |
逗号分隔的方法 |
cachecontrol |
"public, max-age=3600" |
响应缓存头 |
global_access_token |
未设 | 设了之后所有端点要求 ?access_token= |
环境变量前缀是 TITILER_API_,例如 TITILER_API_CORS_ORIGINS=https://app.example.com。中间件在 main.py 里注册,allow_credentials=True、allow_headers=["*"]。
前端带凭证请求时要注意两条:
- 服务端
allow_credentials=True时,allow_origins不能是*,必须是显式来源,否则浏览器会拒绝。 - 带自定义头(如
Authorization)会触发预检OPTIONS,而cors_allow_methods默认只有GET,预检通过但实际请求仍被拒。要么把方法补成GET,OPTIONS,要么改用查询参数传 token。
// 方式一:走服务端内置的 access_token 查询参数(配置 TITILER_API_GLOBAL_ACCESS_TOKEN)
const token = 'signed-token-or-api-key';
const tiles = [`https://titiler.example.com/cog/tiles/WebMercatorQuad/{z}/{x}/{y}`];
const source = {
type: 'raster',
tiles: tiles.map((t) => `${t}?url=${encodeURIComponent(cogUrl)}&access_token=${token}`),
tileSize: 256
};
// 方式二:加自定义头需要使用 transformRequest(MapLibre 在请求前改写)
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
transformRequest: (url, resourceType) => {
if (resourceType === 'Tile' && url.startsWith('https://titiler.example.com')) {
return {url, headers: {Authorization: `Bearer ${token}`}};
}
}
});
MapLibre 的 transformRequest 是统一注入请求头的入口,能覆盖瓦片、sprite、glyphs 等所有资源类型,比在每个 URL 上拼参更可靠。
19.7 从 STAC Item 出发
真实业务里前端通常先拿到一个 STAC Item(或从 stac-fastapi 检索得到),再把它的资产交给 TiTiler 的 /stac 端点:
// item 来自 stac-fastapi 的 /search 结果
const item = await fetch(
'https://stac.example.com/collections/sentinel-2-l2a/items/S2B_20240101'
).then((r) => r.json());
// /stac 端点接受 item 的 URL,多资产通过 assets 筛选
const itemHref = item.links.find((l) => l.rel === 'self').href;
const params = new URLSearchParams({
url: itemHref,
assets: 'red,green,blue',
rescale: '0,3000',
tilesize: '256'
});
map.addSource('stac-scene', {
type: 'raster',
tiles: [`https://titiler.example.com/stac/tiles/WebMercatorQuad/{z}/{x}/{y}?${params}`],
tileSize: 256
});
/stac 用 STACReader,所以 assets 参数是资产键名(逗号分隔),bidx/expression 在选中资产后按顺序处理。前端如果只拿 Item 的 JSON,不需要下载影像,TiTiler 会按需读取 Range。
STAC Item 的 bbox 可以直接作为 source 的 bounds,避免多一次 TileJSON 请求:
map.addSource('stac-scene', {
type: 'raster',
tiles: [tilesUrl],
tileSize: 256,
bounds: item.bbox // [west, south, east, north]
});
注意 bbox 有可能是三维的(六个值,含高程上下限),取前四个。
19.8 性能与体验建议
tileSize 256 与 512 的取舍。 512 的瓦片单张覆盖 4 倍面积、请求数少 4 倍,但单张渲染成本高、首屏等待更久;256 更细粒度、快速平移时响应快。影像类底图建议 256,静态大图预览可用 512。改 tileSize 时务必同步改服务端的 tilesize。
限制 maxzoom。 TileJSON 给出的 maxzoom 是数据本身的可用层级,可能高达 22。前端把 source 的 maxzoom 压到业务实际需要的层级(例如 18),高缩放时地图用既有的低层瓦片放大,而不是继续请求新瓦片。
给瓦片加 fade。 raster-fade-duration 默认 300 ms,动态渲染场景(每次请求都要读 COG)建议降到 100–150 ms,否则切换时相会看到明显的交叉淡入。
避免整屏同时请求。 动态瓦片每次都是实时读文件+重采样,一屏 20 张瓦片可能压满后端。可以调小 MapOptions.maxTileCacheZoomLevels 或改用 CDN 缓存(第 17 章)。
用 error 事件兜底。 瓦片 4xx/5xx 在 MapLibre 里不会抛异常,只会走 map.on('error')。做一个”瓦片失败提示”比让用户看白块好。
map.on('error', (e) => {
const status = e.error?.status;
if (status) {
console.warn('瓦片请求失败', status, e.error.url);
}
});
19.9 排错清单
地图上永远是灰底,没有影像。 先直接浏览器打开 TileJSON URL 看返回;再打开一张瓦片 URL 看图片。两步都正常,问题在前端 source 配置(tiles 模板里 {z}/{x}/{y} 被 URL 编码、tileSize 不匹配、bounds 写反)。
瓦片错位、拼接有缝。 bounds 与数据实际范围不一致,或者 tileSize 与 tilesize 参数不一致。TiTiler 返回的 bounds 是数据范围,不要手改成”更好看”的范围。
CORS 报错 No 'Access-Control-Allow-Origin' header。 检查 TITILER_API_CORS_ORIGINS 是否包含前端域名(带协议与端口),以及是否走了网关/Ingress —— 有些网关会覆盖 CORS 头。
带 token 后全部 401。 global_access_token 与前端传参名必须一致(默认是 access_token),并且注意 query 参数在 TileJSON 里会被透传到 tiles URL。
切换时相时闪白。 用了 removeSource/addSource 而不是 setTiles。另外新瓦片未加载完成就清了旧图层,可以用两张图层做交叉淡入。
高缩放级别非常慢。 数据的 maxzoom 被用满了,每级都在实时读全分辨率数据。把前端 source 的 maxzoom 限制在合理值,或者为 COG 补 overview(第 5 章)。
标注/路网被影像盖住。 图层顺序问题:raster 图层要插在矢量底图之上、标注之下用 before(第 7 章)。
19.10 前端集成的检查表
| 项 | 检查内容 |
|---|---|
| TileJSON | bounds、minzoom、maxzoom、tiles 可直接用;attribution 需服务端配 TITILER_DEFAULT_ATTRIBUTION |
| tileSize | 前端 tileSize 必须等于请求参数 tilesize,默认 512 |
| 渲染参数 | rescale、colormap、bidx、expression 全部放服务端,别在前端二次处理 |
| 鉴权 | 查询参数 access_token(内置)或 transformRequest 注入头 |
| CORS | cors_origins 显式配置;带凭证时不能用 *;预检方法要含 OPTIONS |
| 时间序列 | 用 source.setTiles() 换数据,不删层重建 |
| 性能 | tileSize 256/512 按场景选;压 maxzoom;调 raster-fade-duration |
| 错误 | 监听 map.on('error'),对 e.error.status 分类处理 |