自制 PMTiles 地图(最简单例子)
本页目的: 用自己的数据(不依赖 OSM 底图)制作一张最简单的 PMTiles 地图并渲染。 流程:GeoJSON → Tippecanoe →
.pmtiles→ MapLibre,全程本地完成。前置:需要安装 Tippecanoe(见 PMTiles 格式与工具链)。 进阶:从 OSM 构建完整底图(含道路/水系/地名)参考官方 Building Tiles,见 PMTiles 格式与工具链。
1. 准备数据(GeoJSON)
示例坐标是上海(与上海项目一致,仅作演示);可换成任意经纬度。 注意:自制文件只含自己的数据,不含 OSM 底图;pmtiles.io 会叠加它内置的 Protomaps 底图 (界面 basemap 开关,默认关、黑色风格)——关掉开关就只剩自己的点。
以 3 个咖啡店点为例,保存为 coffee.geojson:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "name": "咖啡A" },
"geometry": { "type": "Point", "coordinates": [121.47, 31.23] }
},
{
"type": "Feature",
"properties": { "name": "咖啡B" },
"geometry": { "type": "Point", "coordinates": [121.49, 31.21] }
},
{
"type": "Feature",
"properties": { "name": "咖啡C" },
"geometry": { "type": "Point", "coordinates": [121.44, 31.26] }
}
]
}
怎么取坐标(地址 → 经纬度)
| 方式 | 说明 |
|---|---|
| geojson.io | 搜地址 → 地图点选 → 自动生成 GeoJSON,直接就是本页格式 |
| 自己的地图点选 | test-map.html 加 map.on("click", e => console.log(e.lngLat)),点哪取哪 |
| 高德拾取坐标 | https://lbs.amap.com/tools/picker 搜中文地址 |
| OSM Nominatim | curl "https://nominatim.openstreetmap.org/search?format=json&q=上海南京东路100号"(代码场景) |
⚠️ 坐标系坑: 高德/百度拾取的是 GCJ-02 火星坐标(加密偏移),直接用于 OSM/MapLibre(WGS-84)会偏移约 300~500 米; 用 geojson.io / Nominatim 取坐标则无此问题,或自行 GCJ-02 → WGS-84 转换。
实例:地址 → 单点 pmtiles(上海东方体育中心)
以高德拾取的坐标 121.48, 31.16(GCJ-02)为例,完整流程:
1. GCJ-02 → WGS-84 转换(高德坐标必须转,否则偏移 300~500 米):
python3 <<'EOF'
import math
def _tlat(x, y):
r = -100.0 + 2*x + 3*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x))
r += (20*math.sin(6*x*math.pi) + 20*math.sin(2*x*math.pi)) * 2/3
r += (20*math.sin(y*math.pi) + 40*math.sin(y/3*math.pi)) * 2/3
r += (160*math.sin(y/12*math.pi) + 320*math.sin(y*math.pi/30)) * 2/3
return r
def _tlng(x, y):
r = 300.0 + x + 2*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x))
r += (20*math.sin(6*x*math.pi) + 20*math.sin(2*x*math.pi)) * 2/3
r += (20*math.sin(x*math.pi) + 40*math.sin(x/3*math.pi)) * 2/3
r += (150*math.sin(x/12*math.pi) + 300*math.sin(x/30*math.pi)) * 2/3
return r
def gcj2wgs(lng, lat):
dlat = _tlat(lng-105, lat-35); dlng = _tlng(lng-105, lat-35)
rad = lat/180*math.pi; magic = math.sin(rad); magic = 1 - 0.00669342162296594323*magic*magic
sq = math.sqrt(magic)
dlat = dlat*180/((6378245*(1-0.00669342162296594323))/(magic*sq)*math.pi)
dlng = dlng*180/(6378245/sq*math.cos(rad)*math.pi)
return lng*2-(lng+dlng), lat*2-(lat+dlat)
print('%f, %f' % gcj2wgs(121.48, 31.16))
EOF
# 输出:121.475504, 31.161994 ← 这就是 WGS-84 坐标
2. GeoJSON(用转换后的 WGS-84 坐标):
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "name": "上海东方体育中心" },
"geometry": { "type": "Point", "coordinates": [121.475504, 31.161994] }
}
]
}
3. tippecanoe 转 PMTiles(单点不能用 -zg,需显式 zoom):
tippecanoe -Z0 -z14 --projection=EPSG:4326 \
-o oriental-sports.pmtiles \
-l poi oriental-sports.geojson
4. 验证与测试:
pmtiles show --metadata oriental-sports.pmtiles | python3 -c "import sys,json; d=json.load(sys.stdin); print('图层:', [l.get('id') for l in d.get('vector_layers', [])])"
# 图层: ['poi']
浏览器测试:pmtiles.io 拖入文件,或本地 test-map.html 里 source-layer 用 poi、url: "pmtiles:///oriental-sports.pmtiles"。
2. Tippecanoe 转为 PMTiles
| 参数 | 说明 |
|---|---|
-zg | 自动检测最佳最大缩放级别(单点数据不可用) |
-Z0 -z14 | 单点/少量点:显式指定 minzoom/maxzoom |
-o coffee.pmtiles | 输出文件 |
-l coffee | 图层名(对应 MapLibre 的 source-layer,必须一致) |
验证:
pmtiles show --metadata coffee.pmtiles | python3 -c "import sys,json; d=json.load(sys.stdin); print('图层:', [l.get('id') for l in d.get('vector_layers', [])])"
# 图层: ['coffee']
3. MapLibre 渲染
coffee.pmtiles 放到 http-server 根目录,页面用 circle 图层显示点:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>My Coffee Map</title>
<style>body { margin: 0 } #map { width: 100vw; height: 100vh }</style>
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/pmtiles@3/dist/pmtiles.js"></script>
</head>
<body>
<div id="map"></div>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
coffee: {
type: "vector",
url: "pmtiles:///coffee.pmtiles",
},
},
layers: [
{
id: "coffee-circle",
type: "circle",
source: "coffee",
"source-layer": "coffee", // 与 tippecanoe -l 一致
paint: {
"circle-color": "#3b82f6",
"circle-radius": 8,
},
},
],
},
center: [121.47, 31.23],
zoom: 11,
});
</script>
</body>
</html>
cd /path/to/pmtiles
npx -y http-server . --cors --port 8080
# 访问 http://localhost:8080/coffee-map.html 看到 3 个蓝色圆点
关键点:
source-layer必须等于 tippecanoe 的-l参数(本例coffee),否则地图不显示。
4. 叠加到已有底图(多 pmtiles 示例)
把 coffee.pmtiles 叠加到 shanghai.pmtiles 底图上:一个 Map 里加两个 source,不需要合并文件。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Shanghai + Coffee</title>
<style>body { margin: 0 } #map { width: 100vw; height: 100vh }</style>
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/pmtiles@3/dist/pmtiles.js"></script>
<script src="https://unpkg.com/@protomaps/basemaps@5/dist/basemaps.js"></script>
</head>
<body>
<div id="map"></div>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light",
sources: {
// source 1:shanghai 底图(9 个图层)
protomaps: {
type: "vector",
url: "pmtiles:///shanghai.pmtiles",
attribution: '© <a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
},
// source 2:自己的数据
coffee: {
type: "vector",
url: "pmtiles:///coffee.pmtiles",
},
},
layers: [
// shanghai 底图的完整样式(道路/水系/地名等)
...basemaps.layers("protomaps", basemaps.namedFlavor("light"), { lang: "zh" }),
// 自己的咖啡点(叠加在上层)
{
id: "coffee-circle",
type: "circle",
source: "coffee",
"source-layer": "coffee",
paint: {
"circle-color": "#ef4444",
"circle-radius": 8,
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
},
},
],
},
center: [121.47, 31.23],
zoom: 11,
});
</script>
</body>
</html>
# 两个 pmtiles 文件放同一目录
cd /path/to/pmtiles
npx -y http-server . --cors --port 8080
# 访问 http://localhost:8080/overlay.html:完整上海底图 + 红色咖啡点
想分发单文件时再合并:
tile-join -o combined.pmtiles shanghai.pmtiles coffee.pmtiles(图层名不能冲突;pmtiles merge只支持不相交档案,不适用)。
5. 进阶
- 点变标签:加一个 symbol 图层,
layout: { "text-field": ["get", "name"] }显示店名 - 区域/线数据:GeoJSON 换成 Polygon / LineString 即可,图层类型用
fill/line - 数据更新:改 GeoJSON 后重新跑 tippecanoe 覆盖
coffee.pmtiles即可 - 从 OSM 构建完整底图:官方 Building Tiles(Planetiler 管线),见 PMTiles 格式与工具链
Backlinks (2)
flowchart LR
n0["AI"]
n1["Database"]
n2["Dev Tools"]
n3["Emoji"]
n4["Frontend"]
n5["Game Dev"]
n6["Collection"]
n7["Languages"]
n8["Maps"]
n9["Media"]
n10["Monitor"]
n11["Plans"]
n12["Cloud"]
n13["对象存储基本用法(Bucket / Object / 常用操作)"]
n14["对象存储数据迁移(R2 → MinIO / 跨厂商搬迁)"]
n15["Object Storage"]
n16["挂载 Bucket 为本地文件系统(FUSE Mount)"]
n17["对象存储签名 URL(Signed URL)原理与实战"]
n18["对象存储供应商对比(S3 / R2 / OSS / Supabase / MinIO)"]
n19["Prototypes"]
n20["Research"]
n21["Better Auth 源码阅读指南"]
n22["DuckDB 环境与基本使用"]
n23["DuckDB 实战研究"]
n24["DuckDB 模拟数据"]
n25["PostgreSQL 数据用 DuckDB 加速查询"]
n26["HK 角色阅读"]
n27["HK 台词阅读"]
n28["Hollow Knight 英语主题"]
n29["HK 物品阅读"]
n30["HK 地点阅读"]
n31["HK 世界观 / Lore 阅读"]
n32["HK 英语学习素材清单"]
n33["英语学习 Dashboard"]
n34["English Scraps Archive"]
n35["English Scraps 使用指南"]
n36["Jellyfin 源码阅读指南"]
n37["Lux 资料整理"]
n38["Nest Commander 学习资料"]
n39["NestJS 源码阅读指南"]
n40["Protomaps 自建底图研究"]
n41["自制 PMTiles 地图(最简单例子)"]
n42["MapLibre 集成 Protomaps"]
n43["PMTiles 格式与工具链"]
n44["上海地区底图项目"]
n45["Redash 源码阅读指南"]
n46["Rust 学习计划"]
n47["shadcn/ui 源码阅读指南"]
n48["TRIP 项目核心原理与代码阅读指南"]
n1 --> n23
n6 --> n0
n6 --> n1
n6 --> n2
n6 --> n3
n6 --> n4
n6 --> n5
n6 --> n7
n6 --> n8
n6 --> n9
n6 --> n10
n6 --> n11
n6 --> n20
n12 --> n15
n13 --> n14
n13 --> n17
n13 --> n18
n13 --> n19
n14 --> n13
n14 --> n16
n14 --> n17
n14 --> n18
n15 --> n13
n15 --> n14
n15 --> n16
n15 --> n17
n15 --> n18
n16 --> n13
n16 --> n17
n16 --> n18
n17 --> n19
n18 --> n13
n18 --> n14
n18 --> n17
n18 --> n19
n19 --> n13
n19 --> n16
n19 --> n17
n19 --> n18
n19 --> n40
n20 --> n21
n20 --> n23
n20 --> n33
n20 --> n36
n20 --> n37
n20 --> n38
n20 --> n39
n20 --> n40
n20 --> n45
n20 --> n46
n20 --> n47
n20 --> n48
n22 --> n24
n23 --> n1
n23 --> n20
n23 --> n22
n23 --> n24
n23 --> n25
n24 --> n22
n24 --> n25
n25 --> n23
n25 --> n24
n28 --> n26
n28 --> n27
n28 --> n29
n28 --> n30
n28 --> n31
n28 --> n32
n28 --> n35
n32 --> n27
n33 --> n28
n33 --> n34
n33 --> n35
n35 --> n33
n35 --> n34
n40 --> n8
n40 --> n41
n40 --> n42
n40 --> n43
n40 --> n44
n41 --> n43
n42 --> n19
n42 --> n44
n43 --> n41
n43 --> n44
n44 --> n42
n44 --> n43
click n0 "../../../../collection/ai/" "AI"
click n1 "../../../../collection/database/" "Database"
click n2 "../../../../collection/dev-tools/" "Dev Tools"
click n3 "../../../../collection/emoji/" "Emoji"
click n4 "../../../../collection/frontend/" "Frontend"
click n5 "../../../../collection/game-dev/" "Game Dev"
click n6 "../../../../collection/" "Collection"
click n7 "../../../../collection/languages/" "Languages"
click n8 "../../../../collection/maps/" "Maps"
click n9 "../../../../collection/media/" "Media"
click n10 "../../../../collection/monitor/" "Monitor"
click n11 "../../../../collection/scraps/plans/" "Plans"
click n12 "../../../../knowledge/infrastructure/cloud/" "Cloud"
click n13 "../../../../knowledge/infrastructure/cloud/object-storage/basic-usage/" "对象存储基本用法(Bucket / Object / 常用操作)"
click n14 "../../../../knowledge/infrastructure/cloud/object-storage/data-migration/" "对象存储数据迁移(R2 → MinIO / 跨厂商搬迁)"
click n15 "../../../../knowledge/infrastructure/cloud/object-storage/" "Object Storage"
click n16 "../../../../knowledge/infrastructure/cloud/object-storage/mount-bucket/" "挂载 Bucket 为本地文件系统(FUSE Mount)"
click n17 "../../../../knowledge/infrastructure/cloud/object-storage/signed-url/" "对象存储签名 URL(Signed URL)原理与实战"
click n18 "../../../../knowledge/infrastructure/cloud/object-storage/vendors-comparison/" "对象存储供应商对比(S3 / R2 / OSS / Supabase / MinIO)"
click n19 "../../../../prototypes/" "Prototypes"
click n20 "../../../" "Research"
click n21 "../../better-auth/" "Better Auth 源码阅读指南"
click n22 "../../duckdb/basic-usage/" "DuckDB 环境与基本使用"
click n23 "../../duckdb/" "DuckDB 实战研究"
click n24 "../../duckdb/mock-data/" "DuckDB 模拟数据"
click n25 "../../duckdb/postgresql-acceleration/" "PostgreSQL 数据用 DuckDB 加速查询"
click n26 "../../english/hollow-knight/characters/" "HK 角色阅读"
click n27 "../../english/hollow-knight/dialogues/" "HK 台词阅读"
click n28 "../../english/hollow-knight/" "Hollow Knight 英语主题"
click n29 "../../english/hollow-knight/items/" "HK 物品阅读"
click n30 "../../english/hollow-knight/locations/" "HK 地点阅读"
click n31 "../../english/hollow-knight/lore/" "HK 世界观 / Lore 阅读"
click n32 "../../english/hollow-knight/resources/" "HK 英语学习素材清单"
click n33 "../../english/" "英语学习 Dashboard"
click n34 "../../english/scraps/archive/" "English Scraps Archive"
click n35 "../../english/scraps/" "English Scraps 使用指南"
click n36 "../../jellyfin/" "Jellyfin 源码阅读指南"
click n37 "../../lux/" "Lux 资料整理"
click n38 "../../nest-commander/" "Nest Commander 学习资料"
click n39 "../../nestjs/" "NestJS 源码阅读指南"
click n40 "../" "Protomaps 自建底图研究"
click n41 "./" "自制 PMTiles 地图(最简单例子)"
click n42 "../maplibre/" "MapLibre 集成 Protomaps"
click n43 "../pmtiles/" "PMTiles 格式与工具链"
click n44 "../shanghai-map/" "上海地区底图项目"
click n45 "../../redash/" "Redash 源码阅读指南"
click n46 "../../rust/" "Rust 学习计划"
click n47 "../../shadcn-ui/" "shadcn/ui 源码阅读指南"
click n48 "../../trip/" "TRIP 项目核心原理与代码阅读指南"