第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]]
}

四个关键点:

  1. bounds 已经是经纬度(get_geographic_bounds 按 TMS 的地理 CRS 换算),可以直接喂给 MapLibre。
  2. minzoom/maxzoom 来自数据源自身的分辨率(src_dst.minzoom / maxzoom),也可以用查询参数覆盖。
  3. tiles 里已经带上了原始查询参数(url、tilesize 等),前端只需要把 {z}/{x}/{y} 占位符留给 MapLibre。
  4. 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=["*"]。

前端带凭证请求时要注意两条:

  1. 服务端 allow_credentials=True 时,allow_origins 不能是 *,必须是显式来源,否则浏览器会拒绝。
  2. 带自定义头(如 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 分类处理